Plugin registry labelling service#1909
Conversation
🦋 Changeset detectedLatest commit: 0beb062 The changes in this PR will be included in the next version bump. This PR includes changesets to release 22 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 0beb062 | Jul 16 2026, 07:20 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 0beb062 | Jul 16 2026, 07:20 PM |
Scope checkThis PR changes 3,014 lines across 3 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-moderation
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 0beb062 | Jul 16 2026, 07:20 PM |
…labelling-service
Execution ledger
Wave 0A coordinator verification:
No umbrella checklist item is marked complete until the corresponding draft PR is ratified, merged, and reverified on the integration branch. |
* docs: define labeller gate 0 contracts * docs: record contract ratification
* docs: audit registry label vocabulary * docs: record vocabulary ratification
* test: prove label crypto interoperability * test: keep crypto vector with retained tests * docs: record crypto ratification
Wave 0A complete
Gate decisions recorded:
The Cloudflare API connector and local Wrangler OAuth token both returned |
…labelling-service # Conflicts: # pnpm-lock.yaml
…2068) * feat(labeler): notification contact-state foundation (W10.4 slice B) Add the opt-in and suppression state for publisher notifications: `notification_contacts` (confirm state machine with single-use, constant-time-compared token hashes and a confirmation rate-limit timestamp) and `notification_suppressions` (bounce/complaint/ unsubscribe/not-me, earliest reason retained), plus the peppered HMAC-SHA256 recipient-hash helper and a typed query layer. Both tables key on the recipient hash only — no plaintext address column exists; plaintext will live solely on the future W10.5 delivery row. Distinct from the operator `notification_outbox` alert subsystem. * fix(labeler): harden notification contact state transitions - recordConfirmSent/confirmContact gate the UPDATE on confirm_state = 'unconfirmed', so a confirmed or declined row is never reopened and a stale token can never flip it; recordConfirmSent returns whether a row was touched. - suppress takes an explicit finite epochMs instead of re-parsing the ISO string. - recipientHash caches the imported HMAC key per pepper on globalThis. * fix(labeler): complete notification contact state guards - declineContact gates on confirm_state = 'unconfirmed' and returns a boolean, so an unsubscribe-path bug can't revoke a confirmed opt-in. - recordConfirmSent rejects a non-finite epochMs (mirrors suppress) to keep NaN out of the rate-gate comparison. - confirmContact always runs the constant-time token compare, against a fixed-length dummy when the row or token is absent, so elapsed time never reveals recipient existence or a pending token. * test(labeler): validate the PRAGMA table identifier before interpolation * test(labeler): allowlist the PRAGMA table identifier
* feat(labeler): verified artifact acquisition (W7.2) Resolves a verified release's package artifact from the preferred source, fetches it under the shared SSRF-hardened controls, verifies the bytes against the signed checksum, and unpacks the canonical bundle into the analysis file set. Composes @emdash-cms/registry-verification (guarded fetch, multihash verification, bundle validation) and adds the labeler's mirror-first source preference, declared-URL fallback, and acquisition-category classification. Every failure is classified so that transport and capability faults on unverified bytes retry into assessment-error and never a public label, while only checksum/coordinate integrity faults and content-policy bundle rejections become deterministic blocking findings (spec §9.4). Non-UTF-8 code files are rejected rather than silently dropped, keeping the analyzed set a superset of the executable set. Mirror-ready seam: v1 ships no mirror binding, so every acquisition resolves via the publisher's declared URL. Extends validatePluginBundle with a validated file inventory (files) so consumers extract analysis inputs without re-parsing the bundle. * docs(labeler): align acquisition size-cap and mirror-key comments with §9.4 classification * docs(labeler): correct crossCheckCoordinates comment for transient malformed-checksum classification
…scope (W8.4 slice 3)
…#2071) * feat(labeler): publisher-notification token endpoints (W10.4 slice C) Add the three public, token-authenticated landing endpoints an email recipient clicks: /notifications/confirm, /notifications/unsubscribe, and /notifications/not-me. Mounted outside /admin/* so neither the Cloudflare Access edge policy nor the operator guard applies -- the capability in the link is the sole authority. GET renders a confirmation page with a POST form; only POST mutates, so an email scanner's automated GET never confirms or suppresses. Responses are neutral and uniform regardless of token validity or on-file status, with the real outcome logged server-side only (never the token). Confirm always runs confirmContact's constant-time compare and never confirms a suppressed contact. Unsubscribe/not-me record an idempotent, hash-keyed suppression and decline any pending confirmation. Adds hashConfirmToken to notification-contacts (SHA-256 of the raw token, the stored confirm_token_hash form) as the shared verify/send contract. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(labeler): gate notification suppression on contact existence Address the adversary pass on W10.4 slice C. Unsubscribe/not-me now write a suppression only when a contact row already exists for the hash. A recipient who legitimately received mail always has one (ensureContact created it, and contacts are never swept), so gating costs legit users nothing while an attacker who fabricates a well-formed but unseen recipient hash writes no orphan suppression row. The response stays byte-identical (the caller only ever sees the neutral done page). Adds a read-only contactExists helper. Document the token-entropy cross-slice dependency at hashConfirmToken (and the confirm path): the pepper-less SHA-256 lookup with no in-worker rate limit is safe only if the W10.5 send path draws the token from a CSPRNG at >=128 bits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(labeler): deny framing on notification landing pages (clickjacking defense-in-depth) * fix(labeler): tighten notification-page CSP to lock down script/object/form-action --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…ice-3 foundation) (#2070) * feat(labeler): aggregator service binding + read-only client Add the AGGREGATOR service binding to the labeler Worker and a typed, read-only client over the aggregator's XRPC surface (getPackage, getLatestRelease, listReleases). Fetch-over-binding matching the perf-monitor pattern; the aggregator stays HTTP/XRPC-only with no RPC entrypoint. Each method does exactly one fetch per call (no caching/retry/memoization, per W10.4), sends no atproto-accept-labelers header so internal reads see the unfiltered view, and URL-encodes every param value against fixed-constant NSIDs. XRPC NotFound resolves to null; other non-2xx and transport failures throw. Stub the binding in vitest.config.ts so the workerd pool can start. * fix(labeler): send blank accept-labelers header for genuinely unfiltered aggregator reads Omitting the header applies the aggregator's default trusted-labeler redaction, so a redacted subject presents as NotFound/null — silently blinding the labeler's own analysis to the subjects it most needs to see. Send a blank value to opt out. Documents the analysis-only boundary and the checksum re-verification downstream. * test(labeler): prove blank accept-labelers header survives the aggregator binding transport * fix(labeler): treat empty listReleases cursor as first page, not a malformed 400
…ope note (W10.4 slice-A adversary) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…edaction policy (W8.4) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…10.4 slice A) (#2073) * feat(labeler): publisher contact resolution via aggregator binding (W10.4 slice A) Resolve a package's takedown/notification email by walking three tiers in spec order -- package security[], package authors[], then the publisher profile contact[] (preferring kind:security within the tier) -- through the aggregator service binding with a fresh single read per tier and no caching. url-only contacts are skipped, not treated as resolved; "no resolvable contact" is an expected terminal outcome, not an error, and only a transport failure throws. Seed the double-opt-in state row on resolution: hash the email via recipientHash and ensureContact, honoring suppression so a victim named in hostile metadata is never re-seeded into a fresh unconfirmed state, and never resetting an already-confirmed contact. Plaintext email is held in memory only -- hashed immediately, never persisted or logged (logs carry the public DID and an 8-char hash prefix). Sending the confirmation mail is W10.5; this seam resolves and seeds only. Tier 3 (the publisher profile contact[]) was already indexed in the aggregator (publishers.contact) but not exposed by any read, so add a minimal getPublisher XRPC query and publisherView definition, mirroring getPackage's label hydration and takedown redaction. Add a getPublisher client method and extend the test AGGREGATOR stub to serve realistic getPackage/getPublisher views so the resolver is exercised end to end through the real binding. * test(aggregator): getPublisher redaction on the profile record URI Mirrors the getPackage symmetric test: a !takedown on the publisher profile URI (not just the DID subject) redacts getPublisher to 404, closing the coverage parity gap.
…W8.4 slice 3) (#2074) * feat(labeler): verification-state history input via aggregator read (W8.4 slice 3) Adds the publisher verification-state input to analyzeHistory, the first of W8.4 slice 3's two aggregator-sourced history inputs. - registry-lexicons: new getPublisherVerification query + publisherVerificationView / verificationClaimView defs, returning the non-tombstoned verification claims indexed for a publisher DID. - aggregator: read-only route over publisher_verifications, redaction-gated on the publisher DID exactly like getPackage (a blank accept-labelers header opts out, so an internal caller reads unfiltered). - labeler: AggregatorClient.getPublisherVerification; analyzeHistory consumes it behind an optional reader and emits a publisher-verification history finding with issuer/handle specifics in privateDetail only. The aggregator read has its own try/catch so a failure never discards the own-D1 findings. The second slice-3 input, recent handle/profile changes, is not built: the aggregator keeps only current profile state (publishers is upserted) and does not ingest the atproto identity events that carry handle changes, so no change history exists to expose. Blocked on ingestion, not a read path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(aggregator): redact per-issuer on getPublisherVerification (W8.4 slice 3 review) Each verification claim is authored by its issuer's repo, so a subject-keyed lookup could resurface a taken-down issuer's vouch. Hydrate labels for the subject DID and every distinct issuer DID in one batched call; a redacted subject 404s the whole view, a redacted issuer drops only its own claim. A blank accept-labelers header (the internal labeler read) has an empty accepted set, so nothing is filtered and every claim is seen. Also correct the shipped docs: the read is NOT indistinguishable from absence (a non-redacted DID returns 200, possibly with an empty verifications array; only a redacted subject 404s), and it returns claims regardless of expiry — expiresAt is present for the consumer to evaluate in-force state, not a server-side filter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…1978) (#2072) * feat(labeler): assessment Workflow shell with per-subject lock Wire the AssessmentOrchestrator to production as a Cloudflare Workflow: each verified subject runs as one Workflow instance whose id is a deterministic SHA-256 of (uri, cid). Creating an instance with an existing id throws, so a duplicate subject cannot start a second concurrent run — the instance-per- subject serialization IS the §14.1 per-subject lock, closing the duplicate-run race #1978 deferred. - assessment-workflow.ts: AssessmentWorkflow (WorkflowEntrypoint) runs the orchestrator inside a single durable step over executeAssessmentInstance, which loads the pending run, composes the orchestrator (stub stages for now), and finalizes. Idempotent resume: an already-finalized run returns its terminal state instead of re-entering the pending-guarded orchestrator. - assessment-dispatch.ts: instance-id derivation + dispatchAssessmentWorkflow, which treats an already-exists collision as an idempotent no-op and surfaces infra failures as AssessmentDispatchError. - discovery-consumer.ts narrows to "verified discovery → dispatch"; a dispatch failure retries the message (upstream steps are idempotent). - ASSESSMENT_WORKFLOW binding in wrangler.jsonc + worker entry export. Stages are stubStages until W7/W8 land the real acquire/deterministic/ dependency/AI/history adapters; the per-run AcquisitionHolder they need is constructed at the per-run buildStages() seam. The orchestrator's finalization race comments are corrected: the lock closes duplicate runs, not a delete or cancel racing an in-flight run — that still needs an in-batch state guard. * fix(labeler): runKey-derived Workflow id + resumable-from-running Address the adversary pass on the Workflow shell. Fix B (Matt's ruling — instance id from runKey, not subject): a subject-only id (SHA-256 of uri+cid) is permanent (Cloudflare retains instance ids ~30 days for completed AND failed runs), so it blocked all re-assessment — operator rerun / intel re-triggers mint a new run (new runKey, which already includes triggerId) for the same subject and would collide with the retained id and strand the run pending forever. The instance id is now the run's runKey itself (already a 64-char SHA-256 hex from computeRunKey, reused verbatim — no second formula). Same-trigger discovery redelivery → same runKey → same id → dedup; distinct triggers → distinct runKeys → distinct instances → re-assessment works. Two different triggers for one subject can now run concurrently — that falls back to the currency/supersede + idempotency-keyed issuance path, not the instance lock. Fix A (resumable-from-running): any failure after the pending→running CAS (reachable even with stub stages) left the row `running`; the step retry re-entered runAssessment, whose pending-guard threw again → run stranded `running` forever. runAssessment now accepts a `running` row and resumes it to finalization (stages recomputed, findings held in memory not persisted mid-run, label issuance idempotency-keyed). Minimal orchestrator change: broaden the entry guard + skip the CAS when already `running`. Fix C (deploy gate): with stub stages, a run finalizes `passed` AND issues a real signed assessment-passed label for every subject. Corrected the orchestrator header to say so and added a prominent DEPLOY GATE note in the workflow docblock — the shell must not reach an enforcing/consumed deployment until real stages land. Also: corrected the stale console-mutation rerun comment (discovery now dispatches) and reconciled the orchestrator's finalize race comments to the per-run (runKey) dedup semantics. Track D (delete/cancel/signing-flip vs finalize) remains gated on the in-batch assessment-state guard. Tests: runKey-id dispatch semantics (dedup on same runKey, distinct runKey → own instance); shell resume from a running row; orchestrator resume after a post-transition failure then success. * fix(labeler): close finalization label-leak race + mechanical deploy gate Address emdashbot review findings on #2072. Finding 2 (finalization CAS race): the run→toState CAS and the label inserts shared one db.batch, but a 0-row CAS (concurrent cancel/delete moved the run out of `running`) did not roll back the inserts — signed assessment-passed labels leaked for a cancelled subject, and a post-hoc re-read could not un-leak them. Now every finalization issuance action is gated in-batch on the assessment reaching `toState` (buildIssuanceStatements gains an optional requireAssessmentState; the label insert selects from the action, so gating the action gates the label). The batch is all-or-nothing against the CAS: a cancel/delete that no-ops the CAS also no-ops every label. The CAS row count is checked after commit — 0 means the race was lost, so the postCommits (which would otherwise mis-diagnose the absent label as a signing failure and throw) are skipped and AssessmentFinalizationConflictError is raised; the Workflow step retries and short-circuits on the now-terminal row. The existing second isSubjectCurrent check only covers delete/supersede down to the check-to-commit gap (the delete path tombstones the subject); it does not cover an operator cancel that leaves the subject current. The in-batch state guard closes both. Finding 1 (deploy gate): buildStages() now throws when import.meta.env.PROD, so a production build with only stub stages fails loudly instead of signing assessment-passed for every unscanned subject. import.meta.env.PROD is a Vite compile-time constant (true in `vite build`, false in dev and the vitest pool); a minimal ambient declaration types it without pulling in vite/client browser globals. Test: a stage that cancels the run mid-flight proves finalization issues no labels and throws the conflict.
…nd slicing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ice-2 contracts Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g/failed rows Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… slice 2 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…d-core (W10.5 slice 1) (#2076) * feat(labeler): gated notification send-core + CSPRNG confirm token (W10.5 slice 1) Migration 0008 adds the publisher-facing `notifications` delivery table (polymorphic issuance/operator source, confirmation|notice kind, pending/sent/failed/undeliverable state, plaintext cleared on success) and a per-DID `notification_confirm_ledger` for distinct-recipient rate limiting. `generateConfirmToken` draws a 256-bit base64url token from `crypto.getRandomValues`; only its SHA-256 digest is persisted via the existing `recordConfirmSent`. `sendNotification` orchestrates one delivery against an injected `NotificationSender`: fresh contact resolve, recipient hash, atomic suppression-guarded claim, and a confirm-state gate (never `seeded:true`) — confirmed => substantive notice; unconfirmed => a confirmation mail only when both the per-address and per-DID limits pass; declined/suppressed/no-contact => undeliverable, no send. The real email adapter and triggers are slice 2. * fix(labeler): close double-mail race + harden notification send-core (W10.5 slice 1 review) Adversary-pass fixes: - recordConfirmSent is now a compare-and-set: the UPDATE re-checks the per-address interval in SQL (last_confirm_sent IS NULL OR <= epochMs - minIntervalMs), so two triggers racing for the same unconfirmed address off a stale canSendConfirm snapshot can no longer both mail the victim. minIntervalMs is an optional param (default 0 for seeding callers); the send path passes CONFIRM_MIN_INTERVAL_MS. - Per-DID ledger: count and ledger-write moved adjacent to narrow the overshoot window (best-effort, bounded; exact serialization deferred to slice 2). The gate still returns before any write, so a sustained DID email-bomb costs no rows. - Migration: CHECK (state = 'undeliverable' OR recipient_hash IS NOT NULL); a sendable row must carry a hash. - finalizeSend / markRowUndeliverable UPDATEs gated on state = 'pending' — one-shot terminal transition, no double-finalize. - NotificationSender doc: slice-2 impls must not leak confirmUrl/token into the returned error string (persisted verbatim in last_error). New concurrency test proves the CAS: two Promise.all sends to one unconfirmed victim yield exactly one confirmation (mail once, loser row undeliverable). * fix(labeler): lifetime confirmation cap via atomic claim guard (W10.5 slice 1) Supersedes the interval-CAS: a confirmation mail now reaches an address at most ONCE EVER. The confirmation claimSend gains a second guard in the same INSERT...SELECT — AND NOT EXISTS (a prior notifications row for the hash with kind='confirmation' AND state != 'undeliverable'). This one atomic statement enforces suppression, the lifetime cap, and the concurrency race (of two racing sends exactly one inserts; the loser writes no row). An undeliverable prior (race loser / suppressed-at-claim that never mailed) never blocks a later send. - recordConfirmSent reverts to its state-only guard (drops the minIntervalMs param); it only stamps the token now. - Per-address interval gating removed from the send path; CONFIRM_MIN_INTERVAL_MS deleted (unreferenced). canSendConfirm left as-is (slice-B/C utility). - Ledger write moved after a successful claim, so a repeat trigger for an already-mailed address writes nothing and cannot bloat the per-DID ledger. - New outcome `already_mailed` for a claim blocked by the lifetime cap (suppression re-checked cheaply first for audit precision). Tests prove it: concurrent Promise.all sends mail exactly once (loser row-less); no second mail after a sent OR failed confirmation; an undeliverable-only prior still mails. All three fail with the guard removed. * fix(labeler): address emdashbot review on #2076 (ledger ordering, sent_at, suppress order) - Move recordConfirmLedgerEntry to after recordConfirmSent succeeds (still before send). A contact_state_changed abort now writes no ledger row, so it no longer eats a distinct-recipient slot — matching the comment's own contract. A failed SEND still consumes budget (the conservative fail-safe). - markRowUndeliverable no longer sets sent_at: a never-sent undeliverable row must not carry a provider-accepted timestamp (dropped the now param too). - Check isSuppressed before ensureContact in sendNotification (matching seedPublisherContact), so a suppressed address is never seeded with an unconfirmed contact row. New test drives the contact_state_changed abort via a DB proxy that flips the contact to declined during the per-DID count (after the state read, before the stamp) and asserts no ledger row + an undeliverable row with sent_at NULL; it fails if the ledger write is moved back before the stamp.
….5 slice 2) (#2077) * feat(labeler): email adapter, issuance triggers, and retry sweep (W10.5 slice 2) Cloudflare Email Sending adapter + the five ratified notification triggers + the cron retry sweep, completing the publisher-notification subsystem on top of slice 1's gated send-core. CloudflareEmailSender (notification-email.ts) implements NotificationSender over the unrestricted `send_email` binding (arbitrary external recipients): structured env.EMAIL.send() with html+text bodies, List-Unsubscribe + one-click headers on every message, from-address from a var. Thrown coded errors map to SendResult CODE ONLY (never the provider message or payload) — E_RECIPIENT_SUPPRESSED carries a `suppress: 'bounce'` discriminant the send-core turns into our own suppression + an undeliverable, never-retried row; other codes are retryable. Triggers (notification-triggers.ts) fire post-commit from the deferred tail at the five events — automated block/warning (assessment-workflow), reviewer label issue/retract, override, override-retract, and emergency takedown (console-mutation) — building a public-safe NoticeContent and calling sendNotification. A notification failure never touches the label: every entry point swallows and logs. Per-source dedup (a source already carrying a notifications row is skipped) plus an atomic per-source guard on the notice claim give the "one notice per action" invariant even under a replay race. Verified publishers (in-force verification claim, reused from history-context's expiry logic) skip double opt-in via a token-less confirm; the verification read fails CLOSED to the confirmation-mail path, and suppressed/declined addresses get nothing regardless. Cron sweep (notification-sweep.ts) re-drives `failed` and crash-stuck `pending` rows (attempts-capped via a CAS claim), abandoning the exhausted to `undeliverable` — which clears plaintext and reopens a confirmation's lifetime cap so the channel is never silently foreclosed. Confirmation retries mint a FRESH token (only the hash was stored); notice retries re-render public content from source (no extra PII persisted). Terminal rows and stale ledger entries are pruned, but a sent confirmation row is kept to hold the lifetime cap. Tests: adapter (success/providerId, per-code mapping, suppression discriminant, List-Unsubscribe headers, no-payload-in-error), triggers (each of the five events, dedup, verified-skip in-force/expired/read-failure/suppressed, throw isolation), sweep (retry->sent, fresh-token stamp, cap exhaustion + reopen, stuck-pending, provider suppression, 30-day cleanup, ledger prune). vitest runs bindings locally (remoteBindings:false) so the unrestricted send_email binding never proxies to real Cloudflare. * fix(labeler): sweep suppression guard, suppress-before-flip, transient-read self-heal (W10.5 slice 2 review) Adversary-pass fixes: - F1 (HIGH): the retry sweep re-mailed suppressed addresses. `claimRetry` had no suppression guard and the row was never re-checked, so a confirmed publisher whose notice failed transiently and who then clicked Unsubscribe (leaving a `failed` row, since declineContact only touches unconfirmed contacts) got re-mailed on the next cron, up to the attempt cap. `sweepRow` now re-checks `isSuppressed` before the claim and ABANDONS the row (undeliverable + plaintext cleared) rather than skipping — a skip would leave the suppressed failed row as a candidate forever, since retention only prunes terminal rows. Covers notice and confirmation uniformly. - F2 (MEDIUM): both suppression paths flipped the row to `undeliverable` (which reopens a confirmation's lifetime cap) BEFORE recording the suppression, so a concurrent live claim in the D1 round-trip gap saw cap-open + suppression-absent and could send a second mail. `markProviderSuppressed` (send-core) and `markSuppressed` (sweep) now `suppress()` FIRST, then flip: before the suppress write the still-non-undeliverable row blocks a concurrent claim via the lifetime guard, after it via the suppression guard — no admitting window. A crash between the two is covered by F1's new isSuppressed→abandon check. - F3 (LOW): the sweep's `loadAssessmentSafe` blanket-caught every getAssessment error and abandoned the notice, so a transient D1 read permanently dropped a legitimate block/warning notice. It now returns null only for a `TypeError` (malformed id — never a stored source_id) and rethrows anything else, so the claimed row stays `pending` and self-heals on the next pass, matching the operator path. Tests: sweep does NOT mail a suppressed address (notice + confirmation) and retires the row undeliverable; a transient assessment read leaves the notice row pending (attempts bumped, not abandoned). * fix(labeler): notify-deps failure never blocks a console label action The console mutation path built notify deps with a bare `await createNotifyDeps(env)` before `handleConsoleMutation`, so a deps-construction failure (e.g. a missing NOTIFICATION_HASH_PEPPER or a rejecting binding) threw before the label mutation ran — violating the invariant that a notification concern never blocks or fails a label. The workflow and scheduled paths already guard this; the console path did not. Wrap construction in `safeCreateNotifyDeps`, which logs under the `[notifications]` prefix and returns `undefined` on failure. The mutation proceeds and issues the label; the defer helpers already no-op when `notify` is undefined, so notifications degrade rather than the label failing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Record the operator-side case model (reconsiderations + immutable reconsideration_notes), outcome vocabulary (granted/denied/withdrawn), the three console mutation actions, and the ratified 'pipeline notice on every resolve' outcome-notice decision. Note that requirements 1-2 (published contact instructions, opaque assessment ID) are already satisfied by the public policy document and existing public assessment id. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What does this PR do?
Establishes
feat/plugin-registry-labelling-serviceas the integration branch for the plugin registry labelling service described in RFC #694.This PR tracks the complete implementation without sub-issues. Implementation PRs should target
feat/plugin-registry-labelling-service; this umbrella PR remains draft until the production launch gates in the implementation plan are complete.The tracked planning documents are:
.opencode/plans/plugin-registry-labelling-service/spec.md.opencode/plans/plugin-registry-labelling-service/implementation-plan.mdRelated to #694.
Implementation tracker
Design and feasibility
security:yankedtosecurity-yankedcutover (docs: audit registry label vocabulary #1912)security:yankedproduction preflight (zero legacy rows confirmed; Branch A, no compatibility path)Shared protocol and policy
Service foundation and signed distribution
apps/labeler, bindings, CI coverage, and real workerd/D1 tests (feat(registry): add labeler service core #1926)com.atproto.label.queryLabels(feat(registry): add labeler service core #1926)com.atproto.label.subscribeLabels(feat(registry): add label subscriptions #1931)NotSupportedfor deferred standard report intake (feat(registry): add labeler key rotation #1938)Aggregator and client enforcement
atproto-content-labelers(feat(registry): label-aware aggregator reads #1965)Automated assessment
import.meta.env.PRODuntil real stage adapters land in W7/W8)registry-verification; W7 extends it from main)Operator and publisher surfaces
getPublisherread, with confirm/unsubscribe/not-me token endpoints in feat(labeler): publisher-notification token endpoints (W10.4 slice C) #2071 (W10.4 slice C))Production readiness
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs; a workflow extracts catalogs on merge tomain.RFC #694 is the approved design source. Individual implementation PRs carry their applicable changesets and verification evidence. No admin translation catalogs are included.
AI-generated code disclosure
Screenshots / test output
Integrated gate on
2d6ad448after #1952 merged:pnpm buildpassespnpm typecheckand aggregator typecheck passTry this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/plugin-registry-labelling-service. Updated automatically when the playground redeploys.