Skip to content

feat(registry): labeler discovery and assessment orchestration#1978

Merged
ascorbic merged 10 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-discovery
Jul 12, 2026
Merged

feat(registry): labeler discovery and assessment orchestration#1978
ascorbic merged 10 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-discovery

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds independent Jetstream discovery and assessment orchestration to the labeler — plan workstream W6, on top of the assessment persistence layer (#1976). It wires the production path up to assessment-pending; the full analysis pipeline ships with deterministic stub adapters, exercised only by tests, until W7/W8 provides real analyses.

  • Discovery: a LabelerDiscoveryDO maintains an independent Jetstream subscription to release records (cursor persisted in ingest_state). The queue consumer verifies each event's exact URI+CID against the publisher's signed record (fetchAndVerifyExactRecord) before creating a subject and issuing a signed assessment-pending label. A forged or unverifiable event dead-letters and produces nothing; a CID mismatch (the PDS serving a newer version than the event named) is a permanent dead-letter; a transient PDS or DID-directory failure retries. Redelivery converges on one run and one pending label via the deterministic runKey and idempotency key.
  • Orchestration: AssessmentOrchestrator drives pending → running → stages → finalization. Finalization composes the assessment CAS + current-pointer update with all label statements (outcome labels, assessment-error on transient exhaustion, negation of this run's own assessment-pending, and negation of prior automated labels this outcome no longer supports) into one atomic db.batch. It never negates a manually-issued label (getNegatableAutomatedLabels returns only automated-provenanced labels whose current stream head is automated).
  • Reconciliation: a scheduled job flags runs stuck in a non-terminal state and subjects lacking any run (read-only; structured logs that W11.3 turns into alerts).

Adversarial review (opus) findings addressed: transient DID-resolution failures now retry instead of permanently dropping a release on a directory blip (#2); getNegatableAutomatedLabels computes the active stream head across all issuers so a manually-negated val is never a candidate (#4); the production-boundary test also catches bare side-effect imports (#6); and the finalization currency re-check was moved to immediately before the commit, shrinking the delete/cancel window from "spans every label's signing round-trip" to two adjacent D1 ops, with a mid-run-delete test.

Two deferrals, called out for the maintainer (both provably unreachable in production — the production-boundary test enforces that no production path constructs the orchestrator until W7/W8):

  1. Full closure of the finalization/delete race requires the per-subject workflow lock the spec assigns to live wiring (§14.1), or an in-batch state guard on every issuance statement (which couples through the shared signer's postCommit diagnosis). The window is tightened and documented; the lock lands with the orchestrator's production wiring in W7/W8. The overstated "under the same lock" comment is corrected.
  2. A create-after-delete race (a delete processed before a create's INSERT resurrects a tombstone) is a narrow eventual-consistency window, already partly mitigated by the create path's PDS verification, and needs a deletes-log to close cleanly. Documented as a W6.8/reconciliation follow-up.

If you'd rather either be fully closed before this merges, say so.

Targets feat/plugin-registry-labelling-service. Related to #1909 and RFC #694.

Type of change

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)
  • 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 and changeset are n/a — apps/labeler is a private, unpublished Worker app.

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/labeler test: 121 pass (75 from the persistence layer + 46 new: discovery consumer, record verification, orchestrator with stub stages, reconciliation, and the static production-boundary test)
  • Typecheck clean; pnpm lint:quick 0 diagnostics
  • The copied Jetstream/PDS primitives are byte-diff-clean against the aggregator originals

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-discovery. Updated automatically when the playground redeploys.

ascorbic added 3 commits July 12, 2026 13:52
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.
…nance

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

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 70d5cc9

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 github-actions Bot added the review/needs-review No maintainer or bot review yet label Jul 12, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Scope check

This PR changes 3,692 lines across 23 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.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 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 70d5cc9 Jul 12 2026, 02:41 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 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 70d5cc9 Jul 12 2026, 02:42 PM

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 12, 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 70d5cc9 Jul 12 2026, 02:42 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.

Approach judgment

This PR is the right next slice for W6: it keeps the production path at assessment-pending, ships the orchestrator as test-only code behind a clear boundary test, and adds the independent Jetstream discovery consumer, reconciliation, and verification plumbing. The structure matches the aggregator precedent and the persistence model from #1976. Test coverage is extensive and the persistence-layer atomicity contract is well documented.

However, there are several real correctness and security gaps in the production-bound discovery path and the finalization/negation plumbing that should be fixed before this merges. The most important are:

  1. Negation candidates are not scoped to this labeler. getNegatableAutomatedLabels queries across all src values, so a label stream from another issuer can incorrectly drive negations in this labeler's own stream, or suppress negation of this labeler's own active labels.
  2. Mid-download network failures are treated as permanent. In pds-verify.ts, a stream read error after headers are received is rethrown as a generic Error and reaches the consumer as an unexpected failure, which dead-letters instead of retrying — contradicting the stated transient-policy.
  3. Delete events are not verified. The discovery consumer trusts Jetstream delete events and immediately cancels non-terminal assessments. A forged or relay-compromised delete can suppress labels without the PDS verification that create/update events require.
  4. Only one blocking label is issued. resolvePolicyOutcome uses findings.find for critical blockers, so a run with multiple blocking findings only emits the first one; warnings correctly dedupe and emit all.

I also note the acknowledged deferrals from the PR description (finalization/delete race, create-after-delete tombstone resurrection) but do not re-file them.

Comment on lines +373 to +391
export async function getNegatableAutomatedLabels(
db: D1Database,
input: { uri: string; cid: string },
): Promise<NegatableAutomatedLabel[]> {
const rows = await db
.prepare(
// The inner MAX reflects the TRUE stream head across all issuers, so a
// val whose latest event was a manual action is never returned — only
// a val whose current active event is an automated non-negation is a
// candidate for automated negation (§10).
`SELECT l.val
FROM issued_labels l
JOIN issuance_actions a ON a.id = l.action_id
WHERE l.uri = ? AND l.cid = ? AND a.type = 'automated-assessment' AND l.neg = 0
AND l.sequence = (
SELECT MAX(l2.sequence) FROM issued_labels l2
WHERE l2.uri = l.uri AND l2.cid = l.cid AND l2.val = l.val
)`,
)

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] getNegatableAutomatedLabels returns automated positive labels without filtering by src. In AT Protocol each labeler maintains its own stream of labels and can only negate labels it issued. Because the outer query is not scoped to this.config.labelerDid, another issuer's later automated positive can become the "active" candidate, causing this labeler to emit a negation for its own stream for a label it may not have issued, or worse, fail to negate its own active label when another issuer has a newer one. Both the outer query and the inner MAX(sequence) lookup should be scoped to the calling source, and the caller in assessment-orchestrator.ts should pass src: this.config.labelerDid.

Suggested change
export async function getNegatableAutomatedLabels(
db: D1Database,
input: { uri: string; cid: string },
): Promise<NegatableAutomatedLabel[]> {
const rows = await db
.prepare(
// The inner MAX reflects the TRUE stream head across all issuers, so a
// val whose latest event was a manual action is never returned — only
// a val whose current active event is an automated non-negation is a
// candidate for automated negation (§10).
`SELECT l.val
FROM issued_labels l
JOIN issuance_actions a ON a.id = l.action_id
WHERE l.uri = ? AND l.cid = ? AND a.type = 'automated-assessment' AND l.neg = 0
AND l.sequence = (
SELECT MAX(l2.sequence) FROM issued_labels l2
WHERE l2.uri = l.uri AND l2.cid = l.cid AND l2.val = l.val
)`,
)
export async function getNegatableAutomatedLabels(
db: D1Database,
input: { src: string; uri: string; cid: string },
): Promise<NegatableAutomatedLabel[]> {
const rows = await db
.prepare(
// The inner MAX reflects the stream head for this labeler's own
// source, so a manually-negated or manually-issued val from this
// source is never returned as a candidate. We only negate labels we
// issued ourselves (spec §10).
`SELECT l.val
FROM issued_labels l
JOIN issuance_actions a ON a.id = l.action_id
WHERE l.src = ? AND l.uri = ? AND l.cid = ? AND a.type = 'automated-assessment' AND l.neg = 0
AND l.sequence = (
SELECT MAX(l2.sequence) FROM issued_labels l2
WHERE l2.src = l.src AND l2.uri = l.uri AND l2.cid = l.cid AND l2.val = l.val
)`,
)
.bind(input.src, input.uri, input.cid)
.all<{ val: string }>();
return (rows.results ?? []).map((row) => ({ val: row.val }));
}

Call site in assessment-orchestrator.ts should also be updated to pass src: this.config.labelerDid.

Comment thread apps/labeler/src/pds-verify.ts Outdated
Comment on lines +191 to +196
} catch (err) {
// Cancel the stream so the underlying socket isn't left dangling.
await reader.cancel().catch(() => {
/* swallow — we already have a primary error to surface */
});
throw 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] The fetchCar catch block rethrows whatever reader.read() threw. If the PDS connection drops or the stream is aborted after the response headers were received, that error surfaces as a generic Error in discovery-consumer.ts, is caught by the unexpected-error branch, and is dead-lettered as UNEXPECTED_ERROR. The PR's stated policy is that network/timeout failures are transient and should retry. Wrap non-PdsVerificationError stream errors as PDS_NETWORK_ERROR.

Suggested change
} catch (err) {
// Cancel the stream so the underlying socket isn't left dangling.
await reader.cancel().catch(() => {
/* swallow — we already have a primary error to surface */
});
throw err;
} catch (err) {
// Cancel the stream so the underlying socket isn't left dangling.
await reader.cancel().catch(() => {
/* swallow — we already have a primary error to surface */
});
// A network drop or stream abort after headers is a transient PDS
// failure, not a permanent verification failure.
if (!(err instanceof PdsVerificationError)) {
throw new PdsVerificationError(
"PDS_NETWORK_ERROR",
err instanceof Error ? err.message : String(err),
undefined,
err,
);
}
throw err;
} finally {

Comment on lines +91 to +99
const blocking = findings.find(
(f) => f.severity === "critical" && blockCategories.has(f.category),
);
if (blocking) {
return {
toState: "blocked",
labels: [
{ val: blocking.category, findingCategory: blocking.category, severity: "critical" },
],

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] resolvePolicyOutcome only emits the first critical blocking finding. A release could trigger several distinct automated-block findings (for example, both malware and supply-chain-compromise), in which case the final outcome should carry all applicable blocking labels, not just whichever appears first. The warning branch already collects all unique warning values; the blocking branch should do the same.

Suggested change
const blocking = findings.find(
(f) => f.severity === "critical" && blockCategories.has(f.category),
);
if (blocking) {
return {
toState: "blocked",
labels: [
{ val: blocking.category, findingCategory: blocking.category, severity: "critical" },
],
const blocking = findings.filter(
(f) => f.severity === "critical" && blockCategories.has(f.category),
);
const blockingVals = [...new Set(blocking.map((f) => f.category))];
if (blockingVals.length > 0) {
return {
toState: "blocked",
labels: blockingVals.map((val) => ({
val,
findingCategory: val,
severity: "critical",
})),
};
}

const now = deps.now ?? (() => new Date());
const uri = jobUri(job);

if (job.operation === "delete") {

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] The delete path in processDiscoveryMessage trusts the Jetstream event and immediately tombstones subjects and cancels non-terminal runs. Create/update events are verified against the publisher's PDS, but a forged or relay-compromised delete event can suppress assessments and labels without any cryptographic or PDS check. At minimum, the consumer should verify the delete (for example, by resolving the DID/PDS and confirming the record is absent or deleted, or by requiring a prior verified subject before cancelling runs) before mutating assessment state.

@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 12, 2026
ascorbic added a commit that referenced this pull request Jul 12, 2026
…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.
@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 12, 2026
…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.
@ascorbic ascorbic force-pushed the feat/labeler-discovery branch from d281a04 to ee843c2 Compare July 12, 2026 13:12
@ascorbic

Copy link
Copy Markdown
Collaborator Author

All four addressed in ee843c2:

  1. Negation src scoping (needs fixing)getNegatableAutomatedLabels now filters l.src = <this labeler DID> (outer and inner MAX). A labeler only negates labels it issued. (In practice issued_labels only ever holds this labeler's own rows, so it was latent, but the scoping matches intent and is robust to any future change.)

  2. Mid-download error → retry (needs fixing)fetchCar's catch re-wraps a post-headers stream error as a transient PdsVerificationError(PDS_NETWORK_ERROR); our own PdsVerificationErrors (e.g. RESPONSE_TOO_LARGE) pass through with their existing classification. The consumer now retries instead of dead-lettering UNEXPECTED_ERROR.

  3. Unverified deletes (needs fixing) — the delete path now calls confirmRecordAbsent (verifies the record is genuinely gone at the PDS) before suppressing: a still-resolving record dead-letters as DELETE_RECORD_PRESENT and suppresses nothing, transient failures retry. Tests cover the confirmed-absent, forged-present (subject not tombstoned, run stays pending), and transient cases. Worth noting the practical exploitability was already low — a forged delete can't make a malicious release installable because the missing-positive-assessment gate stays fail-closed — but the create/delete verification asymmetry is now closed.

  4. Only first blocking label (needs fixing)resolvePolicyOutcome collects every distinct critical automated-block finding (matching the warning branch), with a two-blocker test.

124 tests pass; typecheck and lint clean.

@ascorbic ascorbic added the bot:review Trigger an emdashbot code review on this PR label Jul 12, 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.

Re-review of the discovery/orchestration slice. The four prior blockers are all addressed:

  1. Negation candidates scoped to this labelergetNegatableAutomatedLabels now filters on l.src = ? and computes the stream head within that source.
  2. Mid-download transient failurespds-verify.ts now rewraps stream-read errors as PdsVerificationError("PDS_NETWORK_ERROR") so the consumer retries.
  3. Delete-event verificationdiscovery-consumer.ts calls confirmRecordAbsent before acting on a delete and dead-letters forged/premature deletes whose record still resolves.
  4. Multiple blocking labelsresolvePolicyOutcome collects all distinct critical block categories and emits one label per category.

The approach remains sound: production wiring still stops at assessment-pending, the orchestrator is kept behind the static production-boundary test, and the Jetstream/verification plumbing matches the aggregator precedent.

I found one new correctness issue: the delete-handling path in discovery-consumer.ts retries permanent verification failures (INVALID_URI, DID_RESOLUTION_FAILED, and any unexpected error) instead of dead-lettering them. This contradicts the stated policy that "a forged or unverifiable event is ALWAYS a dead letter, never a retry" and diverges from the create/update path, which maps permanent RecordVerificationError reasons straight to dead_letters and acks. It should mirror that classification logic. Because the queue has max_retries: 5, a malformed delete will eventually land in the DLQ, but it will burn retries and lose its specific reason code.

Test coverage is otherwise extensive and the atomicity contract is well documented.

Comment on lines +218 to +234
} catch (err) {
if (err instanceof PdsVerificationError && isTransient(err.reason, err.status)) {
controller.retry();
return;
}
if (err instanceof RecordVerificationError && err.transient) {
controller.retry();
return;
}
console.error("[labeler] discovery delete failed", {
did: job.did,
collection: job.collection,
rkey: job.rkey,
error: err instanceof Error ? err.message : String(err),
});
controller.retry();
}

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] The delete path retries permanent verification failures, while the create/update path dead-letters them. confirmRecordAbsent can throw non-transient RecordVerificationErrors such as INVALID_URI or DID_RESOLUTION_FAILED; the catch block currently falls through to controller.retry() (line 233), which is the wrong signal. It should classify failures the same way the create branch does: transient errors retry, permanent RecordVerificationErrors write a dead_letters row and ack, and unexpected errors are also dead-lettered/acked rather than retried.

Suggested change
} catch (err) {
if (err instanceof PdsVerificationError && isTransient(err.reason, err.status)) {
controller.retry();
return;
}
if (err instanceof RecordVerificationError && err.transient) {
controller.retry();
return;
}
console.error("[labeler] discovery delete failed", {
did: job.did,
collection: job.collection,
rkey: job.rkey,
error: err instanceof Error ? err.message : String(err),
});
controller.retry();
}
} catch (err) {
if (err instanceof PdsVerificationError && isTransient(err.reason, err.status)) {
controller.retry();
return;
}
if (err instanceof RecordVerificationError && err.transient) {
controller.retry();
return;
}
if (err instanceof RecordVerificationError) {
await writeDeadLetter(deps.db, job, err.reason, err.message, now());
controller.ack();
return;
}
console.error("[labeler] discovery delete failed", {
did: job.did,
collection: job.collection,
rkey: job.rkey,
error: err instanceof Error ? (err.stack ?? err.message) : String(err),
});
await writeDeadLetter(
deps.db,
job,
"UNEXPECTED_ERROR",
err instanceof Error ? err.message : String(err),
now(),
);
controller.ack();
}

@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 12, 2026
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.
@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 12, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Fixed in 5afe847. Extracted a single classifyDiscoveryError helper that both the delete and create paths route their catch through — transient failures retry, permanent verification failures dead-letter and ack, unexpected errors log + dead-letter + ack. The delete path no longer retries a permanent INVALID_URI/DID_RESOLUTION_FAILED, and the two paths can't diverge again since they share the classifier. Added a test asserting a permanent delete-verification failure dead-letters rather than retries.

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 12, 2026
…vation

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.
@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 12, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Both addressed in c68729c:

Issuance-during-rotation classification (needs fixing) — the signing-pause/stale/refresh/state-changed conditions in service.ts now throw a typed LabelIssuanceUnavailableError (identical messages, so existing message-based assertions are unaffected), and the discovery consumer's classifier retries it. So a create or delete event arriving mid key-rotation retries until the rotation completes instead of dead-lettering and permanently losing the release's pending label. (The signer-integrity-mismatch throw stays a plain Error → unexpected → dead-letter, since that's a real integrity failure, not transient.) New test: a verified create with signing paused retries, no dead-letter.

Missing error-preservation test (needs fixing) — added a two-consecutive-error-runs test: the second error run for the same subject leaves assessment-error active at the stream head rather than self-negating. It fails against the pre-fix code (keep-set without assessment-error) and passes now.

129 tests pass; typecheck and lint clean.

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 12, 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.

The approach is sound: an independent Jetstream discovery cursor, PDS exact-record verification before trusting events, idempotent run creation, and a deliberately test-only orchestrator/finalization layer behind an explicit production-boundary test. It matches RFC #694 and the architecture conventions in AGENTS.md.

Both items from the prior review are addressed in this revision:

  • applyDiscoveryDelete now negates the assessment-pending label it issued, so a deleted release stops advertising an in-progress assessment.
  • AssessmentOrchestrator.finalize now keeps assessment-error in the keep set, so back-to-back error runs do not accidentally supersede their own active error label.

I found two remaining gaps:

  1. Production-reachable correctness gap on delete retry. applyDiscoveryDelete tombstones the subject and cancels every non-terminal run before negatePendingForDeletedRuns signs the assessment-pending negations. If the signer throws a transient error after the cancellations have committed, the message retries; on the retry the now-cancelled runs are invisible to listNonTerminalAssessmentsForUri, so no pending negation is produced and the deleted release keeps a positive assessment-pending label.

  2. Test-only orchestrator stale path leaves pending labels active. When AssessmentOrchestrator finds the subject no longer current and finalizes as stale, it discards the entire signed batch (including its own assessment-pending negation). The discovery consumer already issued a positive assessment-pending label, so a deleted/superseded subject would keep advertising an in-progress assessment. The existing tests hide this because pendingRun never issues the discovery label.

Fix #1 and add a test that simulates a transient signer failure during the delete negation and asserts the retry still produces the negative label. For #2, add the pending negation before each running -> stale transition and update the orchestrator tests to seed a real discovery-issued pending label. Once those are addressed this should be good to merge.

Comment on lines +216 to +217
const cancelled = await applyDiscoveryDelete(deps.db, uri, now());
await negatePendingForDeletedRuns(deps, cancelled, now());

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] applyDiscoveryDelete transitions all non-terminal runs to cancelled before negatePendingForDeletedRuns signs the matching assessment-pending negations. If the signer throws a transient error after the cancellations have committed, the queue message retries; on the second attempt the runs are now terminal, listNonTerminalAssessmentsForUri returns an empty array, and the positive assessment-pending label is never retracted. queryLabels then continues to advertise an in-progress assessment for a deleted release.

Negate the pending labels while the runs are still non-terminal, then tombstone/cancel. A regression test that fails the signer on the first delete attempt and asserts the retry still produces neg=1 is needed to close the loop.

Suggested change
const cancelled = await applyDiscoveryDelete(deps.db, uri, now());
await negatePendingForDeletedRuns(deps, cancelled, now());
const nonTerminal = await listNonTerminalAssessmentsForUri(deps.db, uri);
const hadPending = nonTerminal.filter(
(run) => run.state === "pending" || run.state === "running",
);
await negatePendingForDeletedRuns(deps, hadPending, now());
await deleteSubjectsByUri(deps.db, { uri, now });
for (const run of nonTerminal) {
if (
run.state !== "observed" &&
run.state !== "verifying" &&
run.state !== "pending" &&
run.state !== "running"
)
continue;
try {
await transitionAssessmentState(deps.db, {
id: run.id,
from: run.state,
to: "cancelled",
now,
});
} catch (err) {
if (!(err instanceof AssessmentTransitionConflictError)) throw err;
}
}
controller.ack();

Comment on lines +198 to +199
if (!current) {
return transitionAssessmentState(this.db, { id: runId, from: "running", to: "stale", now });

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] When the run is no longer current, both runAssessment (line 198-199) and finalize (line 326-331) transition the run to stale and discard the signed label batch. The discovery consumer already issued a positive assessment-pending label for this run, so a deleted or superseded subject keeps advertising an in-progress assessment. The tests pass because pendingRun never seeds that label.

Import issueAutomatedAssessmentLabel from ./service.js and issue a negative assessment-pending label for the run before each running -> stale transition. Then update pendingRun in test/assessment-orchestrator.test.ts to issue the discovery assessment-pending label so the stale paths are actually exercised.

@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 12, 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.

The approach remains the right one: the labeler gets its own Jetstream cursor and PDS-verified discovery path, while the orchestrator/finalization machinery stays deliberately behind the production-boundary test. The PR builds on an approved Discussion (RFC #694) and fits the existing aggregator-style patterns.

Both findings from the prior review are addressed in this revision:

  • AssessmentOrchestrator.finalize now adds assessment-error to the keep set when toState === "error" (line 311 of assessment-orchestrator.ts), and a regression test exercises a second error run for the same subject to ensure the prior assessment-error is not self-negated.
  • discovery-consumer.ts now catches LabelIssuanceUnavailableError and calls controller.retry() instead of dead-lettering/acking, and the test suite adds a regression test for paused signing on the create path.

AGENTS.md conventions look good for this private Worker app: parameterized D1 statements, .js internal imports, no admin UI strings, and no published-package changeset. I also re-read the production-boundary test — it now catches bare side-effect imports as the author noted.

I have two minor, non-blocking suggestions: one test gap on the delete path and one edge-case note on finalization consistency under a mid-batch signing-state flip.

.first<{ n: number }>();
expect(dl?.n).toBe(0);
});
});

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 create path gained a regression test for paused signing-state retry, but the delete path's negatePendingForDeletedRuns also calls issueAutomatedAssessmentLabel and can throw the same LabelIssuanceUnavailableError. Add a mirror test inside the "processDiscoveryMessage: delete" describe block that pauses signing_state, processes a confirmed delete, and asserts msg.retried === 1 with no dead_letters row.

});
}

await this.db.batch(statements);

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] buildFinalizationStatements commits the assessment state transition before the label-issuance statements. Each buildIssuanceStatements result guards the label inserts on signing state, but the state-update statement itself does not. If signing state flips after the last issue() await but before db.batch, the terminal transition can commit while some label inserts no-op; postCommit then throws and leaves a terminal run with missing labels. Because the orchestrator is still test-only this is narrow and deferred, but consider either guarding the state transition on signing state as well, or documenting this sub-window alongside the existing finalization/deletion-race deferral.

@ascorbic

Copy link
Copy Markdown
Collaborator Author

Both suggestions:

Delete-path paused-issuance test — added in 3ceeeb9, mirroring the create-path one: a confirmed delete with signing paused retries (no ack, no dead-letter), since negatePendingForDeletedRuns throws the same LabelIssuanceUnavailableError.

Mid-batch signing-state flip during finalization — this is the same test-only atomicity family as the finalization/delete-race already documented in finalize()'s comment and the PR's deferral list: the label statements are signing-guarded and no-op on a flip, but the state CAS isn't, so a flip mid-batch could commit the terminal state without labels. Full closure lands with the per-subject workflow lock the spec assigns to production wiring (§14.1, W7/W8), which also closes the delete-race window; the orchestrator is unreachable in production until then (production-boundary test). I've noted the signing-flip case alongside the existing deferral rather than partially guarding it now, so both are closed together with the lock. Shout if you'd rather I guard the state CAS on signing_state here.

130 tests pass; typecheck and lint clean.

@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 12, 2026
@ascorbic ascorbic force-pushed the feat/labeler-discovery branch from 3ceeeb9 to 9550832 Compare July 12, 2026 14:28
@ascorbic ascorbic added bot:review Trigger an emdashbot code review on this PR and removed bot:review Trigger an emdashbot code review on this PR labels Jul 12, 2026
@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 12, 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 is a re-review of the labeler discovery/orchestration PR. The approach still fits EmDash: the discovery consumer gets its own Jetstream cursor and PDS-verified subject creation, while the orchestrator/finalization machinery stays deliberately behind the production-boundary test. The prior review’s two blocking points are resolved here: assessment-error is now in the keep set during error finalization, and LabelIssuanceUnavailableError on the create path triggers a retry rather than a dead-letter.

I checked the new orchestrator, discovery consumer, record verification, Jetstream ingestor, reconciliation, tests, and wrangler/package wiring. Most of it is solid — parameterized D1 statements, no admin UI i18n or Tailwind concerns, no public-package changeset needed. I do see two correctness issues in AssessmentOrchestrator.finalize that are worth fixing before this code is relied on in production, plus a suggestion for the concurrent-delete race:

  1. Stale finalization leaves a dangling assessment-pending label. When isSubjectCurrent() returns false, the method returns after only transitioning the run to stale; the pending-negation statement that was already built is never committed. A CID-superseded (but not deleted) subject will therefore keep advertising an in-progress assessment. This contradicts spec §9.9 point 9, which says the run must always negate its own assessment-pending.
  2. The finalization CAS is not validated after db.batch. The transition out of running uses a WHERE state = ? guard, but D1 batches do not abort on a zero-row update. If a concurrent delete/cancel moved the state, the remaining label INSERTs still commit and the function returns a run that never actually exited running.
  3. Unreified AssessmentTransitionConflictError under concurrent state changes. runAssessment and the stale branch call transitionAssessmentState without catching the conflict exception. Once this is wired to production, a delete racing with the orchestrator can propagate an unhandled exception.

None of these touch the production path today (the production-boundary test still holds), but they are real data-shape/logic bugs in the shipped orchestrator code.

// The same lock also closes a signing-state flip during the batch: the
// label inserts are guarded on active signing state and no-op if it
// flips, but the state CAS below is not, so a flip after this point
// could commit the terminal state without its labels. Deferred to the

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] The !stillCurrent early return finalizes the run as stale but never commits the assessment-pending negation that was built at line 298. For a CID-superseded (but not deleted) subject, the old revision will now carry a dangling, never-negated assessment-pending label, which violates spec §9.9 point 9 ("always negate this run's own assessment-pending").

The stale branch should still clean up its own pending label. The minimal fix is to commit a reduced batch on the stale path that contains the state transition plus the pending negation, while omitting the outcome labels that the spec says a stale run must not issue.

if (!stillCurrent) {
return transitionAssessmentState(this.db, {
id: assessment.id,
from: "running",

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] After await this.db.batch(statements), the batch result array is not inspected. The CAS statement that exits running is guarded only by WHERE id = ? AND state = ?; if a concurrent delete or cancel has already changed the state, that UPDATE returns meta.changes === 0 but D1 continues executing the subsequent label INSERTs. The function can then return a still-non-terminal assessment while outcome labels have already been committed.

Capture the batch result and verify results[finalization.assessmentUpdateIndex].meta.changes === 1 before running postCommit. If the CAS lost, do not run post-commit hooks and surface the race rather than returning success.

this.retryDelayMs = opts.retryDelayMs ?? 0;
}

async runAssessment(runId: string): Promise<Assessment> {

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 pending → running transition and the stale-path running → stale transition both call transitionAssessmentState without catching AssessmentTransitionConflictError. Once the orchestrator is wired to production, a delete that races with these transitions will propagate an unhandled exception. Consider catching the conflict, re-reading the current assessment state, and returning the terminal run instead of throwing.

@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 12, 2026
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.
@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 12, 2026
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Confirmed real, and thank you for the thorough trace — all three are genuine, and they're one family: finalize() isn't serialised against a concurrent state change (stale run's dangling pending, unchecked finalization CAS, uncaught transition conflict).

Decision (maintainer): defer all three to W7/W8, where the orchestrator is wired to production behind the per-subject workflow lock the spec assigns to finalization (§14.1). That lock serialises finalization per subject and closes the whole family — including the currency-window and signing-flip cases already noted. Fixing them piecemeal here (partial CAS checks, in-code negations) would be superseded by the lock and risks a false sense of closure. The production-boundary test proves nothing wires this method live until that work, so none is reachable today. Documented together in finalize()'s comment (70d5cc9) and the PR description so the lock work closes them as a set.

Everything production-reachable in this PR — discovery, PDS verification, delete verification, issuance retry-under-rotation, negation provenance — is fixed and tested. Merging on that basis.

@ascorbic ascorbic merged commit ee3ecef into feat/plugin-registry-labelling-service Jul 12, 2026
5 of 8 checks passed
@ascorbic ascorbic deleted the feat/labeler-discovery branch July 12, 2026 14:39
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/needs-rereview Author pushed changes since the last review size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant