Skip to content

Plugin registry labelling service#1909

Draft
ascorbic wants to merge 99 commits into
mainfrom
feat/plugin-registry-labelling-service
Draft

Plugin registry labelling service#1909
ascorbic wants to merge 99 commits into
mainfrom
feat/plugin-registry-labelling-service

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Establishes feat/plugin-registry-labelling-service as 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.md

Related to #694.

Implementation tracker

Design and feasibility

Shared protocol and policy

Service foundation and signed distribution

Aggregator and client enforcement

Automated assessment

Operator and publisher surfaces

Production readiness

  • Harden staging/production bindings, CI, deploy validation, and secrets handling
  • Add component metrics, dashboards, logs, and alerts
  • Complete key-management, incident, outage, replay, and recovery runbooks
  • Implement backup, restore, retention, and contact-data minimization
  • Document self-hosting and third-party labeller consumption (operator guide, ATProto reference, moderation model, and deploy/keygen docs landed in docs(labeler): operator guide, ATProto reference, and keygen script #2031; self-hosting and third-party-consumer guides pending)
  • Complete protocol, registry, adversarial, query-count, performance, and browser conformance suites
  • Complete calibration review and external security review
  • Pass key compromise, D1 restore, cursor loss, full replay, queue, AI, mirror, and notification drills
  • Assess every first-party release and enable positive-assessment enforcement through the staged rollout

Type of change

  • Bug fix
  • Feature (requires maintainer-approved Discussion)
  • Refactor (no behavior change)
  • Translation
  • Documentation
  • Performance improvement
  • Tests
  • Chore (dependencies, CI, tooling)

Checklist

  • I have read CONTRIBUTING.md
  • pnpm typecheck passes
  • pnpm lint passes
  • pnpm test passes (or targeted tests for my change)
  • pnpm format has been run
  • I have added/updated tests for my changes (if applicable)
  • User-visible strings in the admin UI are wrapped for translation (if applicable). Do not include messages.po changes except in translation PRs; a workflow extracts catalogs on merge to main.
  • I have added a changeset (if this PR changes a published package)
  • New features link to an approved Discussion: https://github.com/emdash-cms/emdash/discussions/...

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

  • This PR includes AI-generated code; model/tool: OpenCode with GPT-5.6-sol

Screenshots / test output

Integrated gate on 2d6ad448 after #1952 merged:

  • pnpm build passes
  • pnpm typecheck and aggregator typecheck pass
  • Type-aware lint reports 0 warnings and 0 errors
  • Aggregator: 216 tests pass
  • Registry moderation: 66 tests pass
  • Labeler workerd integration: 29 tests pass
  • feat(registry): add label verification primitives #1952 completed two independent review rounds with no remaining high or medium findings

Try 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.

@changeset-bot

changeset-bot Bot commented Jul 10, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0beb062

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 22 packages
Name Type
@emdash-cms/registry-lexicons Minor
@emdash-cms/registry-moderation Patch
@emdash-cms/admin Patch
@emdash-cms/registry-client Patch
emdash Patch
@emdash-cms/plugin-cli Patch
@emdash-cms/registry-verification Minor
@emdash-cms/labeler Patch
@emdash-cms/cloudflare Patch
@emdash-cms/sandbox-workerd Patch
@emdash-cms/fixture-perf-site Patch
@emdash-cms/perf-demo-site Patch
@emdash-cms/cache-demo-site Patch
@emdash-cms/do-demo-site Patch
@emdash-cms/do-solo-demo-site Patch
@emdash-cms/auth Patch
@emdash-cms/blocks Patch
@emdash-cms/gutenberg-to-portable-text Patch
@emdash-cms/x402 Patch
create-emdash Patch
@emdash-cms/auth-atproto Patch
@emdash-cms/plugin-embeds Patch

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

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-playground 0beb062 Jul 16 2026, 07:20 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-do 0beb062 Jul 16 2026, 07:20 PM

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This 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.

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

@emdash-cms/admin

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/admin@1909

@emdash-cms/auth

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth@1909

@emdash-cms/auth-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/auth-atproto@1909

@emdash-cms/blocks

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/blocks@1909

@emdash-cms/cloudflare

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/cloudflare@1909

@emdash-cms/contentful-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/contentful-to-portable-text@1909

emdash

npm i https://pkg.pr.new/emdash-cms/emdash@1909

create-emdash

npm i https://pkg.pr.new/emdash-cms/emdash/create-emdash@1909

@emdash-cms/gutenberg-to-portable-text

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/gutenberg-to-portable-text@1909

@emdash-cms/plugin-cli

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-cli@1909

@emdash-cms/plugin-types

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-types@1909

@emdash-cms/registry-client

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-client@1909

@emdash-cms/registry-lexicons

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-lexicons@1909

@emdash-cms/registry-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-moderation@1909

@emdash-cms/registry-verification

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/registry-verification@1909

@emdash-cms/sandbox-workerd

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/sandbox-workerd@1909

@emdash-cms/x402

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/x402@1909

@emdash-cms/plugin-ai-moderation

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-ai-moderation@1909

@emdash-cms/plugin-atproto

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-atproto@1909

@emdash-cms/plugin-audit-log

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-audit-log@1909

@emdash-cms/plugin-color

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-color@1909

@emdash-cms/plugin-embeds

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-embeds@1909

@emdash-cms/plugin-field-kit

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-field-kit@1909

@emdash-cms/plugin-forms

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-forms@1909

@emdash-cms/plugin-webhook-notifier

npm i https://pkg.pr.new/emdash-cms/emdash/@emdash-cms/plugin-webhook-notifier@1909

commit: 0beb062

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 10, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
emdash-demo-cache 0beb062 Jul 16 2026, 07:20 PM

@github-actions github-actions Bot added the query-count changed PR diff modifies query-count snapshot files label Jul 10, 2026
@ascorbic ascorbic added this to the 1.0 milestone Jul 10, 2026
@ascorbic ascorbic changed the title docs: plan plugin registry labelling service implementation Plugin registry labelling service Jul 10, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Execution ledger

Sequence Workstream PR Status Gate
01 W0.1 + W0.2 contracts and policy fixtures #1910 Draft, ready for maintainer ratification Gate 0
02 W0.3 vocabulary cutover audit #1912 Draft, ready for audit review; production preflight still required Gate 0
03 W0.4 crypto interoperability #1911 Draft, ready for maintainer ratification Gate 0

Wave 0A coordinator verification:

  • All three branches target feat/plugin-registry-labelling-service and are isolated to their assigned scope.
  • pnpm lint passes with 0 warnings/errors on all three branches.
  • Formatting and git diff --check pass.
  • Contract fixtures: 30/30 expected moderation outcomes reproduced.
  • Vocabulary audit: 19/19 structural-screen cases and 10/10 expiry/quarantine cases pass.
  • Crypto: independent Node vector test 1/1, workerd interoperability 12/12, both targeted typechecks pass.
  • Final independent reviews report no unresolved high/medium findings.

No umbrella checklist item is marked complete until the corresponding draft PR is ratified, merged, and reverified on the integration branch.

@github-actions github-actions Bot removed the query-count changed PR diff modifies query-count snapshot files label Jul 10, 2026
ascorbic added 3 commits July 10, 2026 13:25
* 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
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Wave 0A complete

Sequence Workstream PR Result
01 W0.1 + W0.2 contracts and policy fixtures #1910 Ratified and merged
02 W0.3 vocabulary cutover audit #1912 Conditionally ratified and merged; production zero-row preflight remains required before W1.1 rollout selection
03 W0.4 crypto interoperability #1911 P-256 contract ratified and merged

Gate decisions recorded:

  • Public NSIDs/API, ATProto state reduction, issuance rules, cross-labeller policy, overrides, and 30 moderation outcomes are frozen.
  • Canonical security-yanked and collision-safe ingest identity are frozen. No compatibility path is added unless the production preflight finds legacy rows.
  • P-256, canonical-CBOR atcute signing, strict DID key validation, and rotation/compromise ordering are frozen.
  • Retained crypto fixtures live under packages/atproto-test-utils/tests/fixtures; no retained source/test depends on .opencode/plans.

The Cloudflare API connector and local Wrangler OAuth token both returned 10000 Authentication error for EmDash CMS D1, so the production preflight remains an explicit operator action.

@ascorbic ascorbic mentioned this pull request Jul 10, 2026
18 tasks
…labelling-service

# Conflicts:
#	pnpm-lock.yaml
emdashbot Bot and others added 2 commits July 16, 2026 07:24
…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
ascorbic and others added 2 commits July 16, 2026 10:59
* 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
…#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>
ascorbic and others added 4 commits July 16, 2026 12:55
…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.
ascorbic and others added 9 commits July 16, 2026 14:12
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants