Skip to content

feat(registry): ingest signed labels in the aggregator#1959

Merged
ascorbic merged 3 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-aggregator-ingest
Jul 11, 2026
Merged

feat(registry): ingest signed labels in the aggregator#1959
ascorbic merged 3 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-aggregator-ingest

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Implements aggregator signed-label ingest — W4.2, W4.3, and the collision-safe history migration from the labelling-service plan (#1909).

  • LabelIngestDO (one per configured labeler, named by DID, woken from the existing cron): maintains the outbound com.atproto.label.subscribeLabels WebSocket, verifies every label against the labeler's resolved #atproto_label key before queue acceptance, and persists its cursor to ingest_state (labeler:<did>) only after every label in a frame is durably enqueued. A fresh labeler subscribes from cursor=0 for full history (the labeler's default is "start from now"). Any unverifiable label fails closed: nothing enqueued, cursor unmoved, connection closed with backoff — after one resolveFresh retry per connection to absorb key rotation.
  • Labels queue consumer: writes append-only history (labels) and the label_state projection in one atomic db.batch. History identity is the SHA-256 digest of the canonical signed bytes (exact redelivery is a silent no-op) plus UNIQUE (src, source_sequence, frame_index) (a different label at taken coordinates dead-letters as a permanent conflict). Projection winner per (src, uri, val) is decided by validated epoch-ms instants, tie-broken by stream coordinates, in a conditional upsert that stays correct under concurrent batches and out-of-order delivery.
  • Migration 0003 drops and recreates labels/label_state with the collision-safe identity and cts_epoch_ms/exp_epoch_ms columns. The tables are empty in every deployment: no writer existed anywhere in code, and the production zero-row preflight was confirmed on Plugin registry labelling service #1909.
  • Enforcement filter fixes (adversarial-review findings that would have gone live with this writer): searchPackages' hard filter matched the legacy security:yanked value instead of canonical security-yanked, and compared RFC 3339 expiry strings as text — which misorders across timezone offsets. It now matches the canonical value and compares exp_epoch_ms. Both regressions are covered by tests that were verified to fail against the old SQL.

Review notes:

  • isCoordinateConflict classifies the coordinate-unique violation by SQLite error text (both column names must appear). This is exercised against workerd/miniflare D1 in tests; if production D1 ever words it differently, the failure degrades to retry → labels DLQ → forensics dead_letters row rather than silent loss.
  • A labeler row removed after its DO persisted the DID keeps a fail-closed retry loop (resolution fails; nothing is ingested). Deprovisioning/reconciliation is W4.7.
  • The subscription covers all configured labelers rows; trusted gates enforcement, not subscription, so untrusted-but-known labelers build history without policy effect.

Implementation PRs target feat/plugin-registry-labelling-service per the umbrella PR. Related to #1909 and RFC #694.

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: RFC RFC: Decentralized Plugin Registry #694

Admin i18n is n/a — no UI changes. Changeset is n/a — @emdash-cms/aggregator is a private package; the only published-package change is adding @emdash-cms/registry-moderation as its dependency, which doesn't alter any published package.

AI-generated code disclosure

  • This PR includes AI-generated code — model/tool: Claude Code with Claude Fable 5 (design, review, fixes) and Sonnet/Opus subagents (implementation, adversarial review)

Screenshots / test output

  • pnpm --filter @emdash-cms/aggregator test: 13 files, 265 tests pass (44 ingestor/stream-client unit tests with real P-256 signing, 19 workerd/D1 consumer tests, 3 enforcement-filter regression tests)
  • pnpm --filter @emdash-cms/aggregator typecheck clean
  • pnpm lint:quick 0 diagnostics; type-aware pnpm lint:json has no findings in any touched file
  • Enforcement regression tests confirmed to fail against the pre-fix SQL (stash-verified) before passing with the fix

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/labeler-aggregator-ingest. Updated automatically when the playground redeploys.

ascorbic added 2 commits July 11, 2026 18:06
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).
…rison

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.
@changeset-bot

changeset-bot Bot commented Jul 11, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 753b512

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 2,826 lines across 16 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.

@github-actions github-actions Bot added review/needs-review No maintainer or bot review yet cla: signed labels Jul 11, 2026
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 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 753b512 Jul 11 2026, 06:07 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 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 753b512 Jul 11 2026, 06:06 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 11, 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 753b512 Jul 11 2026, 06:07 PM

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I reviewed the signed-label ingest pipeline introduced by this PR: the Durable Object lifecycle and queue wiring, the streaming client, the ingestor's verification/cursor/backoff logic, the labels consumer's batch write path, the labeler resolver, the searchPackages enforcement-filter fixes, the migration, the wrangler/queue/DO configuration, and the associated unit tests.

The pipeline is well-structured and the tests cover most of the intended behavior (signature verification, cursor persistence, redelivery idempotency, coordinate conflicts, expiry, source snapshots). However, I found three logic bugs that should block merge:

  1. LabelIngestor backoff accounting swallows verification failures whenever any earlier frame succeeded. madeProgress is set to true after every fully processed frame, so a connection that processes some valid frames and then fails verification is treated as a successful run and resets consecutiveFailures. This contradicts the fail-closed intent and lets a misbehaving or compromised labeler force a tight reconnect loop.

  2. The cron wakeLabelIngestDOs fire-and-forgets DO fetches inside an awaited function. The promises are not collected or returned, so ctx.waitUntil(wakeLabelIngestDOs(env)) may resolve before the fetches complete. If the runtime invocation is released early, some DO wakes may be dropped.

  3. RealLabelStreamClient accept()s the WebSocket before attaching message/close listeners. Any frames or close events sent by the labeler immediately after the upgrade can be lost, causing hangs or missed historical replay.

I also flagged two lower-priority suggestions: defending the migration's unconditional table drops, and guarding Date.parse timestamps in the consumer.

Add a regression test for case (1) — process one valid frame then an unverifiable frame in the same connection and assert that consecutiveFailures increases.

Comment on lines +168 to +172
if (this.madeProgress) this._consecutiveFailures = 0;
else this._consecutiveFailures += 1;
} catch (err) {
if (this.madeProgress) this._consecutiveFailures = 0;
else this._consecutiveFailures += 1;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] connectAndConsume() sets this.madeProgress = true (line 262) after every fully-processed frame. If a later label in the same connection fails verification and sets verificationClosed = true (lines 221/237), the method returns into this run loop, which then sees madeProgress === true and resets _consecutiveFailures to 0. That means a stream that alternates one good frame with one unverifiable frame never escalates backoff, contradicting the fail-closed comment at line 247.

The cleanest fix is to mark the connection as not having made safe progress when verificationClosed triggers:

Suggested change
if (this.madeProgress) this._consecutiveFailures = 0;
else this._consecutiveFailures += 1;
} catch (err) {
if (this.madeProgress) this._consecutiveFailures = 0;
else this._consecutiveFailures += 1;
if (verificationClosed) {
// Fail closed: nothing from this frame is enqueued, the
// cursor doesn't move, and the connection ends here (the
// `finally` below closes `sub`). The run loop's backoff
// counter picks this up as a failed attempt.
this.madeProgress = false;
break;
}

Also add a regression test: emit one verifiable frame, then an unverifiable frame in the same connection, and assert that ingestor.consecutiveFailures increases.

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.

Fixed in 753b512: verificationClosed now clears madeProgress so the connection counts as a failed attempt, with a regression test (good frame then unverifiable frame in one connection asserts consecutiveFailures increases, cursor stays at the good frame).

Comment on lines +327 to +348
async function wakeLabelIngestDOs(env: Env): Promise<void> {
try {
const rows = await env.DB.prepare(`SELECT did FROM labelers`).all<{ did: string }>();
for (const row of rows.results ?? []) {
const stub = env.LABEL_INGEST_DO.getByName(row.did);
// Fire-and-forget per labeler — one slow/unreachable DO shouldn't
// block waking the others.
void stub
.fetch(`https://do.internal/wake?did=${encodeURIComponent(row.did)}`)
.catch((err: unknown) => {
console.error("[aggregator] label ingest DO wake failed", {
did: row.did,
error: err instanceof Error ? err.message : String(err),
});
});
}
} catch (err) {
console.error("[aggregator] label ingest DO wake pump failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] wakeLabelIngestDOs is passed to ctx.waitUntil at line 323, but inside the loop it does void stub.fetch(...) and returns as soon as the loop finishes. Because the per-labeler fetches are not awaited or collected, the waitUntil promise can resolve before the fetches complete, and pending work may be dropped when the cron invocation is released. The .catch() on each fetch prevents one slow labeler from rejecting the batch, but the calls still need to be awaited collectively to keep the invocation alive.

Change the function to collect the promises and return Promise.all(...):

Suggested change
async function wakeLabelIngestDOs(env: Env): Promise<void> {
try {
const rows = await env.DB.prepare(`SELECT did FROM labelers`).all<{ did: string }>();
for (const row of rows.results ?? []) {
const stub = env.LABEL_INGEST_DO.getByName(row.did);
// Fire-and-forget per labeler — one slow/unreachable DO shouldn't
// block waking the others.
void stub
.fetch(`https://do.internal/wake?did=${encodeURIComponent(row.did)}`)
.catch((err: unknown) => {
console.error("[aggregator] label ingest DO wake failed", {
did: row.did,
error: err instanceof Error ? err.message : String(err),
});
});
}
} catch (err) {
console.error("[aggregator] label ingest DO wake pump failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}
async function wakeLabelIngestDOs(env: Env): Promise<void> {
try {
const rows = await env.DB.prepare(`SELECT did FROM labelers`).all<{ did: string }>();
const wakes: Promise<void>[] = [];
for (const row of rows.results ?? []) {
const stub = env.LABEL_INGEST_DO.getByName(row.did);
// Fire-and-forget per labeler — one slow/unreachable DO shouldn't
// block waking the others.
wakes.push(
stub
.fetch(`https://do.internal/wake?did=${encodeURIComponent(row.did)}`)
.catch((err: unknown) => {
console.error("[aggregator] label ingest DO wake failed", {
did: row.did,
error: err instanceof Error ? err.message : String(err),
});
}),
);
}
await Promise.all(wakes);
} catch (err) {
console.error("[aggregator] label ingest DO wake pump failed", {
error: err instanceof Error ? err.message : String(err),
});
}
}

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.

Fixed in 753b512: the wake fetches are collected and awaited via Promise.all, with the per-fetch catch retained so one unreachable DO can't fail the batch.

Comment on lines +221 to +228
socket.accept();
if (ended) {
// close() won the race against the upgrade: without this the
// accepted socket would stay open with nobody reading it.
socket.close();
return;
}
socket.addEventListener("message", (event: MessageEvent) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[needs fixing] socket.accept() is called at line 221, but the message and close listeners are not attached until line 228+. If the labeler sends a frame or closes the connection immediately after the WebSocket upgrade, the event is delivered while no handler is listening and is silently lost. This can cause the async iterator to miss replay frames or hang because finish() is never called.

Add the listeners (and explicitly request binary frames) before accepting the socket:

Suggested change
socket.accept();
if (ended) {
// close() won the race against the upgrade: without this the
// accepted socket would stay open with nobody reading it.
socket.close();
return;
}
socket.addEventListener("message", (event: MessageEvent) => {
socket = response.webSocket;
socket.binaryType = "arraybuffer";
socket.addEventListener("message", (event: MessageEvent) => {
if (!(event.data instanceof ArrayBuffer)) {
deliver({
kind: "error",
error: new TypeError("subscribeLabels message was not binary"),
});
socket?.close();
return;
}
handleMessage(event.data);
});
socket.addEventListener("close", () => {
finish();
});
socket.accept();
if (ended) {
// close() won the race against the upgrade: without this the
// accepted socket would stay open with nobody reading it.
socket.close();
return;
}

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.

Fixed in 753b512: message and close listeners attach before accept() (the close()-race guard stays after it). Left binaryType unset — workerd delivers binary frames as ArrayBuffer and the handler already fails closed on anything else.

-- different label landing at coordinates the ingestor already used. `cts`/`exp`
-- gain epoch-millisecond columns because RFC 3339 strings compare incorrectly
-- across timezone offsets in SQL.
DROP TABLE labels;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The migration drops labels and label_state unconditionally. The comment states these tables are empty and preflight confirmed it, but a guard that aborts if rows exist would be safer against accidental data loss if that assumption ever changes in an existing deployment. Since this is still pre-1.0 and preflight-confirmed empty, treat as low-priority polish.

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.

Taken in 753b512 — worth more than polish given self-hosted aggregators we can't preflight: an unsatisfiable-CHECK guard table makes the migration fail if either label table has rows, tested in migrations.test.ts by seeding a row and asserting the migration rejects.

Comment on lines +180 to +181
const ctsEpochMs = Date.parse(label.cts);
const expEpochMs = label.exp === undefined ? null : Date.parse(label.exp);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] Date.parse(label.cts) and Date.parse(label.exp) are inserted directly. If a malformed date ever reaches this point, the epoch columns silently become NaN, which can invert comparisons in the state upsert and search-enforcement filters. parseSignedLabel likely validates these today, but an explicit guard here would keep the consumer fail-closed: check Number.isNaN(ctsEpochMs) (and expEpochMs) and dead-letter with LABEL_INVALID rather than committing bad epoch values.

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.

Taken in 753b512: NaN in either parsed instant dead-letters as LABEL_INVALID before any write.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-review No maintainer or bot review yet labels Jul 11, 2026
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.
@github-actions github-actions Bot added review/needs-rereview Author pushed changes since the last review and removed review/awaiting-author Reviewed; waiting on the author to respond labels Jul 11, 2026
@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 11, 2026

@emdashbot emdashbot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This PR implements the signed-label ingest pipeline (labeler DO, stream client, queue consumer, D1 schema migration, and the searchPackages enforcement-filter fixes) against the labelling-service plan. The approach is sound and consistent with the existing records-DO/ingestor architecture.

All three blockers from the previous review are fixed in this revision:

  1. Backoff accounting after verification failureLabelIngestor.connectAndConsume now resets madeProgress = false at the start of every connection and clears it again on verificationClosed, so a stream that processes some valid frames and then hits an unverifiable frame counts as a failed attempt. A regression test was added.
  2. Cron DO wake Promise collectionwakeLabelIngestDOs now awaits Promise.all(...) for the per-labeler fetch calls, so ctx.waitUntil keeps the invocation alive until every wake lands.
  3. WebSocket listener orderingRealLabelStreamClient attaches message/close listeners before calling socket.accept().

I have one remaining low-confidence suggestion. The real WebSocket binary-frame path is not exercised by the unit tests (which only call the exported decodeLabelStreamFrame directly with a Uint8Array), and RealLabelStreamClient does not explicitly set socket.binaryType = "arraybuffer". If the runtime ever delivers binary frames as Blob (the WebSocket spec default), the !(event.data instanceof ArrayBuffer) guard would close every connection. Explicitly setting binaryType removes that latent risk.

Overall the pipeline is solid; with the binary-type defensive fix the ingest surface would be robust.

Comment on lines +221 to +223
// Listeners attach before accept() so a frame or close arriving
// immediately after the upgrade can't slip past unhandled.
socket.addEventListener("message", (event: MessageEvent) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] RealLabelStreamClient does not explicitly set the WebSocket binaryType. The ATProto label stream sends binary CBOR frames, and this code assumes they arrive as ArrayBuffer. If the runtime default is ever blob, !(event.data instanceof ArrayBuffer) at line 226 will fail and the ingestor will treat every binary frame as a fatal stream error.

Because the unit tests call decodeLabelStreamFrame directly with Uint8Array inputs, the real WebSocket binary path is currently untested, so this latent default would not be caught by the test suite.

Suggested change
// Listeners attach before accept() so a frame or close arriving
// immediately after the upgrade can't slip past unhandled.
socket.addEventListener("message", (event: MessageEvent) => {
socket = response.webSocket;
// Defensive: ensure binary frames are delivered as ArrayBuffer.
// The labeler sends CBOR; the decode path expects ArrayBuffer input.
socket.binaryType = "arraybuffer";
socket.addEventListener("message", (event: MessageEvent) => {

Setting binaryType before accept() (and before any listener can fire) guarantees the subsequent event.data instanceof ArrayBuffer check is reliable.

@github-actions github-actions Bot added review/awaiting-author Reviewed; waiting on the author to respond and removed review/needs-rereview Author pushed changes since the last review labels Jul 11, 2026
@ascorbic ascorbic merged commit c5983a5 into feat/plugin-registry-labelling-service Jul 11, 2026
30 of 32 checks passed
@ascorbic ascorbic deleted the feat/labeler-aggregator-ingest branch July 11, 2026 18:39
@ascorbic ascorbic mentioned this pull request Jul 11, 2026
81 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot:review Trigger an emdashbot code review on this PR cla: signed review/awaiting-author Reviewed; waiting on the author to respond size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant