Plugin registry labelling service#1909
Conversation
🦋 Changeset detectedLatest commit: 14402a3 The changes in this PR will be included in the next version bump. This PR includes changesets to release 21 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-playground | 82c1aa9 | Jul 12 2026, 09:13 PM |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-do | 82c1aa9 | Jul 12 2026, 09:14 PM |
Scope checkThis PR changes 3,014 lines across 3 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-moderation
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
emdash-demo-cache | 82c1aa9 | Jul 12 2026, 09:14 PM |
…labelling-service
Execution ledger
Wave 0A coordinator verification:
No umbrella checklist item is marked complete until the corresponding draft PR is ratified, merged, and reverified on the integration branch. |
* docs: define labeller gate 0 contracts * docs: record contract ratification
* docs: audit registry label vocabulary * docs: record vocabulary ratification
* test: prove label crypto interoperability * test: keep crypto vector with retained tests * docs: record crypto ratification
Wave 0A complete
Gate decisions recorded:
The Cloudflare API connector and local Wrangler OAuth token both returned |
|
Shared label-verification precursor #1952 merged at 2d6ad44. Post-merge integration gate passed on that SHA: full build, full and aggregator typechecks, type-aware lint with 0 findings, 216 aggregator tests, 66 registry-moderation tests, and 29 labeler workerd tests. The shared primitive tracker item now cites #1952; signed subscription ingest remains open. |
- Sweep labeller -> labeler to match the #1926 US-spelling standardization of NSIDs, app/package names, and the aggregator labelers table - Reconcile ReleaseModeration and PublicAssessment shapes with the ratified gate-0 contract and the implemented registry-moderation evaluator, recording the applicableLabels addition - Fix gate-0 fixture links to their relocated product paths - Drop the dangling W0.8 dependency from W9.1 (removed with the gate-zero scope narrowing) - Assign the collision-safe label-history migration to W4.3
Staging and production use custom domains from the start. Production key generation is a deferred maintainer action with offline custody in Keeper; staging uses a disposable key so code work never blocks on the ceremony. Prune the ratified NSID item from the spec's open ratification points.
Publisher notifications send through the Workers send_email binding from an onboarded emdashcms.com address, so no provider API keys are needed. The open ratification point narrows to the monitored reconsideration address.
* feat(registry): ingest signed labels in the aggregator Adds the per-labeler subscribeLabels ingest path: a LabelIngestDO per configured labeler maintains the outbound WebSocket, verifies every label against the resolved #atproto_label key before queue acceptance, and persists its cursor to ingest_state only after durable enqueue. A new labels queue consumer writes append-only history and the label_state projection in one atomic batch. Replaces the label history identity (src, uri, val, cts) with a collision-safe digest primary key plus unique (src, source_sequence, frame_index) coordinates, and adds validated epoch-millisecond columns so SQL never orders RFC 3339 text. Tables were empty in every deployment (no writer existed; production preflight confirmed zero rows). * fix(registry): correct enforcement filter vocabulary and expiry comparison Adversarial review findings: the search enforcement filter still matched the legacy 'security:yanked' value (canonical labels use 'security-yanked') and compared RFC 3339 expiry strings as text, which misorders across timezone offsets. Both were dormant against the empty label_state table and would have gone live with the new ingest writer. The filter now matches the canonical value and compares the validated epoch-millisecond column. Also bounds the stream client's inbound decode buffer, failing closed on overflow. * fix(registry): address ingest review findings A connection ending on a verification failure no longer counts earlier frames as progress, so an alternating good/unverifiable stream escalates backoff instead of hammering. The scheduled wake pump awaits its DO fetches collectively so waitUntil keeps them alive. Stream listeners attach before accept() so an immediate frame or close can't slip past. The label migration refuses to run against a deployment that already has label rows. The consumer dead-letters any label whose datetimes fail to parse to instants rather than committing NaN epoch values.
* feat(registry): add accepted-labeler header parsing Adds parseAcceptLabelersHeader and serializeContentLabelersHeader to the shared moderation package: RFC 8941 list parsing restricted to the forms the ATProto label spec uses, strict DID validation, first-occurrence ordering, union merging of repeated-DID redact flags, and typed syntax errors. An empty header value means no accepted labelers; resolving a missing header to deployment defaults stays with the caller. * feat(registry): resolve accepted-labeler policy at the aggregator boundary Parses atproto-accept-labelers once per request: a missing header resolves to the deployment's trusted labelers with redact enabled, an empty header accepts none, malformed syntax returns InvalidRequest, and requested DIDs without a labelers row are omitted. The response reports the sources actually considered via atproto-content-labelers, now correctly CORS- exposed in place of the request header. Also exports the package-scope and release-scope hard-block vocabulary from the shared moderation package so SQL enforcement and the evaluator cannot drift. * feat(registry): enforce and hydrate labels on aggregator reads Search and latest-release selection exclude subjects carrying hard-block labels from the request's accepted sources, with CID-bound labels applying only to the subject's current CID and package/publisher blocks cascading to releases. Direct package reads and release lists return content with hydrated labels; an accepted source with redact enabled turns an active takedown into a response indistinguishable from absence, including for the parent package of a release list. Redaction decisions always see the full label set; the lexicon's 64-label cap applies only on the wire. Latest-release selection skips the latest_version fast path whenever an accepted policy is in force, since the pointer is not label-aware. * docs(registry): clarify label cap boundary and cursor redaction trade-off
* feat(registry): add hydrated moderation evaluation and client adapter evaluateHydratedReleaseModeration evaluates aggregator-hydrated labels that carry no signature, sharing one evaluation body with the branded verified path so the two cannot drift; the branded entrance keeps its runtime check and remains required for any positive-assessment gate. The registry client gains a moderation adapter that assembles subject context from validated views, an accepted-policy resolver preferring the response's atproto-content-labelers header over configured values, and response-header exposure on DiscoveryClient. Corrects a latent invalid CID in the ratified moderation corpus (recorded as a gate-0 amendment) that structural validation surfaced. * feat(registry): enforce moderation labels in install, update, and CLI Install and update gate on the shared moderation evaluation — package and publisher cascades, CID-bound labels, negation and expiry — immediately before artifact download, replacing the raw security:yanked string comparisons. Only actual blocking labels or a redact-flagged takedown block; the missing-assessment-pass state does not, since the positive- assessment gate is a later, flag-controlled phase. Update now fetches the package view (it previously checked release labels only, missing every cascade) and cross-checks aggregator identity like install. The artifact proxy refuses media for blocked releases, update-check reports per-plugin moderation state, and the CLI renders eligibility instead of raw labels. * fix(registry): block on labels regardless of assessment-state ranking The evaluator ranks pending/error above automated blocks, so a malware label with a co-present assessment-pending label yields eligibility 'pending' while blockingLabels still carries the block — and the gate, keyed on eligibility, failed open. All three consumers now key solely on blockingLabels/redacted, which still admits missing-assessment-pass and pure pending/error states. * fix(registry): fail closed on label-state collisions via one shared predicate A block label colliding with a same-cts negation puts the stream in the ratified fail-closed error state with empty blockingLabels, which the gate — restated separately in three consumers — read as not blocked. The shared moderation package now exports isModerationBlocking as the single blocking predicate (applicable blocking label, redact-flagged takedown, or label-state collision; never keyed on eligibility), and core install/ update, the artifact proxy, and the CLI all delegate to it.
* feat(admin): show registry moderation state and gate blocked installs The registry browser now evaluates moderation labels client-side: blocked releases stay visible but their install is gated with an explanation panel, warning-labelled releases surface their warnings in the install consent dialog, browse cards mark blocked packages, and the plugin manager flags blocked or warned updates. Each response is evaluated against the accepted-labeler policy that produced it — the atproto-content-labelers header travels with the data via a per-call discovery client. Localized label names come from a descriptor map over the canonical vocabulary, falling back to the raw value for anything unknown. Removes the legacy security:yanked string filter that silently hid yanked releases. * fix(admin): evaluate package labels against their own response policy Adversarial review: package-scope labels ride on the package response while release-scope labels ride on the releases response, each with its own atproto-content-labelers header. Evaluating both against only the releases policy could filter out a package/publisher block whose labeler that response did not report. The two header-derived policies are now unioned. Adds coverage for the header-precedence path and a package-scope block surfaced only via the package response header. * fix(admin): localize the release-holdback duration units Bot review: formatHoldback returned bare English units. Route them through Lingui plurals like the rest of the admin.
…#1976) * feat(registry): add assessment persistence and automated label issuance Adds the labeler's assessment storage model — subjects, assessments with a CHECK-constrained lifecycle vocabulary, the current-assessment pointer, findings, evidence objects, and the ingest/dead-letter tables discovery will use — with CAS-guarded state transitions and a deterministic runKey so redelivered triggers converge on one run. Finalization is exposed as a statement builder whose pointer update re-checks the state CAS inside the same batch, letting orchestration compose assessment completion and label issuance into one atomic transaction. The issuance boundary gains an automated-assessment action whose proposals are validated from the ratified policy fixture (release-only, CID rules, blocking values only from critical automated-block finding categories); manual issuance behavior is unchanged and its suite passes unmodified. * fix(registry): guard automated negation of manual labels and pointer recency Adversarial review findings. Automated issuance now refuses to negate a label whose active stream event was issued by a manual action (§10: automation cannot retract an action-backed human label) — a defense-in- depth guard at the issuance chokepoint, independent of the orchestrator's own candidate set. The current-assessment pointer upsert gains a recency guard so an older run finalizing after a newer one cannot regress it. CID presence is asserted unconditionally on the automated path per §20.2 rather than only when a policy rule requires it, and the atomicity test now fails on a runtime constraint violation so it genuinely proves batch rollback. * fix(registry): make the automated-negation guard atomic and harden rule lookup Bot review. The §10 negation guard now runs as a condition inside the issuance-action INSERT, not only as a pre-batch SELECT, closing the read-then-write race with a concurrent manual issuance; gating the action insert (not the label insert) leaves no orphan action when the guard fires. Automated-path release-rule selection uses .some() over all release rules so a duplicate reviewer-only rule can't mask an automated one, and the policy parser now rejects duplicate subjects outright. The assessment id pattern is shared from assessment-lifecycle rather than duplicated. * fix(registry): surface the negation policy violation, not a signing alert Bot re-review: when the in-batch §10 guard suppresses an automated negation, the missing issued_labels row was misdiagnosed in postCommit as a signing-state failure, firing misleading alerts. postCommit now re-runs the negation guard first so the caller sees the policy violation. * perf(registry): index the §10 negation guard's stream-head lookup Bot suggestion: the automated-negation guard's latest-label lookup per (src, uri, val) had no covering index. Add one so it stays cheap as a subject's label stream grows. * test(registry): prove negations enforce release-record and CID checks The release-record and mandatory-CID checks already precede the negation early-return; only finding-category/severity is skipped. Lock that in.
* feat(registry): add labeler discovery and assessment orchestration Independent Jetstream discovery of release records: a per-labeler discovery DO maintains the subscription (cursor in ingest_state), and the queue consumer verifies each event's exact URI+CID against the publisher's signed record before creating a subject and issuing assessment-pending — a forged or unverifiable event dead-letters and produces no label. The assessment orchestrator drives pending through the stage pipeline to an atomic finalization that composes the current-pointer update with label issuance and negates only this run's own prior automated labels, never a manually-issued one. Production wiring stops at assessment-pending; the orchestrator and its stage adapters ship with deterministic stubs exercised only by tests, enforced by a static production-boundary test. Scheduled reconciliation flags stuck runs and unassessed subjects. * fix(registry): retry transient DID resolution, tighten negation provenance Adversarial review. A thrown DID-document resolution is now a transient RecordVerificationError the consumer retries, rather than permanently dead-lettering a release on a directory blip at publish time; a document that resolves but lacks a PDS or key stays permanent. getNegatableAutomated Labels computes the active stream head across all issuers, so a val whose latest event is manual is never returned for automated negation. The production-boundary test also catches bare side-effect imports. The finalize() currency-check comment no longer claims a lock it doesn't hold and documents the in-batch guard / workflow lock required to wire the orchestrator live in W7/W8. * fix(registry): tighten the finalization currency window Re-check subject currency inside finalize() with no signing between the check and db.batch, shrinking the delete/cancel window from every label's signing round-trip to two adjacent D1 ops. Full closure still needs the workflow lock deferred to W7/W8; a mid-run delete injected via a stage now proves the re-check aborts before issuing any label. * fix(registry): scope negations to this labeler, verify deletes, emit all blocks Bot review of #1978. getNegatableAutomatedLabels is scoped to this labeler's own src (a labeler only negates labels it issued). A delete event now confirms the record is genuinely absent at the PDS before suppressing any work — a still-resolving record dead-letters as a forged/premature delete, matching the distrust the create path already applies. A PDS stream error after headers is re-wrapped as a transient PdsVerificationError so it retries instead of dead-lettering. And resolvePolicyOutcome emits every distinct critical blocking finding, not just the first. * fix(registry): dead-letter permanent delete-verification failures Bot review: the delete path retried permanent verification failures (INVALID_URI, DID_RESOLUTION_FAILED) instead of dead-lettering them, diverging from the create path. Both paths now route through one shared classifier — transient retries, permanent dead-letters and acks — so they can't drift. * fix(registry): reactivate tombstoned subjects and break currency ties Bot review. createSubject's conflict clause now clears deleted_at on a verified re-observation, so a delete-then-recreate (or an out-of-order delete before create) correctly reactivates the subject — closing the create-after-delete race that was previously deferred, since the create path only runs after PDS verification confirms the record exists. isSubjectCurrent breaks a same-millisecond observed_at tie by CID so exactly one subject at a URI is ever current. * fix(registry): negate assessment-pending on delete, keep fresh error label Bot review. A delete now negates the assessment-pending label each cancelled run issued (using the run's own assessment id and a deterministic idempotency key), so a deleted release stops advertising an in-progress assessment through queryLabels. And finalize()'s negation pass keeps a freshly-issued assessment-error, so a second error run for the same subject can't negate its own new error label. * fix(registry): retry issuance during signing pause, test error preservation Bot review. issueAutomatedAssessmentLabel's signing-pause/stale/refresh conditions now throw a typed LabelIssuanceUnavailableError (identical messages) that the discovery consumer retries instead of dead-lettering — so a discovery or delete event arriving mid key-rotation doesn't permanently lose its label. Adds the two-consecutive-error-runs regression test for the assessment-error keep-set fix, and a paused- issuance retry test. * test(registry): cover paused-issuance retry on the delete path * docs(registry): document the deferred finalization concurrency gaps The three finalize() concurrency gaps (stale run leaving its pending label, unchecked finalization CAS, uncaught transition conflict) are all closed by the per-subject workflow lock the spec assigns to production wiring (§14.1, W7/W8). The production-boundary test proves nothing reaches this method in production until then; documenting them together so the lock work closes the whole family.
… machinery Bundled plugins have no dependency graph; remove all dependency/advisory/CVE/ malware-hash/signature analysis. Full size-capped bundle analyzed in-full by Kimi K2.7 Code via the AI binding (multimodal: code + images). Real pipeline is deterministic declared-vs-actual capability analysis + full-bundle AI. Frozen public output shape (scannerVersions, coverage.dependencies) retained for contract stability, vestigial in v1.
…dencies Unpublished service; no dependency/scanner analysis exists, so remove the fields rather than carry them empty. Lexicon, bindings, and code pruned in the public-API PR.
* feat(labeler): normalized finding contract (W8.1) Add the canonical NormalizedFinding shape every analysis stage produces, with validateFinding/validateFindings enforcing it against the policy-derived allowed-category set (automated-block union warning) and a run's recorded evidence objects before an assessment finalizes. toPublicFindingView routes through evidence.ts's never-typed PrivateFindingRecord -> PublicFindingView narrowing so private fields can't leak. The orchestrator now validates collected findings before resolving policy outcome; an invalid finding aborts the run as a non-retryable stage-adapter bug. * fix(labeler): copy validated finding arrays to decouple from caller input requireStringArray returned the caller's array by reference, so mutating the input array after validation would mutate the validated finding. Return a copy so the finding matches the contract's immutability claim.
What does this PR do?
Establishes
feat/plugin-registry-labelling-serviceas the integration branch for the plugin registry labelling service described in RFC #694.This PR tracks the complete implementation without sub-issues. Implementation PRs should target
feat/plugin-registry-labelling-service; this umbrella PR remains draft until the production launch gates in the implementation plan are complete.The tracked planning documents are:
.opencode/plans/plugin-registry-labelling-service/spec.md.opencode/plans/plugin-registry-labelling-service/implementation-plan.mdRelated to #694.
Implementation tracker
Design and feasibility
security:yankedtosecurity-yankedcutover (docs: audit registry label vocabulary #1912)security:yankedproduction preflight (zero legacy rows confirmed; Branch A, no compatibility path)Shared protocol and policy
Service foundation and signed distribution
apps/labeler, bindings, CI coverage, and real workerd/D1 tests (feat(registry): add labeler service core #1926)com.atproto.label.queryLabels(feat(registry): add labeler service core #1926)com.atproto.label.subscribeLabels(feat(registry): add label subscriptions #1931)NotSupportedfor deferred standard report intake (feat(registry): add labeler key rotation #1938)Aggregator and client enforcement
atproto-content-labelers(feat(registry): label-aware aggregator reads #1965)Automated assessment
registry-verification; W7 extends it from main)Operator and publisher surfaces
Production readiness
Type of change
Checklist
pnpm typecheckpassespnpm lintpassespnpm testpasses (or targeted tests for my change)pnpm formathas been runmessages.pochanges except in translation PRs; a workflow extracts catalogs on merge tomain.RFC #694 is the approved design source. Individual implementation PRs carry their applicable changesets and verification evidence. No admin translation catalogs are included.
AI-generated code disclosure
Screenshots / test output
Integrated gate on
2d6ad448after #1952 merged:pnpm buildpassespnpm typecheckand aggregator typecheck passTry this PR
Open a fresh playground →
A full working EmDash site, deployed from this branch. Each visit gets its own session-scoped sandbox: no login needed and no shared state. Try the admin, edit content, hit the public site.
Tracks
feat/plugin-registry-labelling-service. Updated automatically when the playground redeploys.