Skip to content

Commit abe4d72

Browse files
authored
fix(labeler): keep assessment-pending live until the last concurrent run resolves (#2168)
Concurrent assessment runs for the same (uri,cid) all target the single assessment-pending label, so one run's finalization negation cleared the shared gate while a sibling run was still active — briefly exposing a stale pass. Gate the pending negation, in-batch, on no other non-terminal run for the subject (requireNoOtherActiveRun), so only the last run to resolve clears it. Make the delete cleanup's pending scan label-driven (any run holding a committed un-negated positive, at any state) so a decision run that now finalizes while still holding a positive is still negated on delete.
1 parent fa4d25b commit abe4d72

6 files changed

Lines changed: 238 additions & 43 deletions

File tree

apps/labeler/src/assessment-orchestrator.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { ModerationPolicy } from "./policy.js";
3232
import {
3333
buildIssuanceStatements,
3434
markPublicationAccepted,
35+
readIssuedLabelByActionKey,
3536
type AutomatedIssuanceAction,
3637
type AutomatedLabelProposal,
3738
type IssuedLabel,
@@ -351,8 +352,40 @@ export class AssessmentOrchestrator {
351352
if (toState === "error") {
352353
await issue("assessment-error", false);
353354
}
354-
// Spec §9.9 point 9: always negate this run's own assessment-pending.
355-
await issue("assessment-pending", true);
355+
// Spec §9.9 point 9: negate this run's own assessment-pending — but only when
356+
// this is the last run in flight for the subject. `requireNoOtherActiveRun`
357+
// no-ops the negation while a sibling run for the same (uri, cid) is still
358+
// non-terminal, so the positive assessment-pending stays the stream head and
359+
// the release stays gated; the last run to finalize clears it.
360+
const pendingNegationKey = automatedIdempotencyKey(
361+
assessment.runKey,
362+
"assessment-pending",
363+
true,
364+
);
365+
const pendingNegation = await buildIssuanceStatements(
366+
this.db,
367+
this.config,
368+
this.signer,
369+
{
370+
actor: this.config.labelerDid,
371+
type: "automated-assessment",
372+
assessmentId: assessment.id,
373+
reason: `assessment ${toState}`,
374+
idempotencyKey: pendingNegationKey,
375+
},
376+
{ uri: assessment.uri, cid: assessment.cid, val: "assessment-pending", neg: true },
377+
now,
378+
this.publisher !== undefined,
379+
{
380+
requireAssessmentState: toState,
381+
requireNoOtherActiveRun: {
382+
uri: assessment.uri,
383+
cid: assessment.cid,
384+
assessmentId: assessment.id,
385+
},
386+
},
387+
);
388+
statements.push(...pendingNegation.statements);
356389

357390
// Spec §9.9 point 8 / §10: negate prior active automated labels this
358391
// outcome no longer supports.
@@ -428,6 +461,12 @@ export class AssessmentOrchestrator {
428461
}
429462
const issued: IssuedLabel[] = [];
430463
for (const postCommit of postCommits) issued.push(await postCommit());
464+
// The pending negation is suppressed (writes no row) while a sibling run is
465+
// still in flight; broadcast it only when it committed. It is the only label
466+
// here that can legitimately be absent after the CAS succeeded — the CAS shares
467+
// its signing guard — so its absence needs no signing diagnosis.
468+
const pendingNegated = await readIssuedLabelByActionKey(this.db, pendingNegationKey);
469+
if (pendingNegated) issued.push(pendingNegated);
431470
await this.publishLabels(issued);
432471

433472
const finalised = await getAssessment(this.db, assessment.id);

apps/labeler/src/assessment-store.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -387,25 +387,14 @@ export async function listNonTerminalAssessmentsForUri(
387387
return (rows.results ?? []).map(rowToAssessment);
388388
}
389389

390-
/** States a run can be in and still carry a live, un-negated positive
391-
* `assessment-pending`: every non-terminal state, plus terminal `stale` (which,
392-
* unlike the other terminals, does not negate its own pending on transition).
393-
* `cancelled` is excluded — the delete negates before cancelling; the decision
394-
* outcomes negate their own pending at finalization. */
395-
const PENDING_BEARING_STATES: readonly AssessmentState[] = [
396-
"observed",
397-
"verifying",
398-
"pending",
399-
"running",
400-
"stale",
401-
];
402-
403390
/**
404-
* Runs for a URI that could still carry a live positive `assessment-pending`
405-
* label — the delete cleanup's scan set (spec §9.1). Widens
406-
* `listNonTerminalAssessmentsForUri` to include terminal `stale` runs: a run that
407-
* self-transitioned to `stale` on detecting a deleted/superseded subject keeps
408-
* its committed positive, so the delete must still reach it to negate.
391+
* Runs for a URI the delete cleanup must reach (spec §9.1): every non-terminal
392+
* run (to cancel it and negate any pending it issued) plus any run — of ANY state,
393+
* terminal included — still holding a committed, un-negated positive
394+
* `assessment-pending`. A run that self-stales on a deleted/superseded subject, or
395+
* a decision run that suppressed its own pending-negation while a sibling was still
396+
* in flight (`requireNoOtherActiveRun`), stays terminal yet keeps its positive, so
397+
* its state alone does not identify it — the label stream does.
409398
*/
410399
export async function listPendingBearingAssessmentsForUri(
411400
db: D1Database,
@@ -416,10 +405,19 @@ export async function listPendingBearingAssessmentsForUri(
416405
`SELECT id, run_key, uri, cid, artifact_id, artifact_checksum, state, trigger, trigger_id,
417406
policy_version, model_id, prompt_hash, public_summary, coverage_json,
418407
supersedes_assessment_id, started_at, completed_at, created_at
419-
FROM assessments
420-
WHERE uri = ? AND state IN (${PENDING_BEARING_STATES.map(() => "?").join(", ")})`,
408+
FROM assessments a
409+
WHERE a.uri = ?
410+
AND (
411+
a.state NOT IN (${Array.from(TERMINAL_STATES, () => "?").join(", ")})
412+
OR (
413+
EXISTS (SELECT 1 FROM issued_labels l JOIN issuance_actions act ON act.id = l.action_id
414+
WHERE act.assessment_id = a.id AND l.val = 'assessment-pending' AND l.neg = 0)
415+
AND NOT EXISTS (SELECT 1 FROM issued_labels l JOIN issuance_actions act ON act.id = l.action_id
416+
WHERE act.assessment_id = a.id AND l.val = 'assessment-pending' AND l.neg = 1)
417+
)
418+
)`,
421419
)
422-
.bind(uri, ...PENDING_BEARING_STATES)
420+
.bind(uri, ...TERMINAL_STATES)
423421
.all<AssessmentRow>();
424422
return (rows.results ?? []).map(rowToAssessment);
425423
}

apps/labeler/src/discovery-consumer.ts

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
automatedIdempotencyKey,
4646
computeRunKey,
4747
initialTriggerId,
48+
TERMINAL_STATES,
4849
} from "./assessment-lifecycle.js";
4950
import {
5051
buildAssessmentRunStatement,
@@ -596,22 +597,23 @@ async function transitionOrObserve(
596597

597598
/**
598599
* Tombstones the subject (advancing its `delete_generation`) and retires every
599-
* run that could still carry a live positive `assessment-pending` — including
600-
* terminal `stale` runs, which self-transition on detecting a deleted subject and
601-
* do NOT negate their own pending. For each such run the negation is issued
602-
* BEFORE any cancellation, so a failed or paused negation (signing mid-rotation)
603-
* leaves a non-terminal run non-terminal and re-discoverable on redelivery;
604-
* cancelling first would drop it from the scan set, stranding the pending live
605-
* once the message acks.
600+
* run that could still carry a live positive `assessment-pending` — including any
601+
* terminal run still holding a committed, un-negated positive (a `stale` run that
602+
* self-transitioned on a deleted/superseded subject, or a decision run that
603+
* suppressed its own pending-negation while a sibling was in flight). For each such
604+
* run the negation is issued BEFORE any cancellation, so a failed or paused
605+
* negation (signing mid-rotation) leaves a non-terminal run non-terminal and
606+
* re-discoverable on redelivery; cancelling first would drop it from the scan set,
607+
* stranding the pending live once the message acks.
606608
*
607609
* The negation is keyed on the run having committed a live positive — NOT on its
608610
* lifecycle state — because an operator rerun issues its positive while the run is
609-
* still `observed`, and a stale run keeps its positive after going terminal.
610-
* Cancellation applies only to non-terminal runs (a stale run is already
611-
* terminal). The invariant: a run is cancelled only after any positive it
612-
* committed has been negated, and the message cannot ack while a negation is still
613-
* owed (a throw propagates to the delete handler's mutation-phase catch, which
614-
* always retries), so no active `assessment-pending` survives an acked delete.
611+
* still `observed`, and a terminal run keeps its positive after finalizing.
612+
* Cancellation applies only to non-terminal runs (a terminal run needs none). The
613+
* invariant: a run is cancelled only after any positive it committed has been
614+
* negated, and the message cannot ack while a negation is still owed (a throw
615+
* propagates to the delete handler's mutation-phase catch, which always retries),
616+
* so no active `assessment-pending` survives an acked delete.
615617
*/
616618
async function applyDiscoveryDelete(
617619
deps: DiscoveryConsumerDeps,
@@ -621,8 +623,8 @@ async function applyDiscoveryDelete(
621623
await deleteSubjectsByUri(deps.db, { uri, now });
622624
const runs = await listPendingBearingAssessmentsForUri(deps.db, uri);
623625
for (const run of runs) {
624-
// Negate (before any cancel) any run — non-terminal OR terminal `stale` —
625-
// that committed a positive pending and has not already been negated.
626+
// Negate (before any cancel) any run — non-terminal or terminal — that
627+
// committed a positive pending and has not already been negated.
626628
const positive = await readIssuedLabelByActionKey(
627629
deps.db,
628630
automatedIdempotencyKey(run.runKey, "assessment-pending", false),
@@ -634,7 +636,7 @@ async function applyDiscoveryDelete(
634636
);
635637
if (!negated) await negateRunPendingLabel(deps, run, now);
636638
}
637-
if (run.state === "stale") continue; // already terminal — negated, nothing to cancel
639+
if (TERMINAL_STATES.has(run.state)) continue; // already terminal — negated, nothing to cancel
638640
try {
639641
await transitionAssessmentState(deps.db, {
640642
id: run.id,

apps/labeler/src/service.ts

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { LabelSigner, SignedLabel } from "@emdash-cms/registry-moderation";
22

3-
import { ASSESSMENT_ID, type AssessmentState } from "./assessment-lifecycle.js";
3+
import { ASSESSMENT_ID, TERMINAL_STATES, type AssessmentState } from "./assessment-lifecycle.js";
44
import { getNegatableAutomatedLabels } from "./assessment-store.js";
55
import type { LabelerConfig } from "./config.js";
66
import type { FindingSeverity } from "./evidence.js";
@@ -132,6 +132,18 @@ export interface BuildIssuanceOptions {
132132
* action (not the label) leaves no orphan label, same as the state guard.
133133
*/
134134
requireSubjectNotDeleted?: { uri: string; cid: string; generation: number };
135+
/**
136+
* Gate the action insert (and thus its label) on NO OTHER assessment run for the
137+
* same subject `(uri, cid)` being non-terminal at commit time. Finalization pairs
138+
* this with `requireAssessmentState` on the `assessment-pending` negation so the
139+
* shared pending gate clears only when the LAST run resolves: while any sibling
140+
* run is still in flight, this run's negation no-ops and the positive
141+
* `assessment-pending` stays the stream head, holding the release gated.
142+
* `assessmentId` excludes this run itself from the sibling scan (by id), so
143+
* self-exclusion holds regardless of batch ordering; siblings are read as their
144+
* committed terminal state. Requires an automated-assessment action.
145+
*/
146+
requireNoOtherActiveRun?: { uri: string; cid: string; assessmentId: string };
135147
}
136148

137149
/**
@@ -232,6 +244,15 @@ export async function buildIssuanceStatements(
232244
? ""
233245
: `\n\t\t\t\t AND EXISTS (SELECT 1 FROM subjects
234246
WHERE uri = ? AND cid = ? AND deleted_at IS NULL AND delete_generation = ?)`;
247+
const requireNoOtherActiveRun = options.requireNoOtherActiveRun;
248+
if (requireNoOtherActiveRun !== undefined && action.type !== "automated-assessment")
249+
throw new TypeError("requireNoOtherActiveRun requires an automated-assessment action");
250+
const activeRunGuardSql =
251+
requireNoOtherActiveRun === undefined
252+
? ""
253+
: `\n\t\t\t\t AND NOT EXISTS (SELECT 1 FROM assessments
254+
WHERE uri = ? AND cid = ? AND id != ?
255+
AND state NOT IN (${Array.from(TERMINAL_STATES, () => "?").join(", ")}))`;
235256
const actionBinds: unknown[] = [
236257
action.actor,
237258
action.type,
@@ -253,6 +274,13 @@ export async function buildIssuanceStatements(
253274
if (requireState !== undefined) actionBinds.push(assessmentId, requireState);
254275
if (requireSubject !== undefined)
255276
actionBinds.push(requireSubject.uri, requireSubject.cid, requireSubject.generation);
277+
if (requireNoOtherActiveRun !== undefined)
278+
actionBinds.push(
279+
requireNoOtherActiveRun.uri,
280+
requireNoOtherActiveRun.cid,
281+
requireNoOtherActiveRun.assessmentId,
282+
...TERMINAL_STATES,
283+
);
256284

257285
const statements: D1PreparedStatement[] = [
258286
db
@@ -277,7 +305,7 @@ export async function buildIssuanceStatements(
277305
)
278306
AND l2.neg = 0 AND a2.type <> 'automated-assessment'
279307
)
280-
)${stateGuardSql}${subjectGuardSql}
308+
)${stateGuardSql}${subjectGuardSql}${activeRunGuardSql}
281309
ON CONFLICT(idempotency_key) DO NOTHING`,
282310
)
283311
.bind(...actionBinds),

apps/labeler/test/assessment-orchestrator.test.ts

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@ import {
1212
type AcquisitionHolder,
1313
type AcquisitionTarget,
1414
} from "../src/artifact-acquisition.js";
15-
import { computeRunKey, initialTriggerId, operatorTriggerId } from "../src/assessment-lifecycle.js";
15+
import {
16+
automatedIdempotencyKey,
17+
computeRunKey,
18+
initialTriggerId,
19+
operatorTriggerId,
20+
} from "../src/assessment-lifecycle.js";
1621
import {
1722
AssessmentFinalizationConflictError,
1823
AssessmentOrchestrator,
@@ -26,14 +31,20 @@ import {
2631
createAssessmentRun,
2732
createSubject,
2833
deleteSubject,
34+
getActiveLabelState,
2935
getAssessment,
3036
getCurrentAssessment,
3137
transitionAssessmentState,
3238
} from "../src/assessment-store.js";
3339
import { FindingValidationError, HISTORY_FINDING_CATEGORIES } from "../src/findings.js";
3440
import { analyzeHistory } from "../src/history-context.js";
3541
import { MODERATION_POLICY } from "../src/policy.js";
36-
import { issueManualLabel, type IssuedLabel } from "../src/service.js";
42+
import {
43+
issueAutomatedAssessmentLabel,
44+
issueManualLabel,
45+
readIssuedLabelByActionKey,
46+
type IssuedLabel,
47+
} from "../src/service.js";
3748
import {
3849
abortRoutineKeyRotation,
3950
beginRoutineKeyRotation,
@@ -1388,6 +1399,84 @@ describe("AssessmentOrchestrator: delete racing finalization (Blocker 1)", () =>
13881399
});
13891400
});
13901401

1402+
/** Issues a run's initial positive `assessment-pending`, the way the discovery
1403+
* consumer does, so the subject carries a live pending gate before a run finalizes. */
1404+
async function issuePendingPositive(run: {
1405+
id: string;
1406+
uri: string;
1407+
cid: string;
1408+
runKey: string;
1409+
}): Promise<void> {
1410+
await issueAutomatedAssessmentLabel(
1411+
testEnv.DB,
1412+
config,
1413+
await signer(),
1414+
{
1415+
actor: LABELER_DID,
1416+
type: "automated-assessment",
1417+
assessmentId: run.id,
1418+
reason: "initial discovery",
1419+
idempotencyKey: automatedIdempotencyKey(run.runKey, "assessment-pending", false),
1420+
},
1421+
{ uri: run.uri, cid: run.cid, val: "assessment-pending" },
1422+
);
1423+
}
1424+
1425+
describe("AssessmentOrchestrator: concurrent runs share one pending gate", () => {
1426+
it("keeps assessment-pending live while a sibling run for the same subject is in flight, clearing it only when the last run finalizes", async () => {
1427+
const cidValue = await cid("shared-pending");
1428+
const runA = await pendingRun({
1429+
name: "shared-pending",
1430+
cidValue,
1431+
triggerId: initialTriggerId(cidValue),
1432+
});
1433+
const runB = await pendingRun({
1434+
name: "shared-pending",
1435+
cidValue,
1436+
triggerId: operatorTriggerId("op-shared-pending"),
1437+
});
1438+
await issuePendingPositive(runA);
1439+
await issuePendingPositive(runB);
1440+
1441+
const orchestrator = await buildOrchestrator();
1442+
1443+
// A finalizes while B is still `pending`. Its own pending-negation is
1444+
// suppressed, so the gate B also depends on stays live.
1445+
const a = await orchestrator.runAssessment(runA.id);
1446+
expect(a.state).toBe("passed");
1447+
1448+
const aNegation = await readIssuedLabelByActionKey(
1449+
testEnv.DB,
1450+
automatedIdempotencyKey(runA.runKey, "assessment-pending", true),
1451+
);
1452+
expect(aNegation).toBeNull();
1453+
1454+
const gateWhileBActive = await getActiveLabelState(testEnv.DB, {
1455+
src: LABELER_DID,
1456+
uri: runA.uri,
1457+
cid: cidValue,
1458+
});
1459+
expect(gateWhileBActive.get("assessment-pending")?.active).toBe(true);
1460+
1461+
// B is the last run in flight; finalizing it clears the shared gate.
1462+
const b = await orchestrator.runAssessment(runB.id);
1463+
expect(b.state).toBe("passed");
1464+
1465+
const bNegation = await readIssuedLabelByActionKey(
1466+
testEnv.DB,
1467+
automatedIdempotencyKey(runB.runKey, "assessment-pending", true),
1468+
);
1469+
expect(bNegation).not.toBeNull();
1470+
1471+
const gateAfterLast = await getActiveLabelState(testEnv.DB, {
1472+
src: LABELER_DID,
1473+
uri: runB.uri,
1474+
cid: cidValue,
1475+
});
1476+
expect(gateAfterLast.get("assessment-pending")?.active).toBe(false);
1477+
});
1478+
});
1479+
13911480
describe("AssessmentOrchestrator: automation pause between prep and commit", () => {
13921481
it("leaves the run running and issues no labels when automation pauses before the batch commits", async () => {
13931482
const run = await pendingRun({

0 commit comments

Comments
 (0)