Skip to content

Plugin registry labelling service#1909

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

Plugin registry labelling service#1909
ascorbic wants to merge 139 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

  • Freeze public NSIDs, assessment API shapes, and error/cursor contracts (docs: define labeller Gate 0 contracts #1910)
  • Freeze label vocabulary, subject/CID rules, override precedence, and executable policy fixtures (docs: define labeller Gate 0 contracts #1910)
  • Audit and plan the security:yanked to security-yanked cutover (docs: audit registry label vocabulary #1912)
  • Prove DRISL label signing and verification interoperability in workerd (test: prove ATProto label crypto interoperability #1911)
  • Decide labeler identity, declaration, and localized-definition hosting
  • Prove verified aggregator-mirror artifact consumption and fallback inputs
  • Select dependency/malware scanners, SBOM tooling, and initial Workers AI models (code AI model selected via calibration data — glm-5.2, feat(labeler): model calibration harness and OpenAI envelope support (W8.6) #2058; image model kimi-k2.7-code; dependency/malware/SBOM scanner + tooling selection DISSOLVED — ratified 2026-07-17 that scanners are inapplicable (dependency-free plugins) and capability analysis redundant (runtime enforces declared access), so there is nothing to select. The production prompt + inputs still require a calibration re-validation sweep before enforcement launch)
  • Run the read-only security:yanked production preflight (zero legacy rows confirmed; Branch A, no compatibility path)

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 (the assessment pipeline is now functionally complete and live — real acquire→code-AI→image-AI→history with the deploy gate lifted, feat(labeler): code-AI and history stage wiring + dead-stage removal (slice A) #2090/feat(labeler): acquire consumer, SSRF egress, image stage, and gate lift (slice B) #2091; enabling enforcement is gated on the calibration re-validation sweep (production prompt + capabilities-only + real-description inputs; finalize model_id/prompt_hash provenance) and the operator-owned Cloudflare Email Sending onboarding + staging/prod provisioning)

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: abe4d72

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 Patch
@emdash-cms/registry-client Patch
@emdash-cms/admin Patch
@emdash-cms/registry-moderation Minor
@emdash-cms/registry-verification Minor
@emdash-cms/plugin-cli Patch
@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 abe4d72 Jul 22 2026, 09:17 AM

@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 abe4d72 Jul 22 2026, 09:18 AM

@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: c7fb4c9

@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 abe4d72 Jul 22 2026, 09:18 AM

@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
emdashbot Bot and others added 3 commits July 17, 2026 20:49
* docs(labeler): incident + key-management runbooks (W11.6, W11.4)

* docs(labeler): correct runbook re-signing + rerun-recovery claims

Addresses the emdashbot review on #2100 — three procedures diverged from the
code:
- subscribeLabels does NOT lazily re-sign (only queryLabels does), so a
  post-rotation WebSocket replay serves retired-key signatures until a
  queryLabels sweep re-signs them; corrected in both the aggregator-replay and
  the after-key-event sections.
- Re-run does not dispatch the Workflow (deferRerunTail stops at pending), so
  it does not recover a stuck run; the honest recovery today is a fresh
  discovery event. Flag updated to note reconciliation is detection-only and
  the rerun-dispatch gap leaves stuck runs with no automatic or manual
  re-drive.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(labeler): correct AI-error landing state and rotation verification window

Second emdashbot round on #2100:
- Non-transient AI adapter errors (MAX_MODEL_INPUT_CHARS overflow, validation)
  are not stage-caught; they leave the run stuck in 'running' after the
  Workflow's 3 step retries, not finalized as 'error'. Diagnosis now splits on
  error (transient outage) vs stuck running (input/config).
- The deploy->activate rotation window is NOT fail-closed for the current key:
  old-key labels match the DB-active version, so queryLabels serves them as-is
  (200), not 503; only earlier-retired-key labels 503 while paused.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
ascorbic and others added 3 commits July 17, 2026 22:35
runRerun minted a fresh assessment run, gated the release with assessment-pending, and advanced the run to pending in its deferred tail, but never dispatched the Cloudflare Workflow that executes it — stranding the release pending forever until the reconciliation stuck-run alert. ConsoleMutationDeps lacked the workflow binding entirely.

Thread AssessmentWorkflowBinding into ConsoleMutationDeps (wired from env.ASSESSMENT_WORKFLOW), and dispatch the run in deferRerunTail after advancing to pending, keyed on the run's runKey. The operator trigger yields a fresh runKey, so the rerun gets its own instance; a re-driven tail or replay dedups onto it via the instance-id lock.
oxlint 1.73's stricter type-aware rules flagged 7 errors + 7 warnings in
branch-local labeler code, failing the umbrella #1909 Lint check:

- registry-verification bundle: Array.from over spread+map
- calibration report/run: hoist regex literals to module scope
- calibration io/dead-letters: isRecord guard instead of narrowing casts,
  annotation-typed JSON.parse, toSorted over in-place sort
- calibration fixture-loader/rest-ai-binding: JSON.stringify for object
  interpolation, typeof-narrow the URL capture, split the optional-chain
  before member access

Behaviour-identical; lint/typecheck/tests all clean.
CodeQL js/incomplete-url-substring-sanitization (high) flagged a test
assertion doing `assessmentUrl.startsWith("https://labels.example")` —
a prefix that `https://labels.example.evil.com` also satisfies. The
production URL builder (assessmentUrl, notification-triggers.ts) is sound
(`new URL(path, base)`); this was a test-only false positive. Parse the
URL and assert origin equality instead — stronger, and clears the rule.

@ascorbic ascorbic left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes are needed before merge. The inline comments cover the directly anchored blockers; the remaining cross-file findings are:

  • Label-state ordering differs between the labeler, aggregator, and the ratified equal-time collision contract. Use one full-precision timestamp/collision algorithm and a shared conformance corpus.
  • Production assessment provenance is disconnected: findings are not persisted, model/prompt identity remains unassigned, artifact pins remain null, and supersession links are never supplied.
  • Recovery stops at logging for failed operator Workflow dispatch and initial discovery cursor reads. Both can leave durable work stranded.
  • Conditional DLQ and reconsideration mutations can lose their guarded update while still committing success audit/result records.
  • Public query bounds can exceed D1 bind and round-trip budgets in hydration, queryLabels, and listAssessments.
  • Stream lifecycle has no periodic DID refresh on a healthy socket and no application deadline for WebSocket upgrade.
  • CLI info can evaluate a latest-release response with stale accepted-labeler policy and broadly hides non-404 failures as no releases.
  • Notification retry needs durable pre-claim intent, an exclusive send lease, and repeated deletion/supersession checks.
  • Aggregator build, typecheck, and its 313 workerd/D1 tests are absent from required CI; add an upgrade test starting from main's actual schema.

Verified at 7074e810: root build/typecheck, explicit aggregator build/typecheck, lint with 0 diagnostics, 313 aggregator tests, and 1,008 labeler/console/calibration tests pass. Those suites do not exercise the failing upgrade, concurrency, replay, and publication sequences described here. Deployment testing remains a separate gate.

~ Sol 🤖

-- Trusted/known labellers (operator config, edited via deployment).
CREATE TABLE labellers (
-- Trusted/known labelers (operator config, edited via deployment).
CREATE TABLE labelers (

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing databases have already recorded 0001 with a labellers table. On upgrade, this edited migration is skipped and 0003 never renames the table, so every new labelers query fails with no such table. Applied migrations must remain immutable; add a forward rename migration and an upgrade test that starts from main's schema.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2111. 0001 is restored byte-for-byte to main's version, the rename lands as forward migration 0004, and a new upgrade test provisions a DB from a frozen copy of main's shipped migration set before applying the live set — it reproduces the exact no such table: labelers failure pre-fix. Fresh installs and main upgrades converge on the same schema.

~ 🤖 Claude Fable 5

redacted: boolean;
}

const AUTOMATED_BLOCKS = new Set<string>([

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The policy declares hateful-imagery, explicit-imagery, and graphic-violence as release-scoped hard blocks, but the shared automated/release sets omit them. The labeler can issue these values while both the evaluator and latest-release SQL ignore them, leaving a policy-blocked release installable. Please derive or validate the enforcement vocabulary against the executable policy fixture.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2112. The three image values join AUTOMATED_BLOCKS (and content-warning joins WARNINGS — same un-synced batch), and a parity test now asserts the enforcement vocabulary against the executable policy fixture, including that every officialEffect: "block" label is enforced at release or package/publisher scope. Third dual-source miss; the test is the structural fix.

~ 🤖 Claude Fable 5

count: labels.length,
cap: LABELS_MAX_LENGTH,
});
return labels.slice(0, LABELS_MAX_LENGTH);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With more than 64 applicable labels, this slice can remove the only non-redacting hard block. Explicit-version install, update, and artifact paths then evaluate the truncated response and allow the release. The wire cap must preserve all enforcement-relevant state or force downstream evaluation to fail closed.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2112. capLabels now partitions by enforcement priority (hard blocks → assessment-state → informational) before slicing, so a truncated view can never drop a block while keeping informational labels; under-cap responses are byte-unchanged. Hydration only returns neg = 0 rows at this boundary, so no negation-pairing hazard. The >64-hard-blocks pathological case keeps all blocks and logs.

~ 🤖 Claude Fable 5

...proposal,
},
now,
false,

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated pending, outcome, block, and negation labels commit without a LabelPublisher, and finalization marks them non-pending without calling the subscription DO. A connected aggregator therefore remains online indefinitely without receiving these sequences until reconnect or an unrelated notification. Every committed automated label needs a durable live-publication path.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2115. The orchestrator now takes the same createLabelPublisher(env) the console path uses, commits finalization labels publication_pending=1, best-effort broadcasts each to the subscription DO post-commit, and a new sweepPendingPublications in the reconciliation cron (5-min threshold, per-row isolated) is the durable backstop. The rotation-drain contract is preserved — the sweep is exactly what unblocks a stuck rotation.

~ 🤖 Claude Fable 5

});
}

private async labelsAfter(cursor: number, through: number): Promise<SubscriptionEvent[]> {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After routine rotation, the DID document exposes only the new key while retained rows still carry old signatures. queryLabels lazily re-signs its page, but WebSocket replay reads these rows unchanged, so a fresh aggregator rejects the first old-key row and cannot advance its cursor. Retained replay must remain verifiable after every supported rotation.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2115. The WS replay reader now reuses queryLabels' resignStaleLabels — re-signing retired-key rows under the active key, persisting the result (no per-connection repeat), preserving sequence/ordering, and throwing on a signing pause like queryLabels' 503. The cursor is untouched on throw (lastSent only advances after a successful send), so reconnect is gap-free.

~ 🤖 Claude Fable 5

return url.toString();
}

async function fetchCar(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint is publisher-controlled, but the fetch follows redirects without the scheme, DNS, reserved-address, and per-hop checks used for artifact acquisition. A hostile DID/PDS document can cause blind requests to forbidden destinations. Route this path through equivalent egress validation.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2113. The PDS fetch now enforces HTTPS-only and validates every hop (initial + each manual redirect, max 3) through cloudflareDohResolver + private/reserved-address rejection, fails closed if the resolver is absent, and bounds the body read with an absolute wall-clock deadline. A permanent PDS_HOST_BLOCKED dead-letter reason is threaded through; 404→RECORD_NOT_FOUND and 5xx→transient classification preserved (so a blocked host can never be mistaken for record-absence). Two sibling publisher-controlled fetches remain unguarded and are tracked for a follow-up: the did:web DID-document resolution, and the aggregator’s own pds-verify.ts.

~ 🤖 Claude Fable 5

// (e.g. a D1 error) propagates so the caller 500s instead of the request
// silently falling open to an empty policy.
let policy: Awaited<ReturnType<typeof resolveRequestLabelerPolicy>>;
try {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only XRPCError is converted here. A D1 policy-resolution failure escapes before the normal CORS and private, no-store wrapper, producing an opaque browser failure and violating the endpoint cache invariant. Convert unexpected policy failures into the same wrapped response path.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2117. Unexpected (non-XRPC) errors — e.g. a D1 policy-resolution failure — now convert to a generic 500 via internalErrorResponse() through the same CORS + private, no-store wrapper as every other response; the internal detail is logged, the client gets the router's opaque envelope (no error.message/SQL/stack). XRPCError handling unchanged. (The same PR also hardens the aggregator's own pds-verify.ts against the SSRF twin of the labeler's.)

~ 🤖 Claude Fable 5

headers: { allow: "GET, POST", "cache-control": "no-store" },
});

const params = await readPostParams(request, action);

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RFC one-click providers POST List-Unsubscribe=One-Click to the header URL while c remains in its query string. This POST parser reads c only from the form body, so the request returns success without suppressing the recipient. Preserve the query capability for one-click POSTs.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2116. The one-click POST handler now reads c (and the confirm token) body-first with a query fallback, so an RFC 8058 one-click request (token in the header URL query, List-Unsubscribe=One-Click in the body) actually suppresses the recipient. GET is unchanged and suppression is still authenticated by the peppered recipient hash.

~ 🤖 Claude Fable 5

statements.push(
db
.prepare(
`INSERT INTO current_assessments (src, uri, cid, assessment_id, updated_at)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other assessment ordering uses (created_at_epoch_ms, id), but this pointer update accepts any equal epoch. If the higher-ID run finalizes first and the lower-ID run later, the lower-ID run replaces it. Apply the same ID tie-break used by list/current semantics.

~ Sol 🤖

/** Emergency takedown or its retraction (console `emergency/takedown[-retract]`).
* The subject may be redacted; the aggregator read is unfiltered (blank
* accept-labelers), so contact resolution still works. */
export async function notifyEmergencyTakedown(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an emergency takedown has no resolvable contact, this records only a generic undeliverable notification row. The required operator signal that manual contact is needed is never emitted, so the most consequential delivery failure is easy to miss. Propagate the no_contact outcome into a dedicated operational event.

~ Sol 🤖

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in #2116. A takedown issuance (not retract) that resolves no contact now emits a dedicated takedown-no-contact operational event (severity high) plus its deployment-alert outbox row, deduped on replay — so the most consequential delivery failure surfaces to operators instead of only a generic undeliverable row.

~ 🤖 Claude Fable 5

ascorbic and others added 7 commits July 18, 2026 19:53
Migration 0001 was edited in place to rename `labellers` -> `labelers`
after shipping on main. Databases that already applied 0001 never see the
edit, so they keep `labellers` while every query in src/ reads `labelers`,
failing with `no such table`.

Restore 0001 to main's byte-for-byte schema and add 0004 to rename the
table forward. Both fresh installs and main upgrades now converge on
`labelers`. Adds an upgrade test that provisions main's frozen 0001, applies
the live set, and asserts the table is queryable.
* fix(registry-moderation): enforce image-content automated-block labels

hateful-imagery, explicit-imagery, and graphic-violence are declared as
automated-block labels in the labeler policy fixture but were absent from
AUTOMATED_BLOCKS, so the evaluator and RELEASE_BLOCK_VALUES ignored them --
a policy-blocked release stayed installable. Add the three, plus
content-warning to WARNINGS (the same un-synced image-label batch), export
both sets, and add a labeler-side parity test that fails whenever the
fixture and package classification drift.

* fix(registry-client): send an explicitly empty accept-labelers header

DiscoveryClient treated acceptLabelers: "" as absent (`|| onResponseMeta`
and `if (acceptLabelers)`), so a config meaning "accept no labelers" was
dropped and the aggregator applied its trusted defaults. Switch to
`!== undefined` checks so an empty string still goes on the wire while an
omitted option still sends no header.

* fix(aggregator): honor reviewer override pair in release enforcement SQL

buildReleaseEnforcementSql excluded a release whenever any live automated
block row existed, but the hydrated evaluator suppresses a source's
automated blocks when that same source has an exact-CID assessment-passed +
assessment-overridden pair (spec §10). A post-override re-assessment that
re-issues a live automated block was therefore dropped from latest-selection
even though the evaluator treats it as eligible. Split the release-URI block
branch so automated blocks carry the same-source exact-CID override
exception while the manual security-yanked / !takedown blocks and the
package/publisher cascade keep firing unconditionally.

* fix(aggregator): keep enforcement labels when capping view label arrays

capLabels sliced a view's labels at LABELS_MAX_LENGTH in hydration order, so
a subject carrying more than 64 labels could lose its only hard block; a
client evaluating the truncated view would then treat the release as
installable. Order labels by enforcement priority (hard blocks, then
assessment states, then informational) before slicing so blocks survive, and
log an error in the pathological case where blocks alone exceed the cap
(the kept slice is still all blocks, so the release stays blocked).

* fix(registry-moderation): add image labels to ModerationLabelValue union

The exported nominal union drifted from the runtime sets when the
image-content labels were added: hateful-imagery, explicit-imagery,
graphic-violence, and content-warning are classified at runtime but were
missing from ModerationLabelValue, so the type contract understated the
values the labeler can issue. Add all four.

* fix(aggregator): validate release/package SQL aliases

buildReleaseEnforcementSql interpolates the caller-supplied
aliases.release / aliases.package directly into raw SQL. Callers pass
compile-time constants today, but the builder is exported, so validate both
aliases against an identifier-plus-trailing-dot shape and throw a TypeError
on mismatch before they reach the query text.

* fix(aggregator): validate the package enforcement builder's SQL alias

buildPackageEnforcementSql interpolates its caller-supplied alias into raw
SQL the same way the release builder does, but the release-builder guard
added earlier did not cover it. Apply the same assertAlias check right after
the alias default so the exported package primitive rejects a malformed
alias too.
…w round 1) (#2113)

* fix(labeler): read Access group claims from the verified custom object

Cloudflare Access places IdP group membership inside the verified
application token's `custom` object (a `groups` claim configured on the
SAML/OIDC integration), never as a top-level `groups` claim. The role
mapper read `payload.groups`, which Access never populates, so the
group-only reviewer/admin allowlist granted no roles to human operators
whose access is conferred by group.

Read the `groups` array from the verified `payload.custom` object,
guarding the `custom` record and the array shape strictly. Restrict group
extraction to human identities: a service token is authorized solely by
common_name and carries no IdP identity, so a `custom.groups` on a
service token must never confer a role (moving the group claim under
`custom` would otherwise open a fail-open path). Email/common_name
resolution is otherwise unchanged.

* fix(labeler): route the PDS record fetch through an SSRF egress guard

The PDS endpoint is resolved from a publisher-controlled DID document,
and the CAR fetch followed redirects with the default fetch policy and no
scheme, DNS, or reserved-address checks. A hostile DID/PDS document could
point the endpoint (or a redirect it serves) at a private/reserved
address or a non-HTTPS host, turning the verifier into a blind SSRF
probe.

Validate every hop before fetching, sharing the DoH resolver
(`cloudflareDohResolver`) that artifact acquisition uses and routing each
hop through core's `resolveAndValidateExternalUrl`: HTTPS-only, DoH
resolution, and private/reserved-IP rejection. Redirects are now followed
manually (`redirect: "manual"`) so each `Location` is re-validated
per-hop. A blocked hop rejects with the new permanent `PDS_HOST_BLOCKED`
reason (dead-lettered, never retried).

This shares the resolver but NOT artifact acquisition's address
predicate: `fetchVerifiedResource`'s stricter `isForbiddenAddress` is not
exported from @emdash-cms/registry-verification, so this path uses core's
`isPrivateIp`, which blocks loopback, RFC1918, link-local/metadata, and
IPv4-mapped/NAT64 forms but not CGNAT (100.64.0.0/10), benchmarking
(198.18.0.0/15), or multicast/reserved (224.0.0.0/4, ff00::/8) — an
accepted residual for PDS egress; closing it would mean exporting a
published-package predicate.

Also bound the body read by a wall-clock budget: the header-phase abort
timer is cleared once headers arrive, so each streamed chunk is now raced
against the remaining fetch timeout, preventing a slow-drip PDS from
holding the read open indefinitely.

The resolver is threaded through the discovery consumer's closure deps;
the existing 404/5xx/size status classification the delete and retry
paths depend on is preserved.

* fix(labeler): retry transient PDS host-resolution failures

The PDS SSRF egress guard mapped every `SsrfError` from
`resolveAndValidateExternalUrl` to the permanent `PDS_HOST_BLOCKED`
reason. But that helper raises `SsrfError` for two distinct cases: a
permanent private/reserved-address or scheme block, and a transient
resolver failure (DoH network error, SERVFAIL, timeout). A transient DoH
blip while resolving a publisher's PDS host therefore permanently
dead-lettered a legitimate record instead of retrying it.

Core exposes no structured discriminator (`SsrfError` carries a single
`code`), so key on the resolver-failure message prefix
("Could not resolve hostname:") to map that case to the transient
`PDS_NETWORK_ERROR` reason, which the consumer retries via `isTransient`.
Genuine address/scheme blocks stay `PDS_HOST_BLOCKED` (permanent). A test
pins the prefix so a core wording change fails loudly rather than
silently mis-classifying.

* fix(labeler): accept the empty sub Cloudflare Access sets for service tokens

Cloudflare Access issues non-identity (service-token) application JWTs
with `sub: ""` and identifies the token via `common_name` (the
CF-Access-Client-Id). The verifier rejected any empty `sub` before it
reached the common_name branch, so every real service-token request
failed `invalid-token` and the service-identity path was unreachable in
production — the prior service-token tests passed only because they minted
a synthetic non-empty subject.

Require `sub` to be a string, but only reject an empty `sub` on the
human/email path; the service path (common_name present) now proceeds with
the empty subject Access actually sends. Tests use the realistic
service-token shape (empty sub + common_name), and a human token with an
empty sub is still rejected.

* fix(labeler): retry an empty DNS answer for a PDS host

The PDS SSRF guard mapped every non-resolver-failure `SsrfError` to the
permanent `PDS_HOST_BLOCKED` reason. But `resolveAndValidateExternalUrl`
also raises `SsrfError` ("Hostname resolved to no addresses") when the
resolver returns an empty answer (NXDOMAIN / NODATA), which a host
mid-DNS-propagation produces transiently — so a legitimate record was
permanently dead-lettered on a temporary negative answer.

Classify the empty-answer message as the transient `PDS_NETWORK_ERROR`
too (the consumer retries it via `isTransient`; a genuinely absent host
dead-letters through retry exhaustion). Keyed on a pinned message constant
like the resolver-failure prefix, so a core wording change fails a test.
Genuine private/reserved-address and scheme blocks stay permanent.

* fix(labeler): back off transient PDS retries so DNS propagation can complete

The discovery consumer retried transient PDS failures with a bare
`retry()`, and the queue consumer has no `retry_delay`, so Cloudflare
Queues re-delivered immediately in the next batch. With only max_retries
attempts, ordinary DNS propagation (now routed here via the empty-answer /
resolver-failure transient classification) could exhaust every attempt
before the host resolved — after which the record dead-lettered as
UNEXPECTED_ERROR.

Pass a capped exponential `delaySeconds` (15s, doubling per delivery
attempt, capped at 300s) when retrying a transient PDS failure, giving
propagation time without an unbounded backlog. The delay applies only to
the transient PDS retry path; permanent failures still dead-letter
immediately with no retry, and the other retry paths (label issuance,
workflow dispatch, DID resolution) are unchanged.
…ication-trust fixes (review round 1) (#2116)

* fix(labeler): honor RFC 8058 one-click unsubscribe query param

A one-click unsubscribe POST carries the recipient hash (c) in the
List-Unsubscribe header URL's query string and only List-Unsubscribe=One-Click
in the body. The POST parser read c from the form body only, so a one-click
request returned success without suppressing the recipient. Read c (and the
confirm token) from the body first, falling back to the query.

* fix(labeler): resolve package contacts for release-subject notices

A release subject's rkey is slug:version, but contactTargetFromUri passed the
whole rkey as the package slug, so getPackage missed the package and its
security[]/authors[] contacts were skipped in favor of the publisher-profile
fallback. Parse the parent package slug (before the version delimiter) so a
release notice reaches the package's contacts; a package rkey and a malformed
rkey both degrade safely.

* fix(labeler): alert operators when a takedown notice has no contact

An emergency takedown issuance that resolves no publisher contact recorded only
a generic undeliverable notification row, so the signal that manual outreach is
needed was easy to miss. Emit a dedicated takedown-no-contact operational event
(severity high) plus its operator-alert outbox row on that path. A takedown
retract and a takedown that does resolve a contact emit nothing, and a deduped
replay does not re-emit.

* fix(labeler): gate double-opt-in bypass on trusted, current verification

A publisher's verification claim upgraded a contact past double opt-in on the
strength of any in-force claim. Verification claims are self-assertable — the
aggregator indexes any issuer, including self-issued ones — so a publisher could
name a victim address in its profile, self-issue a claim, and have an
unconfirmed notice sent there. Upgrade only on a claim whose issuer is in the
configured trust set, that is unexpired, and whose bound displayName still
matches the publisher's current identity. No issuer is trusted by default, so
every address falls back to double opt-in.

The publisher view carries a current displayName but no handle (the aggregator's
identity-event ingestion is unbuilt), so the binding checks displayName only;
handle-binding is deferred until the view carries a handle.

* fix(labeler): make takedown no-contact alert recoverable

The takedown-no-contact operator alert was emitted only on the first pass's
send outcome, after the undeliverable notifications row had committed. A
transient failure of that fire-and-forget event write lost the alert
permanently: a deferred replay dedups on the existing notifications row and
never reached the emit again. Key the alert on its own operational_events row
instead, and run the check on every takedown issuance (including the dedup
replay path), so a replay after a failed emit recovers the signal while a normal
replay does not duplicate it.

* fix(labeler): scope unsubscribe query fallback, keep confirm body-only

The one-click query fallback was applied to the confirm and not-me POST parsers
too, so a scanner POSTing the original confirmation link URL (with c and t in
the query) could confirm an address without the user submitting the form.
Restrict the query fallback to the RFC 8058 one-click unsubscribe path; confirm
and not-me require their capability from the POST body.

* fix(labeler): validate release rkey shape; make no-contact alert dedup atomic

Contact resolution stripped a subject rkey at the first colon without checking
the collection or the canonical release rkey shape, so a malformed colon-bearing
subject could resolve a DIFFERENT package's contacts. Only strip a canonical
release record (release collection + well-formed slug:version); anything else
keeps its rkey and degrades to the publisher tier.

The takedown-no-contact alert used an existence-check-then-insert with no
uniqueness guard, so two concurrent replays could double-emit. Add a
UNIQUE(action_id, event_type) index (migration 0012) and make the insert
idempotent (ON CONFLICT DO NOTHING), with the outbox gated on the event being
written; concurrency and replay now converge to exactly one event.

* fix(labeler): scope dedup index to takedown-no-contact; require semver

Migration 0012 created a GLOBAL unique index on (action_id, event_type), which
would fail at migration time on any existing DB holding historical duplicate
rows of other event types. Make it PARTIAL (WHERE event_type =
'takedown-no-contact') and point the insert's ON CONFLICT target at that
predicate, so it stays safe on historical data while still deduping the alert.

The release-rkey parse validated only the slug and a non-empty suffix, so
'gallery:not-semver' still resolved the gallery package. Validate the complete
canonical shape (slug + percent-decoded semver version, mirroring the
aggregator's parseReleaseRkey) before stripping; anything else degrades to the
publisher tier.
…nst SSRF (review round 1) (#2117)

* fix(aggregator): wrap unexpected XRPC errors and harden PDS egress against SSRF

Finding 17 (Sol): a non-XRPC failure resolving the labeler policy (e.g. a D1
error) escaped `handleXrpc` unwrapped, hitting workerd's bare 500 without the
CORS or `private, no-store` headers every aggregator response carries. Convert
unexpected errors to a generic 500 through the same wrapper, logging the
internal detail but never leaking it to the client. XRPCError handling is
unchanged.

SSRF follow-up: `pds-verify.ts` fetched the publisher-controlled PDS endpoint
(from a DID document) following redirects with no scheme/DNS/reserved-address
validation and no body-read deadline. Route it through the shared hardened
egress (`resolveAndValidateExternalUrl` + injected `cloudflareDohResolver`):
HTTPS-only, per-hop re-resolution under `redirect: "manual"` with a bounded
redirect count, a wall-clock deadline spanning redirects and the body read, and
fail-closed when no resolver is injected. Blocks are reported as a new permanent
`PDS_ADDRESS_BLOCKED` reason; existing 404/5xx/too-large/invalid-proof and the
transient network classification are preserved.

* fix(aggregator): retry PDS verification on resolver-infra failures, don't dead-letter

`assertFetchableUrl` collapsed every `SsrfError` from
`resolveAndValidateExternalUrl` into the permanent `PDS_ADDRESS_BLOCKED`, but
that helper also throws `SsrfError` when the injected DNS resolver itself fails
(DoH network error / SERVFAIL / timeout) — transient infrastructure, not a
disallowed publisher address. A DoH blip therefore permanently dead-lettered
legitimate records and risked mass dead-lettering during a resolver outage.

`SsrfError.code` is the same constant for both cases, so key on the message
prefix the helper uses only for the resolver-threw path (`Could not resolve
hostname:`): map that to the transient `PDS_NETWORK_ERROR` (retried), and keep
genuine address blocks — private/reserved IP, blocked host, scheme — as the
permanent `PDS_ADDRESS_BLOCKED`. A test asserts the transient mapping so a future
wording change in the shared core helper breaks loudly rather than silently
mis-classifying. Also adds a redirect-hop-limit boundary test.

* fix(aggregator): retry PDS verification on empty DNS answer, don't dead-letter

An empty resolver answer (NXDOMAIN / NOERROR-NODATA / CNAME-only) makes
`resolveAndValidateExternalUrl` throw `SsrfError("Hostname resolved to no
addresses")`, which the classifier mapped to the permanent PDS_ADDRESS_BLOCKED
— so a host mid-DNS-propagation lost the record to a dead-letter. Treat the
empty-answer message as a transient resolution failure (PDS_NETWORK_ERROR,
retried) alongside the existing resolver-threw case; a genuinely-gone host
still dead-letters via retry exhaustion. Genuine private/reserved/blocked-host
addresses stay permanent. Keyed on the shared helper's message as a module
constant with a test, matching the resolver-failure-prefix approach.

* fix(aggregator): delay re-delivery on transient DNS-resolution failure

The records queue re-delivers immediately (no `retry_delay`), so classifying an
empty DNS answer / resolver outage as transient only helped if the retry waited
for propagation — otherwise all `max_retries` (5) burn in seconds and the record
DLQs as UNEXPECTED_ERROR before DNS settles. `PdsVerificationError` now carries
an optional `retryAfterSeconds`, set (to 60s) only on the transient DNS-resolution
path; the consumer passes it as `retry({ delaySeconds })`. Other transient
retries (network, 5xx, timeout) stay immediate.

Delay strategy: fixed 60s. With max_retries: 5 that spreads retries across a
~5-minute propagation window. Chosen for reconciliation with labeler #2113.
…w round 1) (#2115)

* fix(labeler): redrive the subscription-DO notify on console mutation replay

A transient afterCommit failure after a label commits leaves the row
publication_pending=1 and drops the live broadcast; the guard replay branch did
not re-drive the notify, so a client retry never recovered it. The label-issue
and emergency replay branches now re-fire the post-commit notify (keyed on the
committed action), giving retries a cheap path back to a live broadcast. The
reconciliation publication-pending sweep remains the durable backstop.

Refs Sol finding 7.

* fix(labeler): re-sign retired-key labels on WebSocket subscription replay

After a routine key rotation the DID document publishes only the new key, but
retained rows keep their old-key signatures; queryLabels re-signs lazily on read
while the subscribeLabels replay path sent rows unchanged, so a fresh aggregator
rejected the first old-key frame and could not advance. The replay reader now
reuses queryLabels' lazy re-sign helper (exported from query-labels), bringing
stale-key rows onto the active key before framing and persisting the result so
the work is not repeated per connection. Sequence and ordering are preserved; a
signing pause throws rather than serving an unverifiable frame.

Refs Sol finding 5.

* fix(labeler): publish automated assessment labels live, with a reconciliation backstop

Automated pending/outcome/block/negation labels committed without ever notifying
the subscription DO, so a connected aggregator received nothing until it
reconnected or an unrelated notification arrived. The orchestrator now issues
finalization labels publication_pending=1 and broadcasts each to the same
subscription-DO publisher the console path uses, off the commit. A new
publication-pending sweep in the reconciliation cron re-drives any notify that
was dropped (assessment or console path alike) — the durable guarantee that a
stranded row reaches subscribers and stops blocking the next key rotation.

Refs Sol findings 4 and 7.

* fix(labeler): guard the finalization CAS on signing state so a rotation pause can't strand a run

If signing paused between finalization prep and the batch commit, the guarded
label INSERTs no-oped while the unguarded assessment CAS still committed terminal
state — leaving the run terminal with its pending-negation, outcome, and block
labels missing, and the Workflow retry seeing terminal and never repairing them.
The CAS now shares the same signing-state predicate as the issuance statements,
so the whole db.batch is all-or-nothing: a mid-batch pause no-ops the CAS too,
the run stays running, and the retry re-runs finalization after signing resumes.

Refs Sol finding 6.

* perf(labeler): index issued_labels for the publication-pending sweep

The reconciliation sweep filtered issued_labels by publication_pending ordered
by sequence with no covering index, full-scanning a monotonically growing table
every cron tick — a D1 query-timeout there would strand pending rows and block
the very rotation drain the sweep exists to unblock. Adds migration 0011 with a
partial covering index on (sequence, cts) WHERE publication_pending = 1, and a
query-plan guard so the sweep can't silently regress to a full scan.

Follow-up to emdashbot review on #2115.

* docs(labeler): drop the stale mid-batch signing-flip gap from finalize

The F6 fix guards the finalization CAS on the same signing-state predicate as
the label issuances, so the race-analysis comment's first bullet (claiming the
CAS is unguarded and a flip could commit terminal state with labels suppressed)
is no longer true. Replaces it with the now-closed description and keeps the two
genuinely-remaining narrower gaps.

Follow-up to emdashbot review on #2115.

* fix(labeler): publish discovery-consumer labels live, with the sweep backstop

The #4 fix gave the orchestrator path live publication but missed the
discovery-consumer path: its initial assessment-pending issuance and its
deletion negations committed publication_pending=0 with no subscription-DO
broadcast, so connected subscribers never received them and the new
reconciliation sweep (pending=1 only) could not recover them. Both issuances now
take the same subscription-DO publisher the orchestrator uses — publication_pending=1
on commit plus a best-effort post-commit notify (a dropped broadcast never fails
the discovery message; the sweep re-drives it). The rotation-drain contract is
unchanged: these pending rows drain exactly like the orchestrator's.

Follow-up to Sol review on #2115.

* fix(labeler): negate a deleted run's pending label before cancelling it

The delete path cancelled pending/running runs to terminal 'cancelled' before
issuing their assessment-pending negations. If issuance was paused (signing
rotation) the negation threw and the message retried, but on redelivery
listNonTerminalAssessmentsForUri excluded the already-cancelled runs — so there
was nothing left to negate, the message acked, and the active assessment-pending
label survived a deleted release forever. Each run's negation is now issued
before its terminal cancellation, so a paused/failed negation leaves the run
non-terminal and re-discoverable on redelivery; a run is retired only after its
negation commits, and the message cannot ack while any pending/running run is
un-negated. No active assessment-pending survives an acked delete.

Follow-up to Sol review on #2115.

* fix(labeler): guard the finalization CAS on the subject not being tombstoned

Finalization's isSubjectCurrent re-check is a separate read from its commit CAS, a
TOCTOU: a delete that tombstoned the subject in between still let the CAS commit
'running -> outcome' plus positive outcome/block labels, then the delete's own
cancel CAS conflicted and was swallowed — leaving live block labels on a deleted
release the evaluator would still honor. The finalization CAS now carries a
not-deleted predicate on the run's subject (same all-or-nothing batch idiom as
the F6 signing guard), so a tombstone landing before the batch no-ops the CAS
and every label gated on the outcome state; finalization retries and stales the
run out, never labelling a deleted subject.

Follow-up to Sol round-3 review on #2115.

* fix(labeler): retry (never dead-letter+ack) a delete-path mutation failure

The 'a negation throw prevents ack' invariant only held for recognized
issuance-unavailable errors; an unexpected signing/D1 failure during the delete's
tombstone/negate/cancel fell through to dead-letter+ack (the create-path policy),
leaving the run non-terminal with its assessment-pending label live on a deleted
subject forever. The delete handler now splits the absence-verification phase
(classified like create: transient retries, forged/permanent dead-letters) from
the mutation phase, which ALWAYS retries on failure so the label can never be
left live on an acked delete. A genuinely permanent fault exhausts to the DLQ via
max_retries — acceptable versus acking a stranded label.

Follow-up to Sol round-3 review on #2115.

* docs(labeler): correct the delete-mutation retry reference post-merge

After merging the integration branch (which added #2113's transient-DNS retry
backoff), the applyDiscoveryDelete docstring still said a failed negation
propagates to classifyDiscoveryError. Since the Blocker-2 fix, the delete
mutation phase has its own always-retry catch — update the comment to match.

No behavior change.

* fix(labeler): guard the initial pending-label issuance against a concurrent delete

The create path committed a recreated run as pending and THEN issued its positive
assessment-pending with no commit-time guard. A concurrent delete could, in the
gap, tombstone the subject, negate + cancel the (still-positive-less) run, and
ack; the create then committed its positive at a higher sequence, winning label
reduction — a live pending label on a tombstoned, cancelled subject. (Queue
invocations overlap; max_concurrency is unset.)

The initial issuance now goes through buildIssuanceStatements gated atomically on
BOTH the run still being pending AND the exact (uri,cid) subject still undeleted
(the same guard idiom as Blocker 1's finalization CAS). On a guard miss the label
no-ops: the issuance is obsolete, so the create neither publishes nor dispatches a
Workflow for a label that never committed; a non-persist not explained by the
guard (a signing flip) still retries. With this and Blocker 1, every automated-
positive issuance site in the discovery consumer is now delete-guarded (the
delete-path negation is negative and exempt).

Follow-up to Sol round-4 review on #2115.

* fix(labeler): guard the operator-rerun pending label against a concurrent delete

The operator rerun issued its positive assessment-pending with no commit-time
guard and while its run was still 'observed' (advance to 'pending' is deferred),
opening two integrity races with a concurrent discovery-delete (queue + HTTP
overlap; max_concurrency unset):
  (a) ordering: the rerun commits its run+pending after a delete tombstoned and
      snapshotted the non-terminal runs, so the delete never negates it and the
      positive survives on the tombstoned subject;
  (b) observed gap: the delete-path negation only fired for pending/running runs,
      so a delete that saw the rerun's 'observed' run cancelled it WITHOUT negating
      its already-live positive.

Fix (a): gate the rerun's assessment-pending issuance on requireSubjectNotDeleted
+ requireAssessmentState (threaded through prepareAutomatedLabelIssuance); on a
miss the label no-ops, assertIssuancePersisted aborts before the deferred tail, so
nothing is published, dispatched, or advanced. Fix (b): the delete-path negation
now fires for any non-terminal run that committed a positive pending (keyed on the
committed positive, not lifecycle state) — no dangling negations, no interleaving
where a cancelled/deleted run keeps a live positive. With Blocker 1 and the round-4
discovery guard, every positive assessment-pending issuance across the discovery
and console paths is now delete-safe.

Follow-up to adversary review on #2115.

* fix(labeler): structural delete-generation guard closing the create/verify/rerun-vs-delete race class

Adds a monotonic delete_generation to subjects (migration 0013). Every delete
increments it; create/verify/rerun capture it before reading state/verifying and
CAS-guard their subject-undelete, run creation, and label issuance on the
generation not having advanced. A decision made before a delete is rejected
obsolete; one that captured the post-delete generation still works (republish).

Closes three race seams: (1) createSubject un-delete is generation-gated so a
stale verify can't resurrect a deleted subject; (2) the delete cleanup now scans
pending-bearing runs INCLUDING terminal stale and negates any committed positive;
(3) the console rerun's run creation is generation-gated so no orphan observed run.

Implementation checkpoint; barrier tests for each seam + a republish test follow.

Refs Sol round-5 (systemic close).

* test(labeler): barrier tests for the delete-generation close (three seams + republish)

Seam 1: a stale verify (barrier during verify, full delete completes) cannot
resurrect the tombstoned subject — createSubject's generation-guarded undelete
no-ops, no run, no positive, no dispatch. Seam 2: the delete negates a terminal
stale run's stranded positive (widened scan). Seam 3: a rerun after a concurrent
tombstone leaves no orphan observed operator run. Plus a delete-then-republish
test proving a new revision after a delete assesses cleanly (the generation does
not over-block). Each verified failing pre-fix.

Refs Sol round-5 (systemic close).
…try-moderation/verification packages

The new (unpublished) registry-moderation and registry-verification packages
accumulated a pile of per-change changesets; since they're initial releases,
collapse them into a single initial-release changeset (net bump unchanged:
both minor -> 0.1.0). Also drops two empty changesets. Changes to already-
published packages (registry-lexicons, registry-client, emdash, admin, auth,
plugin-types, plugin-cli) keep their individual changesets.
…elope (#2167)

* fix(aggregator): wrap syncGetRecord errors in the CORS + no-store envelope

An unexpected error (e.g. a D1 failure in the record-blob fetch) escaped
handleXrpc unwrapped, hitting workerd's bare 500 with no CORS or cache
headers — the parallel gap #2117 left out of scope on the cacheable
record-blob path. Extract the shared no-store+CORS wrapper (wrapDispatchError)
so the sync and policy error paths stay in lockstep, add a context string for
correct log attribution, and route syncGetRecord throws through it. The success
200 keeps its cacheable header; only errors get private, no-store.

* docs(aggregator): drop redundant catch comment (wrapDispatchError docstring covers it)
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 21, 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 failed
View logs
emdash-bot fa4d25b Jul 21 2026, 12:09 PM

…y, pointer tie-break (held review findings) (#2166)

* fix(labeler): tie-break current-pointer update on assessment id

The current-pointer upsert guard accepted any run at an equal
created_at_epoch_ms, but list/current ordering is (created_at_epoch_ms,
id). A later lower-id run could displace a higher-id current sharing a
millisecond. Match the pointer guard to the ordering so a lower-id run
cannot overwrite a higher-id current at an equal epoch.

* fix(labeler): enforce automation kill-switch on in-flight runs

An admin pausing automation only halted discovery; already-dispatched
Workflow runs never re-read automation_state and finalization had no
pause predicate, so in-flight runs kept issuing automated labels after a
successful pause.

Re-read the kill-switch on Workflow entry (halting before AI/network
work) and fold a fail-closed automation-not-paused predicate into the
finalization CAS. A pause landing mid-flight no-ops the whole batch,
leaving the run running for the Workflow retry to resume after unpause --
matching the signing-pause precedent.

* fix(labeler): make dead-letter retry durable before marking retried

The dead-letter retry committed status='retried' before decoding the
payload and delivering the re-drive to the queue. A failed or undecodable
delivery stranded the row 'retried': health checks (which count only
'new') stopped seeing it and a later operator retry returned 409, with
nothing ever re-driven.

Enqueue the discovery job before committing 'retried', so the status
flips only once the queue accepts the job. An undecodable payload (422)
or a queue failure (503) leaves the row 'new' -- still counted and
retryable. A duplicate enqueue under concurrent double-retry is absorbed
by the consumer's runKey dedup.

* docs(labeler): correct sendDiscoveryJob comment — awaited before commit, not deferred
…run resolves (#2168)

Concurrent assessment runs for the same (uri,cid) all target the single
assessment-pending label, so one run's finalization negation cleared the
shared gate while a sibling run was still active — briefly exposing a stale
pass. Gate the pending negation, in-batch, on no other non-terminal run for the
subject (requireNoOtherActiveRun), so only the last run to resolve clears it.
Make the delete cleanup's pending scan label-driven (any run holding a
committed un-negated positive, at any state) so a decision run that now
finalizes while still holding a positive is still negated on delete.
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