Skip to content

feat(registry): labeler assessment persistence and automated issuance#1976

Merged
ascorbic merged 6 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-assessment-persistence
Jul 12, 2026
Merged

feat(registry): labeler assessment persistence and automated issuance#1976
ascorbic merged 6 commits into
feat/plugin-registry-labelling-servicefrom
feat/labeler-assessment-persistence

Conversation

@ascorbic

@ascorbic ascorbic commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Adds the labeler service's assessment persistence layer and automated label issuance — plan items W2.2–W2.5 plus the issuance-boundary extension the assessment orchestrator (a follow-up PR) needs. This is the storage and state-machine foundation; nothing in production issues an automated label yet (no code path drives a run to completion until discovery/orchestration lands).

  • Storage model (migration 0003): subjects, assessments (state CHECK-constrained to the ten-state lifecycle vocabulary), current_assessments, findings, evidence_objects, plus the dead_letters and ingest_state tables the discovery PR will write to. Timestamp columns that get compared carry epoch-millisecond siblings so ordering never depends on RFC 3339 string comparison. issuance_actions gains a nullable assessment_id (rather than a parallel actions table) linking a label to the assessment that produced it.
  • Lifecycle + repositories: CAS-guarded state transitions (the signing-rotation idiom — a lost race is a typed conflict, never a silent no-op), a deterministic runKey per spec §9.2 so a redelivered trigger converges on one run, and buildFinalizationStatements whose pointer update re-checks the state CAS inside the same batch, letting the orchestrator compose assessment completion and label issuance into one atomic transaction. The pointer update also carries a recency guard so a late-committing older run cannot regress a newer run's current pointer.
  • Automated issuance: a new automated-assessment action whose proposals are validated from the ratified policy fixture (release-only subject, mandatory CID, blocking values only from critical automated-block finding categories, manual-only values unreachable). One shared buildIssuanceStatements backs both manual and automated paths — manual issuance behavior is byte-identical and its existing suite passes unmodified. Automation is additionally forbidden from negating a label whose active stream event was issued manually (§10), enforced at the issuance chokepoint.
  • Evidence boundary: public/private finding types where the public serializer cannot carry private detail (compile-time + runtime tested).

Adversarial review (opus) findings all addressed pre-open: the §10 automated-negation guard (was reachable, now blocked + tested), the pointer-recency regression (guarded + tested with an older-commits-last ordering), unconditional CID on the automated path per §20.2, and a strengthened atomicity test that fails on a runtime constraint violation rather than a missing table.

Deferred by design (noted so reviewers don't flag them): the notifications table is W10 (nothing writes to it yet); assessment_id lives on issuance_actions and is recovered by join rather than duplicated onto issued_labels per the spec DDL (a deliberate normalization).

Targets feat/plugin-registry-labelling-service. 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 and changeset are n/a — apps/labeler is a private, unpublished Worker app (no UI, not on npm).

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: 73 pass (29 pre-existing manual-issuance tests unmodified; 44 new across migration, lifecycle/CAS-race, finalization atomicity, supersession/recency, automated-issuance legality, evidence boundary, policy parse)
  • Typecheck clean; pnpm lint:quick 0 diagnostics; type-aware lint clean on touched files

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

ascorbic added 2 commits July 12, 2026 12:05
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.
…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.
@changeset-bot

changeset-bot Bot commented Jul 12, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 85531f1

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,421 lines across 14 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 12, 2026
@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 85531f1 Jul 12 2026, 12:48 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 85531f1 Jul 12 2026, 12:48 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 85531f1 Jul 12 2026, 12:48 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.

This is a well-scoped storage and state-machine foundation for the labeler assessment lifecycle. The migration is clean, SQL is parameterized throughout, the CAS transition pattern is sound, and the test suite covers the important atomicity, recency, and policy-legality cases. It aligns with the approved RFC and does not touch admin UI or published packages, so Lingui/RTL/changeset checks are not applicable.

I found two correctness issues that should be addressed:

  1. Automated-path release-rule validation is fragile to duplicate subject rules. validateAutomatedProposal uses .find() to pick the first release rule, but the policy parser does not reject duplicate subject entries. A label with two release rules can be mis-evaluated.

  2. The §10 automated-negation guard is racy. It runs as a pre-batch SELECT, so a manual label can be committed between the check and the subsequent db.batch. For a true defense-in-depth guarantee, the guard needs to live inside the same atomic INSERT statement.

Both are fixable in targeted ways. I also noted a small code-health suggestion about the duplicated assessment-id regex.

Comment thread apps/labeler/src/service.ts Outdated
throw new TypeError("automated labels must target a release record");
if (proposal.cid === undefined)
throw new TypeError("automated labels must include a release CID");
const rule = definition.subjectRules.find((candidate) => candidate.subject === "release");

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 automated-path validation uses .find() to pick the first release rule, but parseLabelDefinition does not reject duplicate subject entries. A label definition such as:

"subjectRules": [
  { "subject": "release", "cidRule": "required", "issuanceModes": ["reviewer"] },
  { "subject": "release", "cidRule": "required", "issuanceModes": ["automated"] }
]

would be rejected for automated issuance even though the policy explicitly permits it.

Suggested change
const rule = definition.subjectRules.find((candidate) => candidate.subject === "release");
const canAutomateRelease = definition.subjectRules.some(
(candidate) =>
candidate.subject === "release" && candidate.issuanceModes.includes("automated"),
);
if (!canAutomateRelease)
throw new TypeError(`${proposal.val} cannot be issued through the automated path`);

Even better, also reject duplicate subject entries in policy.ts so the policy document is unambiguous.

Comment on lines 133 to +135

if (action.type === "automated-assessment" && proposal.neg === true) {
await assertAutomatedNegationAllowed(db, signer.issuerDid, proposal);

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 §10 guard is a SELECT that runs before the statements are returned to the caller for db.batch. Because the actual INSERT commits later in a separate batch, a concurrent manual label can be inserted after the guard reads the stream but before the automated negation commits. That would let automation retract a manually-issued label despite the comment claiming this is "defense in depth independent of whatever candidate set an orchestrator computes."

To make the guarantee real, move the guard into the issued_labels INSERT ... SELECT WHERE clause (around line 176) so the check and insert are atomic:

Suggested change
if (action.type === "automated-assessment" && proposal.neg === true) {
await assertAutomatedNegationAllowed(db, signer.issuerDid, proposal);
AND (
? = 0 -- 1 only for automated negations
OR NOT EXISTS (
SELECT 1 FROM issued_labels l2
JOIN issuance_actions a2 ON a2.id = l2.action_id
WHERE l2.src = ? AND l2.uri = ? AND l2.val = ?
AND l2.sequence = (SELECT MAX(sequence) FROM issued_labels WHERE src = ? AND uri = ? AND val = ?)
AND l2.neg = 0
AND a2.type <> 'automated-assessment'
)
)

Bind an isAutomatedNegation flag followed by the matching src, uri, and val parameters. Keeping the existing pre-check is fine for diagnostics, but only an in-batch condition closes the race.

Comment thread apps/labeler/src/assessment-store.ts Outdated
type AssessmentState,
} from "./assessment-lifecycle.js";

const ASSESSMENT_ID = /^asmt_[0-9A-HJKMNP-TV-Z]{26}$/;

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] ASSESSMENT_ID is defined identically in assessment-store.ts (line 10) and service.ts (line 17). Export it from a shared module such as assessment-lifecycle.ts so the two stay in sync.

@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
…le 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.
@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

All three findings addressed in 8b03d37:

Racy §10 guard (needs fixing) — fixed. The negation-provenance check now runs as a condition inside the issuance_actions INSERT's WHERE, gated on an isAutomatedNegation flag, so the check and the write are one atomic statement — no manual label can commit in the window. I placed it on the action insert rather than the label insert: when the guard fires, no action row is written, so the label insert's SELECT FROM issuance_actions finds nothing and there's no orphan action (guarding only the label insert would leave one). The pre-batch SELECT stays as a fast, friendly-error fail. New test builds the negation statements while the stream is empty (pre-check passes), then commits a manual label before executing the batch — the exact TOCTOU window — and asserts the negation didn't land and no orphan action exists.

Fragile release-rule lookup (needs fixing) — fixed. Switched from .find() to .some() over all release rules, and the policy parser now rejects duplicate subject entries outright (test added) so the document is unambiguous at parse time too.

Duplicated ASSESSMENT_ID (suggestion) — taken. Exported from assessment-lifecycle.ts; both service.ts and assessment-store.ts import it.

75 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 labeler assessment persistence PR. Both prior findings are addressed: duplicate subject entries within a label are now rejected at policy parse time (so the automated-path .some() check is unambiguous), and the §10 negation guard has been moved into the same atomic INSERT statement in the batch.

The migration is clean, the CAS lifecycle transitions are sound, the current_assessments recency guard correctly prevents older runs from regressing newer pointers, and the automated proposal validation stays derived from the ratified policy fixture. Admin/Lingui/RTL/changeset items are not applicable for this private Worker app.

I found one remaining correctness/diagnostics issue in the post-batch error path: when the in-batch §10 guard suppresses an automated negation, issueLabel misdiagnoses the missing row as a signing-state problem. The guard itself is solid, but the error surfaced to callers is wrong and will fire misleading signing alerts. The fix is a targeted re-check in postCommit.

Comment on lines +263 to +287
if (!issued) {
const status = await getSigningStatusIfInitialized(db);
if (!status) throw new Error("label issuance did not persist");
if (!signingStatus) {
await recordSigningAlert(db, "SIGNING_STATE_CHANGED", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("signing state changed; retry label issuance");
}
if (status.phase === "paused") {
await recordSigningAlert(db, "ISSUANCE_PAUSED", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("label issuance is paused");
}
await recordSigningAlert(db, "STALE_SIGNING_KEY", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("label signing key version is stale");

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 postCommit hook treats every missing issued_labels row as a signing-state failure. If the in-batch §10 automated-negation guard suppresses the issuance_actions INSERT because a manual label was committed between the pre-check and the batch, getIssuedLabel returns null and this codepath records STALE_SIGNING_KEY / SIGNING_STATE_CHANGED alerts and throws signing-related errors instead of the policy violation.

Re-check the §10 guard in the no-row path before diagnosing signing state. The existing race test calls db.batch() directly, so it does not exercise this misdiagnosis; adding an issueAutomatedAssessmentLabel-level test for the same interleaving would catch it.

Suggested change
if (!issued) {
const status = await getSigningStatusIfInitialized(db);
if (!status) throw new Error("label issuance did not persist");
if (!signingStatus) {
await recordSigningAlert(db, "SIGNING_STATE_CHANGED", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("signing state changed; retry label issuance");
}
if (status.phase === "paused") {
await recordSigningAlert(db, "ISSUANCE_PAUSED", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("label issuance is paused");
}
await recordSigningAlert(db, "STALE_SIGNING_KEY", {
activeKeyVersion: status.activeKeyVersion,
targetKeyVersion: config.signingKeyVersion,
rotationId: status.rotationId,
});
throw new Error("label signing key version is stale");
if (!issued) {
if (
action.type === "automated-assessment" &&
proposal.neg === true
) {
await assertAutomatedNegationAllowed(
db,
signer.issuerDid,
proposal as AutomatedLabelProposal,
);
}
const status = await getSigningStatusIfInitialized(db);

@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
…lert

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.
@ascorbic

Copy link
Copy Markdown
Collaborator Author

Fixed in 704b733. postCommit's no-row branch now re-runs the §10 guard first when the label was an automated negation, so a guard-suppressed insert surfaces the policy violation (cannot negate the manually-issued label) instead of misdiagnosing it as a signing-state failure and firing spurious alerts. If no manual label is present it's a no-op and falls through to the genuine signing diagnosis. The race test now also asserts postCommit() rejects with the policy error.

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

Copy link
Copy Markdown
Contributor

Overlapping PRs

This PR modifies files that are also changed by other open PRs:

This may cause merge conflicts or duplicated work. A maintainer will coordinate.

@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 assessment persistence PR. The one prior emdashbot[bot] finding is addressed: when the in-batch §10 guard suppresses an automated negation, postCommit now re-runs assertAutomatedNegationAllowed before falling through to signing-state diagnosis, so callers see the policy violation rather than a misleading signing alert (verified by the new in-batch race test).

Overall the approach is sound and the implementation matches the described architecture:

  • CAS-guarded state transitions return typed AssessmentTransitionConflictErrors instead of silently no-op'ing.
  • buildFinalizationStatements re-checks the assessment state inside the same batch, and the current_assessments upsert carries a recency guard that prevents older runs from regressing newer pointers.
  • The §10 automated-negation guard is placed on the action insert, so a lost race leaves no orphan action.
  • Automated issuance validation is derived from the ratified policy fixture, with unconditional release-record + CID requirements and critical-only blocking findings.
  • The public/private evidence boundary is enforced both at compile time (never-typed fields) and at runtime.

Admin i18n/RTL/changesets remain n/a for this private Worker app.

I found one non-blocking suggestion: the §10 guard performs a MAX(sequence) lookup over (src, uri, val) on issued_labels without a covering index. On a live labeler the stream per subject/value can grow, so adding an index would keep the guard cheap. Otherwise the diff looks clean.

WHERE l.src = ? AND l.uri = ? AND l.val = ?
ORDER BY l.sequence DESC
LIMIT 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.

[suggestion] The §10 guard looks up the latest label for a (src, uri, val) stream with ORDER BY l.sequence DESC LIMIT 1 and a subquery SELECT MAX(sequence) FROM issued_labels WHERE src = ? AND uri = ? AND val = ?. The existing indexes on issued_labels are (sequence), (uri, sequence), and (src, sequence); none cover (src, uri, val, sequence). As the label stream grows, these lookups will scan or filter many rows per automated negation.

Add a covering index, either in 0003_assessment_lifecycle.sql (migrations can create indexes on pre-existing tables) or by updating the prior migration if this branch is still pre-release. For example:

CREATE INDEX idx_issued_labels_stream_max
ON issued_labels(src, uri, val, sequence);

This single index serves both the outer guard filter and the MAX(sequence) subquery used in assertAutomatedNegationAllowed and the in-batch condition.

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

Added the covering index in 9684dafidx_issued_labels_stream ON issued_labels(src, uri, val, sequence) in the 0003 migration (unreleased, so it lands with the table's first deploy). Keeps the §10 guard's per-stream head lookup cheap as label streams grow.

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

This is a re-review of the assessment persistence PR. The prior emdashbot[bot] finding is addressed: postCommit now re-runs assertAutomatedNegationAllowed when the in-batch §10 guard suppresses an automated negation, and a test covers the lost-race case. The performance suggestion from the prior review is also implemented (idx_issued_labels_stream).

The overall approach remains sound: the migration adds the lifecycle tables with epoch-ms siblings, the CAS-guarded transitions return typed conflicts, buildFinalizationStatements keeps the state CAS and pointer update in one batch with a recency guard, and automated issuance validation is derived from the ratified policy fixture.

I did not run the test suite, linter, or builds (read-only static review). I found one remaining correctness issue: validateAutomatedProposal returns early for negations, bypassing the release-record and mandatory-CID checks that the PR description says are unconditional on the automated path. The fix is to move the if (proposal.neg === true) return; after those checks while still skipping the finding-category/severity requirement for negations. A test for this case should be added.

No AGENTS.md conventions are violated for this private Worker app: there is no admin UI, no published-package changeset is needed, and all SQL uses parameterised D1 statements.

Comment on lines +568 to +591
function validateAutomatedProposal(proposal: AutomatedLabelProposal): void {
const definition = getLabelDefinition(proposal.val);
if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`);
const record = REGISTRY_RECORD.exec(proposal.uri);
if (!record || !record[2]!.endsWith(".release"))
throw new TypeError("automated labels must target a release record");
if (proposal.cid === undefined)
throw new TypeError("automated labels must include a release CID");
const canAutomateRelease = definition.subjectRules.some(
(candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"),
);
if (!canAutomateRelease)
throw new TypeError(`${proposal.val} cannot be issued through the automated path`);
if (proposal.neg === true) return;
if (definition.category === "automated-block") {
if (proposal.findingCategory === undefined)
throw new TypeError(`${proposal.val} requires a finding category`);
const findingDefinition = getLabelDefinition(proposal.findingCategory);
if (!findingDefinition || findingDefinition.category !== "automated-block")
throw new TypeError("finding category must be an allowed security/impersonation category");
if (proposal.severity !== "critical")
throw new TypeError(`${proposal.val} requires a critical finding severity`);
}
}

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] validateAutomatedProposal exits early on proposal.neg === true before checking that the target is a release record and that a CID is present. Because every automated label in the ratified fixture has subjectRules for a release with cidRule: "required", and the PR description states that CID is "unconditional on the automated path per §20.2", an automated negation can currently omit the CID or target a non-release subject.

Move the negation early-return after the release-record and CID gates; keep the finding-category/severity block skipped for negations. Add a test that an automated negation without a CID is rejected.

Suggested change
function validateAutomatedProposal(proposal: AutomatedLabelProposal): void {
const definition = getLabelDefinition(proposal.val);
if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`);
const record = REGISTRY_RECORD.exec(proposal.uri);
if (!record || !record[2]!.endsWith(".release"))
throw new TypeError("automated labels must target a release record");
if (proposal.cid === undefined)
throw new TypeError("automated labels must include a release CID");
const canAutomateRelease = definition.subjectRules.some(
(candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"),
);
if (!canAutomateRelease)
throw new TypeError(`${proposal.val} cannot be issued through the automated path`);
if (proposal.neg === true) return;
if (definition.category === "automated-block") {
if (proposal.findingCategory === undefined)
throw new TypeError(`${proposal.val} requires a finding category`);
const findingDefinition = getLabelDefinition(proposal.findingCategory);
if (!findingDefinition || findingDefinition.category !== "automated-block")
throw new TypeError("finding category must be an allowed security/impersonation category");
if (proposal.severity !== "critical")
throw new TypeError(`${proposal.val} requires a critical finding severity`);
}
}
function validateAutomatedProposal(proposal: AutomatedLabelProposal): void {
const definition = getLabelDefinition(proposal.val);
if (!definition) throw new TypeError(`unknown label value: ${proposal.val}`);
const record = REGISTRY_RECORD.exec(proposal.uri);
if (!record || !record[2]!.endsWith(".release"))
throw new TypeError("automated labels must target a release record");
if (proposal.cid === undefined)
throw new TypeError("automated labels must include a release CID");
const canAutomateRelease = definition.subjectRules.some(
(candidate) => candidate.subject === "release" && candidate.issuanceModes.includes("automated"),
);
if (!canAutomateRelease)
throw new TypeError(`${proposal.val} cannot be issued through the automated path`);
if (proposal.neg === true) return;
if (definition.category === "automated-block") {
if (proposal.findingCategory === undefined)
throw new TypeError(`${proposal.val} requires a finding category`);
const findingDefinition = getLabelDefinition(proposal.findingCategory);
if (!findingDefinition || findingDefinition.category !== "automated-block")
throw new TypeError("finding category must be an allowed security/impersonation category");
if (proposal.severity !== "critical")
throw new TypeError(`${proposal.val} requires a critical finding severity`);
}
}

@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 release-record and mandatory-CID checks already precede the negation
early-return; only finding-category/severity is skipped. Lock that in.
@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

I don't think this one holds — the release-record and CID checks already run before the negation early-return. Current validateAutomatedProposal order (service.ts:571-590):

  1. line 572-573: reject non-release URIs
  2. line 574-575: reject missing CID (unconditional)
  3. line 576-580: reject vals whose release rule lacks automated mode
  4. line 581: if (proposal.neg === true) return; — skips only the finding-category/severity checks below it

So a negation already gets the release-record and mandatory-CID checks; only the finding requirements (which don't apply to negations) are skipped — which is the intended behavior your suggested fix describes. Added a test in 85531f1 that proves it: a negation with a publisher-DID URI rejects with "must target a release record", and a negation missing a CID rejects with "must include a release CID". If you were reading an earlier revision where the CID check sat after the early-return, this is already resolved.

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

Re-review of the assessment persistence and automated-issuance PR.

The previous review’s findings are both addressed in the current revision:

  1. postCommit §10 guard: when the in-batch automated-negation condition suppresses the action insert, postCommit now re-runs assertAutomatedNegationAllowed so the caller gets the policy violation instead of a misleading signing-state error. A dedicated race test verifies both the insert suppression and the error surfaced by postCommit.

  2. validateAutomatedProposal negation ordering: the if (proposal.neg === true) return; guard is now positioned after the release-record, mandatory-CID, and release-automated-mode checks, while still skipping the finding-category/severity requirement for negations. A test explicitly confirms that negations are still rejected for non-release subjects and missing CIDs.

The overall approach remains sound for this private Worker app: the new migration adds subjects, assessments, current_assessments, findings, evidence_objects, and the ahead-of-time dead_letters/ingest_state tables; compared timestamps carry epoch-ms siblings; CAS-guarded transitions return typed AssessmentTransitionConflictErrors; buildFinalizationStatements keeps the state CAS and recency-guarded pointer update in one batch; shared buildIssuanceStatements backs both manual and automated paths; and the §10 automated-negation guard is enforced both pre-check and in-batch.

I did not run the test suite, linter, or builds (read-only static review). No AGENTS.md conventions are violated for apps/labeler: there is no admin UI, no published-package changeset is required, and all SQL uses parameterised D1 statements.

No remaining blocking or suggestion-level findings.

@github-actions github-actions Bot added review/approved Approved; no new commits since and removed review/needs-rereview Author pushed changes since the last review labels Jul 12, 2026
@ascorbic ascorbic merged commit 81acff9 into feat/plugin-registry-labelling-service Jul 12, 2026
10 checks passed
@ascorbic ascorbic deleted the feat/labeler-assessment-persistence branch July 12, 2026 12:50
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 overlap review/approved Approved; no new commits since size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant