From 592b059cd60e46d2166f7a9f843701abd2799674 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 16 Jul 2026 20:51:26 +0100 Subject: [PATCH 1/3] feat(labeler): reconsideration case management + outcome notice (W10.6 server) Operator-side manual reconsideration workflow. Publishers quote the opaque public assessment id (already served) to the monitored inbox; operators open a case, attach private notes, and resolve it with an outcome, which fires a publisher outcome notice through the W10.5 double-opt-in pipeline. - migration 0009: `reconsiderations` (mutable case keyed on subject uri+cid, stable across reruns; state open->resolved; partial unique index bounds one open case per subject; CHECK binds state<->outcome<->resolved provenance) and `reconsideration_notes` (append-only, immutable) for operator-only notes kept off the semi-public operator_actions.reason. - three reviewer console mutations (open/note/resolve) via guardMutation/ commitMutation: audit row + effects commit atomically; duplicate-open and double-resolve are 409s backstopped by the unique index and a state-guarded UPDATE. - reconsideration-outcome notice (granted/denied; withdrawn is silent) keyed on the resolve operator_action so the retry sweep re-renders identical public copy; private note text never enters notice copy. The notice is gated on the resolve winning its guarded UPDATE, on both the fresh and replay paths, so it fires at most once per case even under a concurrent-resolve + key-replay race. - read API: paginated case list + case-with-notes detail (reviewer-gated). Co-Authored-By: Claude Opus 4.8 --- .../migrations/0009_reconsiderations.sql | 79 ++ apps/labeler/src/console-api.ts | 53 ++ apps/labeler/src/console-mutation-api.ts | 421 +++++++++- apps/labeler/src/console-serialize.ts | 78 ++ apps/labeler/src/notification-triggers.ts | 66 ++ apps/labeler/src/operational-events.ts | 4 +- apps/labeler/src/operator-actions.ts | 5 +- apps/labeler/src/reconsiderations.ts | 343 ++++++++ .../test/console-reconsiderations.test.ts | 787 ++++++++++++++++++ 9 files changed, 1832 insertions(+), 4 deletions(-) create mode 100644 apps/labeler/migrations/0009_reconsiderations.sql create mode 100644 apps/labeler/src/reconsiderations.ts create mode 100644 apps/labeler/test/console-reconsiderations.test.ts diff --git a/apps/labeler/migrations/0009_reconsiderations.sql b/apps/labeler/migrations/0009_reconsiderations.sql new file mode 100644 index 000000000..86e455b66 --- /dev/null +++ b/apps/labeler/migrations/0009_reconsiderations.sql @@ -0,0 +1,79 @@ +-- Publisher reconsideration case management (spec §18/§19, plan W10.6). Two +-- tables mirroring the operator_actions(immutable)/notifications(mutable) split: +-- +-- * `reconsiderations` — MUTABLE case, keyed on the subject release +-- (uri + cid) which is STABLE across reruns (a reconsideration is about +-- "this release"; a rerun mints a fresh assessment id). State open → +-- resolved; at most one open case per subject via a partial unique index. +-- * `reconsideration_notes` — APPEND-ONLY private operator notes, immutable +-- like the other audit logs. Kept OFF `operator_actions`, whose `reason` is +-- semi-public (folded into notices/events): a private note must never reach +-- notice copy. +-- +-- `triggering_assessment_id` is context only (the assessment the publisher +-- quoted when they wrote in); the case tracks the release, not that run. +-- +-- Timestamp columns that queries order on gain an integer `*_epoch_ms` sibling, +-- matching 0003/0004/0005 (RFC 3339 strings compare incorrectly across timezone +-- offsets in SQL). + +CREATE TABLE reconsiderations ( + id TEXT PRIMARY KEY, + subject_uri TEXT NOT NULL, + subject_cid TEXT NOT NULL, + triggering_assessment_id TEXT NOT NULL REFERENCES assessments(id), + state TEXT NOT NULL CHECK (state IN ('open', 'resolved')), + outcome TEXT CHECK (outcome IN ('granted', 'denied', 'withdrawn')), + opened_by_id TEXT NOT NULL, + opened_by_email TEXT, + opened_by_common_name TEXT, + opened_by_role TEXT NOT NULL CHECK (opened_by_role IN ('admin', 'reviewer')), + opened_at TEXT NOT NULL, + opened_at_epoch_ms INTEGER NOT NULL, + resolved_by_id TEXT, + resolved_by_email TEXT, + resolved_by_common_name TEXT, + resolved_at TEXT, + resolved_at_epoch_ms INTEGER, + outcome_action_id TEXT REFERENCES operator_actions(id), + CHECK ( + (state = 'open' AND outcome IS NULL AND resolved_at IS NULL AND outcome_action_id IS NULL) + OR (state = 'resolved' AND outcome IS NOT NULL AND resolved_at IS NOT NULL) + ) +); + +CREATE UNIQUE INDEX idx_reconsiderations_open_subject + ON reconsiderations(subject_uri, subject_cid) WHERE state = 'open'; +CREATE INDEX idx_reconsiderations_state ON reconsiderations(state, opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_subject + ON reconsiderations(subject_uri, subject_cid, opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_opened ON reconsiderations(opened_at_epoch_ms DESC); +CREATE INDEX idx_reconsiderations_outcome_action ON reconsiderations(outcome_action_id) + WHERE outcome_action_id IS NOT NULL; + +CREATE TABLE reconsideration_notes ( + id TEXT PRIMARY KEY, + reconsideration_id TEXT NOT NULL REFERENCES reconsiderations(id), + author_id TEXT NOT NULL, + author_email TEXT, + author_common_name TEXT, + author_role TEXT NOT NULL CHECK (author_role IN ('admin', 'reviewer')), + note TEXT NOT NULL, + created_at TEXT NOT NULL, + created_at_epoch_ms INTEGER NOT NULL +); + +CREATE TRIGGER reconsideration_notes_immutable_update +BEFORE UPDATE ON reconsideration_notes +BEGIN + SELECT RAISE(ABORT, 'reconsideration notes are immutable'); +END; + +CREATE TRIGGER reconsideration_notes_immutable_delete +BEFORE DELETE ON reconsideration_notes +BEGIN + SELECT RAISE(ABORT, 'reconsideration notes are immutable'); +END; + +CREATE INDEX idx_reconsideration_notes_case + ON reconsideration_notes(reconsideration_id, created_at_epoch_ms ASC); diff --git a/apps/labeler/src/console-api.ts b/apps/labeler/src/console-api.ts index b4187c1d4..3fef5a500 100644 --- a/apps/labeler/src/console-api.ts +++ b/apps/labeler/src/console-api.ts @@ -41,6 +41,8 @@ import { serializeOperatorActionView, serializeOperatorFinding, serializePublisherHistory, + serializeReconsideration, + serializeReconsiderationNote, serializeSubjectLabel, serializeSubjectRecord, type Page, @@ -51,6 +53,11 @@ import { computeEffectPreview, computeOverrideEffectPreview } from "./label-effe import { MutationGuardError } from "./mutation-guard.js"; import { getOperatorActionsPage } from "./operator-actions.js"; import { guardRead, ReadGuardError, type ReadGuardDeps } from "./operator-read-guard.js"; +import { + getNotesForReconsideration, + getReconsiderationById, + getReconsiderationsPage, +} from "./reconsiderations.js"; import { assertNegatableBlockSet, NegatableBlockSetError, parseSubjectKind } from "./service.js"; const DEFAULT_LIMIT = 50; @@ -132,6 +139,11 @@ function matchRoute( return () => handleListAuditLog(request, url, deps); } else if (segments[0] === "dead-letters" && segments.length === 1) { return () => handleListDeadLetters(request, url, deps); + } else if (segments[0] === "reconsiderations") { + if (segments.length === 1) return () => handleListReconsiderations(request, url, deps); + const id = segments[1]; + if (id !== undefined && segments.length === 2) + return () => handleGetReconsideration(request, deps, id); } else if (segments[0] === "status" && segments.length === 1) { return () => handleGetStatus(request, deps); } else if (segments[0] === "whoami" && segments.length === 1) { @@ -391,6 +403,47 @@ function parseCursorId(raw: string): number { return id; } +/** Lists reconsideration cases newest-first for the operator console (plan + * W10.6). Keyset pagination on `opened_at`, mirroring the audit log. */ +async function handleListReconsiderations( + request: Request, + url: URL, + deps: ConsoleApiDeps, +): Promise { + requireGet(request); + const limit = parseLimit(url.searchParams); + const filterHash = await computeFilterHash({}); + const keyset = decodeReadCursor(url.searchParams.get("cursor"), filterHash); + + const rows = await getReconsiderationsPage(deps.db, keyset, limit); + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + const last = page.at(-1); + const body: Page> = { + items: page.map(serializeReconsideration), + ...(hasMore && last + ? { nextCursor: encodeCursor({ createdAt: last.openedAt, id: last.id }, filterHash) } + : {}), + }; + return jsonData(body); +} + +/** One reconsideration case plus its private note thread (oldest-first). */ +async function handleGetReconsideration( + request: Request, + deps: ConsoleApiDeps, + id: string, +): Promise { + requireGet(request); + const reconsideration = await getReconsiderationById(deps.db, id); + if (!reconsideration) throw new ReadGuardError("NOT_FOUND"); + const notes = await getNotesForReconsideration(deps.db, id); + return jsonData({ + reconsideration: serializeReconsideration(reconsideration), + notes: notes.map(serializeReconsiderationNote), + }); +} + async function handleGetStatus(request: Request, deps: ConsoleApiDeps): Promise { requireGet(request); const [pendingAssessments, deadLetterDepth, automation, jetstreamConnected] = await Promise.all([ diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index f0ae55555..f6ebb5e0e 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -15,7 +15,12 @@ import type { LabelSigner } from "@emdash-cms/registry-moderation"; import { ulid } from "ulidx"; -import type { AccessAuthConfig, AccessKeyResolver } from "./access-auth.js"; +import type { + AccessAuthConfig, + AccessKeyResolver, + OperatorIdentity, + OperatorRole, +} from "./access-auth.js"; import { automatedIdempotencyKey, computeRunKey, @@ -50,6 +55,7 @@ import { notifyOperatorLabel, notifyOverride, notifyOverrideRetract, + notifyReconsiderationOutcome, type NotifyDeps, } from "./notification-triggers.js"; import { @@ -60,6 +66,17 @@ import { } from "./operational-events.js"; import { ReadGuardError } from "./operator-read-guard.js"; import { getLabelDefinition, MODERATION_POLICY } from "./policy.js"; +import { + buildReconsiderationInsert, + buildReconsiderationNoteInsert, + buildReconsiderationResolveUpdate, + getOpenCaseForSubject, + getReconsiderationById, + isOpenReconsiderationConflict, + newReconsiderationId, + newReconsiderationNoteId, + type ReconsiderationOutcome, +} from "./reconsiderations.js"; import { assertNegatableBlockSet, LabelIssuanceUnavailableError, @@ -205,6 +222,15 @@ export async function handleConsoleMutation( if (segments[2] === "quarantine") return await runDeadLetterAction(request, deps, id, "quarantined"); } + if (segments[0] === "reconsiderations") { + if (segments.length === 2 && segments[1] === "open") + return await runReconsiderationOpen(request, deps); + if (segments.length === 3 && segments[1] !== undefined) { + const id = segments[1]; + if (segments[2] === "note") return await runReconsiderationNote(request, deps, id); + if (segments[2] === "resolve") return await runReconsiderationResolve(request, deps, id); + } + } throw new ReadGuardError("NOT_FOUND"); } catch (error) { if ( @@ -212,7 +238,8 @@ export async function handleConsoleMutation( error instanceof ReadGuardError || error instanceof LabelMutationError || error instanceof DeadLetterResolvedError || - error instanceof NoActiveLabelError + error instanceof NoActiveLabelError || + error instanceof ReconsiderationStateError ) return error.toResponse(); // A submitted override negation set that isn't exactly the live automated @@ -590,6 +617,24 @@ function deferTakedownNotify(deps: ConsoleMutationDeps, d: EmergencyDescriptor): ); } +/** A resolve fires an outcome notice only for `granted`/`denied`; `withdrawn` + * notifies nothing. Keyed on the resolve action id so a replay dedups. */ +function deferReconsiderationNotify( + deps: ConsoleMutationDeps, + d: ReconsiderationResolveDescriptor, +): void { + if (!deps.notify) return; + if (d.outcome !== "granted" && d.outcome !== "denied") return; + deps.defer( + notifyReconsiderationOutcome(deps.notify, { + actionId: d.actionId, + uri: d.uri, + cid: d.cid, + outcome: d.outcome, + }), + ); +} + /** * `POST /admin/api/assessments/:id/rerun` — mints the immutable operator trigger * (`operator:`), creates a fresh run for the assessment's exact URI+CID @@ -1336,3 +1381,375 @@ function parseDeadLetterId(raw: string): number { if (!Number.isSafeInteger(id)) throw new ReadGuardError("NOT_FOUND"); return id; } + +// ─── W10.6: reconsideration case management + outcome notice ────────────────── + +const NOTE_MAX_LENGTH = 10_000; +const RECONSIDERATION_OUTCOMES: ReadonlySet = new Set(["granted", "denied", "withdrawn"]); + +type ReconsiderationStateCode = "RECONSIDERATION_OPEN_EXISTS" | "RECONSIDERATION_RESOLVED"; + +/** A case-state conflict: a second open for a subject that already has an open + * case, or a resolve of an already-resolved case. A 409 rather than a spurious + * success. Static messages; neither echoes operator-supplied content. */ +class ReconsiderationStateError extends Error { + override readonly name = "ReconsiderationStateError"; + readonly code: ReconsiderationStateCode; + readonly status = 409; + + constructor(code: ReconsiderationStateCode) { + super( + code === "RECONSIDERATION_OPEN_EXISTS" + ? "An open reconsideration already exists for this subject" + : "Reconsideration is already resolved", + ); + this.code = code; + } + + toResponse(): Response { + return Response.json( + { error: { code: this.code, message: this.message } }, + { status: this.status }, + ); + } +} + +interface ReconsiderationOpenBody { + assessmentId: string; + note: string; +} + +/** `:id` folded into the parsed body so it joins the request fingerprint — a + * replayed key must target the same case. */ +interface ReconsiderationNoteBody { + reconsiderationId: string; + note: string; +} + +interface ReconsiderationResolveBody { + reconsiderationId: string; + outcome: ReconsiderationOutcome; + note?: string; +} + +interface ReconsiderationOpenDescriptor { + actionId: string; + reconsiderationId: string; + uri: string; + cid: string; + triggeringAssessmentId: string; + cts: string; +} + +interface ReconsiderationNoteDescriptor { + actionId: string; + reconsiderationId: string; + noteId: string; + cts: string; +} + +interface ReconsiderationResolveDescriptor { + actionId: string; + reconsiderationId: string; + outcome: ReconsiderationOutcome; + uri: string; + cid: string; + cts: string; +} + +/** The actor provenance columns shared by the case and its notes, derived like + * `commitMutation`'s audit actor: a human carries an email, a service a common name. */ +function reconsiderationActor(ctx: { identity: OperatorIdentity; role: OperatorRole }): { + id: string; + email: string | null; + commonName: string | null; + role: OperatorRole; +} { + return { + id: ctx.identity.sub, + email: ctx.identity.kind === "human" ? ctx.identity.email : null, + commonName: ctx.identity.kind === "service" ? ctx.identity.commonName : null, + role: ctx.role, + }; +} + +function requireNote(value: unknown): string { + if (typeof value !== "string" || value.trim().length === 0 || value.length > NOTE_MAX_LENGTH) + throw new MutationGuardError("INVALID_BODY"); + return value; +} + +function parseOutcome(value: unknown): ReconsiderationOutcome { + if (typeof value !== "string" || !RECONSIDERATION_OUTCOMES.has(value)) + throw new MutationGuardError("INVALID_BODY"); + // eslint-disable-next-line typescript/no-unsafe-type-assertion -- validated against the set above + return value as ReconsiderationOutcome; +} + +/** + * `POST /admin/api/reconsiderations/open` — opens a case for the subject of the + * quoted assessment (uri + cid, stable across reruns) with its first private + * note, and emits a `reconsideration-opened` event, all in one atomic + * `commitMutation` batch with the audit row. Issues no label and sends no notice. + * A subject that already has an open case is a 409: the pre-check handles the + * common path, and the partial unique index closes the race between it and the + * commit (mapped back to the same 409 via `isOpenReconsiderationConflict`). + */ +async function runReconsiderationOpen( + request: Request, + deps: ConsoleMutationDeps, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-open", + requiredRole: "reviewer", + parseBody: (raw) => ({ + assessmentId: requireString(raw.assessmentId), + note: requireNote(raw.note), + }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const assessment = await loadAssessment(deps.db, ctx.body.assessmentId); + if (await getOpenCaseForSubject(deps.db, assessment.uri, assessment.cid)) + throw new ReconsiderationStateError("RECONSIDERATION_OPEN_EXISTS"); + + const reconsiderationId = newReconsiderationId(); + const actor = reconsiderationActor(ctx); + const caseInsert = buildReconsiderationInsert(deps.db, { + id: reconsiderationId, + subjectUri: assessment.uri, + subjectCid: assessment.cid, + triggeringAssessmentId: assessment.id, + openedById: actor.id, + openedByEmail: actor.email, + openedByCommonName: actor.commonName, + openedByRole: actor.role, + openedAt: ctx.now.toISOString(), + openedAtEpochMs: ctx.now.getTime(), + }); + const noteInsert = buildReconsiderationNoteInsert(deps.db, { + id: newReconsiderationNoteId(), + reconsiderationId, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }); + const eventInsert = buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: "reconsideration-opened", + severity: "info", + actionId: ctx.actionId, + subjectUri: assessment.uri, + payload: { reason: ctx.reason }, + now: ctx.now, + }); + + const descriptor: ReconsiderationOpenDescriptor = { + actionId: ctx.actionId, + reconsiderationId, + uri: assessment.uri, + cid: assessment.cid, + triggeringAssessmentId: assessment.id, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: assessment.uri, + subjectCid: assessment.cid, + metadata: { reconsiderationId }, + }), + }; + try { + return jsonData( + await commitMutation( + deps.db, + ctx, + commitSpec, + [caseInsert, noteInsert, eventInsert], + descriptor, + ), + ); + } catch (error) { + if (isOpenReconsiderationConflict(error)) + throw new ReconsiderationStateError("RECONSIDERATION_OPEN_EXISTS"); + throw error; + } +} + +/** + * `POST /admin/api/reconsiderations/:id/note` — appends one private note. A note + * is allowed in any state (a resolved case still accepts post-hoc audit notes), + * so the only gate is that the case exists (404). Commits the note INSERT with + * the audit row; no event, no notice. + */ +async function runReconsiderationNote( + request: Request, + deps: ConsoleMutationDeps, + id: string, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-note", + requiredRole: "reviewer", + parseBody: (raw) => ({ reconsiderationId: id, note: requireNote(raw.note) }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") + return jsonData(storedDescriptor(outcome.result)); + + const { ctx } = outcome; + const existing = await getReconsiderationById(deps.db, id); + if (!existing) throw new ReadGuardError("NOT_FOUND"); + + const actor = reconsiderationActor(ctx); + const noteId = newReconsiderationNoteId(); + const noteInsert = buildReconsiderationNoteInsert(deps.db, { + id: noteId, + reconsiderationId: id, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }); + + const descriptor: ReconsiderationNoteDescriptor = { + actionId: ctx.actionId, + reconsiderationId: id, + noteId, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: existing.subjectUri, + subjectCid: existing.subjectCid, + metadata: { reconsiderationId: id }, + }), + }; + return jsonData(await commitMutation(deps.db, ctx, commitSpec, [noteInsert], descriptor)); +} + +/** + * `POST /admin/api/reconsiderations/:id/resolve` — sets the outcome + state, + * emits a `reconsideration-resolved` event, and (for `granted`/`denied`) fires + * the outcome notice through the W10.5 pipeline. Issues no label. The resolve is + * a `state = 'open'`-guarded UPDATE (like `runDeadLetterAction`): a double-resolve + * is a zero-row UPDATE, and the pre-check reports it as a 409. The notice fires + * only when this action actually won the UPDATE, so a losing concurrent resolve + * does not re-notify under its own action id. + */ +async function runReconsiderationResolve( + request: Request, + deps: ConsoleMutationDeps, + id: string, +): Promise { + const spec: MutationSpec = { + action: "reconsideration-resolve", + requiredRole: "reviewer", + parseBody: (raw) => ({ + reconsiderationId: id, + outcome: parseOutcome(raw.outcome), + ...(raw.note === undefined ? {} : { note: requireNote(raw.note) }), + }), + auditFields: () => ({}), + }; + + const outcome = await guardMutation(request, spec, guardDepsOf(deps)); + if (outcome.outcome === "replay") { + const stored = storedDescriptor(outcome.result); + // Gate the replay notice on the same win check as the fresh path: a resolve + // that lost the guarded UPDATE (a concurrent distinct-key resolve) committed + // its audit row but never resolved the case, so replaying its key must not + // fire a second, possibly contradictory outcome notice under its own source id. + if (await resolveWon(deps.db, stored.reconsiderationId, stored.actionId)) + deferReconsiderationNotify(deps, stored); + return jsonData(stored); + } + + const { ctx } = outcome; + const existing = await getReconsiderationById(deps.db, id); + if (!existing) throw new ReadGuardError("NOT_FOUND"); + if (existing.state !== "open") throw new ReconsiderationStateError("RECONSIDERATION_RESOLVED"); + + const actor = reconsiderationActor(ctx); + const resolveUpdate = buildReconsiderationResolveUpdate(deps.db, { + id, + outcome: ctx.body.outcome, + resolvedById: actor.id, + resolvedByEmail: actor.email, + resolvedByCommonName: actor.commonName, + resolvedAt: ctx.now.toISOString(), + resolvedAtEpochMs: ctx.now.getTime(), + outcomeActionId: ctx.actionId, + }); + const statements: D1PreparedStatement[] = [resolveUpdate]; + if (ctx.body.note !== undefined) { + statements.push( + buildReconsiderationNoteInsert(deps.db, { + id: newReconsiderationNoteId(), + reconsiderationId: id, + authorId: actor.id, + authorEmail: actor.email, + authorCommonName: actor.commonName, + authorRole: actor.role, + note: ctx.body.note, + createdAt: ctx.now.toISOString(), + createdAtEpochMs: ctx.now.getTime(), + }), + ); + } + statements.push( + buildOperationalEventInsert(deps.db, { + id: newOperationalEventId(), + eventType: "reconsideration-resolved", + severity: "info", + actionId: ctx.actionId, + subjectUri: existing.subjectUri, + payload: { reason: ctx.reason }, + now: ctx.now, + }), + ); + + const descriptor: ReconsiderationResolveDescriptor = { + actionId: ctx.actionId, + reconsiderationId: id, + outcome: ctx.body.outcome, + uri: existing.subjectUri, + cid: existing.subjectCid, + cts: ctx.now.toISOString(), + }; + const commitSpec: MutationSpec = { + ...spec, + auditFields: () => ({ + subjectUri: existing.subjectUri, + subjectCid: existing.subjectCid, + metadata: { reconsiderationId: id, outcome: ctx.body.outcome }, + }), + }; + const returned = await commitMutation(deps.db, ctx, commitSpec, statements, descriptor); + if (await resolveWon(deps.db, id, returned.actionId)) deferReconsiderationNotify(deps, returned); + return jsonData(returned); +} + +/** Whether this action won the `state = 'open'` resolve race: the case's + * `outcome_action_id` is ours. A loser that committed its audit row yet matched + * zero rows reads back the winner's id and skips its notice, so the outcome + * notice fires exactly once. */ +async function resolveWon(db: D1Database, id: string, actionId: string): Promise { + const current = await getReconsiderationById(db, id); + return current?.outcomeActionId === actionId; +} diff --git a/apps/labeler/src/console-serialize.ts b/apps/labeler/src/console-serialize.ts index 0eb151cec..84c9639de 100644 --- a/apps/labeler/src/console-serialize.ts +++ b/apps/labeler/src/console-serialize.ts @@ -30,6 +30,12 @@ import type { FindingSeverity } from "./evidence.js"; import type { FindingSource } from "./findings.js"; import type { StoredOperatorAction } from "./operator-actions.js"; import { derivePublicState } from "./public-assessment.js"; +import type { + ReconsiderationOutcome, + ReconsiderationState, + StoredReconsideration, + StoredReconsiderationNote, +} from "./reconsiderations.js"; export interface AssessmentRun { id: string; @@ -165,6 +171,38 @@ export interface DeadLetter { resolvedByActionId: string | null; } +/** A reconsideration case for the operator console. */ +export interface ReconsiderationView { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + outcomeActionId: string | null; +} + +/** A private reconsideration note for the operator console. */ +export interface ReconsiderationNoteView { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; +} + export interface Page { items: T[]; nextCursor?: string; @@ -296,6 +334,46 @@ export function serializeOperatorActionView(action: StoredOperatorAction): Opera }; } +/** A reconsideration case for the reviewer console. Every field is operator-only + * (Access-edge + reviewer gated), including the actor provenance. */ +export function serializeReconsideration(row: StoredReconsideration): ReconsiderationView { + return { + id: row.id, + subjectUri: row.subjectUri, + subjectCid: row.subjectCid, + triggeringAssessmentId: row.triggeringAssessmentId, + state: row.state, + outcome: row.outcome, + openedById: row.openedById, + openedByEmail: row.openedByEmail, + openedByCommonName: row.openedByCommonName, + openedByRole: row.openedByRole, + openedAt: row.openedAt, + resolvedById: row.resolvedById, + resolvedByEmail: row.resolvedByEmail, + resolvedByCommonName: row.resolvedByCommonName, + resolvedAt: row.resolvedAt, + outcomeActionId: row.outcomeActionId, + }; +} + +/** A private reconsideration note. The `note` text is operator-only and NEVER + * enters publisher notice copy. */ +export function serializeReconsiderationNote( + row: StoredReconsiderationNote, +): ReconsiderationNoteView { + return { + id: row.id, + reconsiderationId: row.reconsiderationId, + authorId: row.authorId, + authorEmail: row.authorEmail, + authorCommonName: row.authorCommonName, + authorRole: row.authorRole, + note: row.note, + createdAt: row.createdAt, + }; +} + export function serializeDeadLetter(row: StoredDeadLetter): DeadLetter { return { id: row.id, diff --git a/apps/labeler/src/notification-triggers.ts b/apps/labeler/src/notification-triggers.ts index 0bdb6dcb9..7fc48107a 100644 --- a/apps/labeler/src/notification-triggers.ts +++ b/apps/labeler/src/notification-triggers.ts @@ -201,6 +201,36 @@ function emergencyNoticeContent( }; } +/** The two reconsideration outcomes that notify. `withdrawn` fires nothing, so + * it is deliberately absent from this union. */ +export type ReconsiderationNoticeOutcome = "granted" | "denied"; + +function reconsiderationOutcomeNoticeContent( + urls: NoticeUrls, + input: { uri: string; cid?: string; outcome: ReconsiderationNoticeOutcome }, +): NoticeContent { + const granted = input.outcome === "granted"; + const shared = { + assessmentUrl: assessmentUrl(urls.serviceUrl, input.uri, input.cid), + reconsiderationUrl: urls.reconsiderationUrl, + }; + return granted + ? { + subject: "Your reconsideration request was granted", + publicSummary: + "After reviewing your reconsideration request, the labeler revised its assessment of this release.", + effect: "Any resulting label changes are reflected in the current assessment.", + ...shared, + } + : { + subject: "Your reconsideration request was reviewed", + publicSummary: + "After reviewing your reconsideration request, the labeler upheld its assessment of this release.", + effect: "The assessment stands unchanged.", + ...shared, + }; +} + // ── Live trigger entry points ─────────────────────────────────────────────── /** Automated block/warning notice from a finalized assessment run. Source is the @@ -279,6 +309,23 @@ export async function notifyEmergencyTakedown( ); } +/** Reconsideration outcome notice (console `reconsiderations/:id/resolve`). Fired + * only for `granted`/`denied`; the caller never invokes it for `withdrawn`. Source + * is the resolve operator action, so a mutation replay dedups on it. */ +export async function notifyReconsiderationOutcome( + deps: NotifyDeps, + input: { actionId: string; uri: string; cid?: string; outcome: ReconsiderationNoticeOutcome }, +): Promise { + const target = contactTargetFromUri(input.uri); + if (!target) return; + await runTrigger( + deps, + { type: "operator", id: input.actionId }, + target, + reconsiderationOutcomeNoticeContent(deps, input), + ); +} + /** * Rebuild a NOTICE's public content from its source row, for the retry sweep. * Nothing about the notice is persisted (plaintext minimization), so a retry @@ -321,11 +368,30 @@ export async function resolveNoticeForSource( return overrideRetractNoticeContent(deps, { uri, cid }); case "takedown": return emergencyNoticeContent(deps, { uri, neg: operatorActionNeg(action.metadataJson) }); + case "reconsideration-resolve": { + const outcome = reconsiderationNoticeOutcome(action.metadataJson); + return outcome ? reconsiderationOutcomeNoticeContent(deps, { uri, cid, outcome }) : null; + } default: return null; } } +/** The notifying outcome a resolve action stored in its metadata, or null when it + * is `withdrawn` (no notice) or unreadable — the sweep then abandons the row. */ +function reconsiderationNoticeOutcome(metadataJson: string): ReconsiderationNoticeOutcome | null { + try { + const parsed: unknown = JSON.parse(metadataJson); + if (typeof parsed === "object" && parsed !== null) { + const outcome = (parsed as { outcome?: unknown }).outcome; + if (outcome === "granted" || outcome === "denied") return outcome; + } + } catch { + return null; + } + return null; +} + /** `getAssessment` throws `TypeError` only for a malformed id — which a stored * `source_id` never is — so that maps to "no notice" (null). Any OTHER error is a * transient read failure: it must PROPAGATE, not abandon the row, so the sweep diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index a26ad0047..090437478 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -11,7 +11,9 @@ export type OperationalEventType = | "automation-paused" | "automation-resumed" | "dead-letter-retried" - | "dead-letter-quarantined"; + | "dead-letter-quarantined" + | "reconsideration-opened" + | "reconsideration-resolved"; export type OperationalEventSeverity = "critical" | "high" | "info"; diff --git a/apps/labeler/src/operator-actions.ts b/apps/labeler/src/operator-actions.ts index 24e1293d4..548b640bd 100644 --- a/apps/labeler/src/operator-actions.ts +++ b/apps/labeler/src/operator-actions.ts @@ -16,7 +16,10 @@ export type OperatorActionType = | "pause-issuance" | "resume-issuance" | "dlq-retry" - | "dlq-quarantine"; + | "dlq-quarantine" + | "reconsideration-open" + | "reconsideration-note" + | "reconsideration-resolve"; export interface StoredOperatorAction { id: string; diff --git a/apps/labeler/src/reconsiderations.ts b/apps/labeler/src/reconsiderations.ts new file mode 100644 index 000000000..66aef1f4c --- /dev/null +++ b/apps/labeler/src/reconsiderations.ts @@ -0,0 +1,343 @@ +import { ulid } from "ulidx"; + +import type { OperatorRole } from "./access-auth.js"; + +/** The resolution vocabulary (plan W10.6): `granted` (assessment revised in the + * publisher's favour), `denied` (assessment upheld), `withdrawn` (requester + * withdrew / duplicate / moot). Only `granted`/`denied` fire an outcome notice. */ +export type ReconsiderationOutcome = "granted" | "denied" | "withdrawn"; + +export type ReconsiderationState = "open" | "resolved"; + +export interface StoredReconsideration { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + openedAtEpochMs: number; + resolvedById: string | null; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string | null; + resolvedAtEpochMs: number | null; + outcomeActionId: string | null; +} + +export interface StoredReconsiderationNote { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; + createdAtEpochMs: number; +} + +interface ReconsiderationRow { + id: string; + subject_uri: string; + subject_cid: string; + triggering_assessment_id: string; + state: ReconsiderationState; + outcome: ReconsiderationOutcome | null; + opened_by_id: string; + opened_by_email: string | null; + opened_by_common_name: string | null; + opened_by_role: OperatorRole; + opened_at: string; + opened_at_epoch_ms: number; + resolved_by_id: string | null; + resolved_by_email: string | null; + resolved_by_common_name: string | null; + resolved_at: string | null; + resolved_at_epoch_ms: number | null; + outcome_action_id: string | null; +} + +interface ReconsiderationNoteRow { + id: string; + reconsideration_id: string; + author_id: string; + author_email: string | null; + author_common_name: string | null; + author_role: OperatorRole; + note: string; + created_at: string; + created_at_epoch_ms: number; +} + +export interface ReconsiderationInsert { + id: string; + subjectUri: string; + subjectCid: string; + triggeringAssessmentId: string; + openedById: string; + openedByEmail: string | null; + openedByCommonName: string | null; + openedByRole: OperatorRole; + openedAt: string; + openedAtEpochMs: number; +} + +export interface ReconsiderationNoteInsert { + id: string; + reconsiderationId: string; + authorId: string; + authorEmail: string | null; + authorCommonName: string | null; + authorRole: OperatorRole; + note: string; + createdAt: string; + createdAtEpochMs: number; +} + +export interface ReconsiderationResolveUpdate { + id: string; + outcome: ReconsiderationOutcome; + resolvedById: string; + resolvedByEmail: string | null; + resolvedByCommonName: string | null; + resolvedAt: string; + resolvedAtEpochMs: number; + outcomeActionId: string; +} + +export function newReconsiderationId(): string { + return `rcn_${ulid()}`; +} + +export function newReconsiderationNoteId(): string { + return `rcnn_${ulid()}`; +} + +/** + * Insert for one case, always born `open` with a null outcome. A concurrent + * second open for the same subject violates `idx_reconsiderations_open_subject` + * and aborts the enclosing `db.batch` (see {@link isOpenReconsiderationConflict}). + * Batched with its first note + the operational event + the operator_actions row + * by `commitMutation`; never run alone. + */ +export function buildReconsiderationInsert( + db: D1Database, + input: ReconsiderationInsert, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO reconsiderations + (id, subject_uri, subject_cid, triggering_assessment_id, state, outcome, + opened_by_id, opened_by_email, opened_by_common_name, opened_by_role, + opened_at, opened_at_epoch_ms) + VALUES (?, ?, ?, ?, 'open', NULL, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.subjectUri, + input.subjectCid, + input.triggeringAssessmentId, + input.openedById, + input.openedByEmail, + input.openedByCommonName, + input.openedByRole, + input.openedAt, + input.openedAtEpochMs, + ); +} + +/** Insert for one append-only private note. Batched with the operator_actions + * row by `commitMutation`; never run alone. */ +export function buildReconsiderationNoteInsert( + db: D1Database, + input: ReconsiderationNoteInsert, +): D1PreparedStatement { + return db + .prepare( + `INSERT INTO reconsideration_notes + (id, reconsideration_id, author_id, author_email, author_common_name, + author_role, note, created_at, created_at_epoch_ms) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .bind( + input.id, + input.reconsiderationId, + input.authorId, + input.authorEmail, + input.authorCommonName, + input.authorRole, + input.note, + input.createdAt, + input.createdAtEpochMs, + ); +} + +/** + * Effect statement for a resolve. The `state = 'open'` predicate makes a second + * concurrent resolve a zero-row UPDATE: the row flips exactly once and + * `outcome_action_id` records the winner, so a loser that also committed its + * audit row is detected post-commit and skips its outcome notice. Batched with + * the operator_actions row + an optional final note + the operational event by + * `commitMutation`. + */ +export function buildReconsiderationResolveUpdate( + db: D1Database, + input: ReconsiderationResolveUpdate, +): D1PreparedStatement { + return db + .prepare( + `UPDATE reconsiderations + SET state = 'resolved', outcome = ?, resolved_by_id = ?, resolved_by_email = ?, + resolved_by_common_name = ?, resolved_at = ?, resolved_at_epoch_ms = ?, + outcome_action_id = ? + WHERE id = ? AND state = 'open'`, + ) + .bind( + input.outcome, + input.resolvedById, + input.resolvedByEmail, + input.resolvedByCommonName, + input.resolvedAt, + input.resolvedAtEpochMs, + input.outcomeActionId, + input.id, + ); +} + +const RECONSIDERATION_COLUMNS = `id, subject_uri, subject_cid, triggering_assessment_id, state, + outcome, opened_by_id, opened_by_email, opened_by_common_name, opened_by_role, + opened_at, opened_at_epoch_ms, resolved_by_id, resolved_by_email, resolved_by_common_name, + resolved_at, resolved_at_epoch_ms, outcome_action_id`; + +/** The single open case for a subject, or null. Backed by the partial unique + * index, so at most one row can match. */ +export async function getOpenCaseForSubject( + db: D1Database, + subjectUri: string, + subjectCid: string, +): Promise { + const row = await db + .prepare( + `SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations + WHERE subject_uri = ? AND subject_cid = ? AND state = 'open'`, + ) + .bind(subjectUri, subjectCid) + .first(); + return row ? rowToStoredReconsideration(row) : null; +} + +export async function getReconsiderationById( + db: D1Database, + id: string, +): Promise { + const row = await db + .prepare(`SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations WHERE id = ?`) + .bind(id) + .first(); + return row ? rowToStoredReconsideration(row) : null; +} + +/** + * Case page, newest first, exclusive keyset on `(opened_at_epoch_ms, id)` over + * `idx_reconsiderations_opened`. Fetches `limit + 1` so the caller detects a next + * page without a trailing COUNT — matching `getOperatorActionsPage`. + */ +export async function getReconsiderationsPage( + db: D1Database, + keyset: { createdAt: string; id: string } | null, + limit: number, +): Promise { + const bindings: (string | number)[] = []; + let where = ""; + if (keyset !== null) { + const epochMs = Date.parse(keyset.createdAt); + where = `WHERE (opened_at_epoch_ms < ? OR (opened_at_epoch_ms = ? AND id < ?))`; + bindings.push(epochMs, epochMs, keyset.id); + } + bindings.push(limit + 1); + const rows = await db + .prepare( + `SELECT ${RECONSIDERATION_COLUMNS} FROM reconsiderations ${where} + ORDER BY opened_at_epoch_ms DESC, id DESC + LIMIT ?`, + ) + .bind(...bindings) + .all(); + return (rows.results ?? []).map(rowToStoredReconsideration); +} + +/** Notes for one case, oldest first (the order an operator reads the thread). */ +export async function getNotesForReconsideration( + db: D1Database, + reconsiderationId: string, +): Promise { + const rows = await db + .prepare( + `SELECT id, reconsideration_id, author_id, author_email, author_common_name, + author_role, note, created_at, created_at_epoch_ms + FROM reconsideration_notes + WHERE reconsideration_id = ? + ORDER BY created_at_epoch_ms ASC, id ASC`, + ) + .bind(reconsiderationId) + .all(); + return (rows.results ?? []).map(rowToStoredReconsiderationNote); +} + +/** + * True when `error` is the D1 UNIQUE violation on the open-case partial index. + * `commitMutation` rethrows any non-idempotency-key violation, so the open + * handler catches this around its commit to report a duplicate-open 409 rather + * than a 500 on the race between its pre-check and the batch. + */ +export function isOpenReconsiderationConflict(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes( + "UNIQUE constraint failed: reconsiderations.subject_uri, reconsiderations.subject_cid", + ) + ); +} + +function rowToStoredReconsideration(row: ReconsiderationRow): StoredReconsideration { + return { + id: row.id, + subjectUri: row.subject_uri, + subjectCid: row.subject_cid, + triggeringAssessmentId: row.triggering_assessment_id, + state: row.state, + outcome: row.outcome, + openedById: row.opened_by_id, + openedByEmail: row.opened_by_email, + openedByCommonName: row.opened_by_common_name, + openedByRole: row.opened_by_role, + openedAt: row.opened_at, + openedAtEpochMs: row.opened_at_epoch_ms, + resolvedById: row.resolved_by_id, + resolvedByEmail: row.resolved_by_email, + resolvedByCommonName: row.resolved_by_common_name, + resolvedAt: row.resolved_at, + resolvedAtEpochMs: row.resolved_at_epoch_ms, + outcomeActionId: row.outcome_action_id, + }; +} + +function rowToStoredReconsiderationNote(row: ReconsiderationNoteRow): StoredReconsiderationNote { + return { + id: row.id, + reconsiderationId: row.reconsideration_id, + authorId: row.author_id, + authorEmail: row.author_email, + authorCommonName: row.author_common_name, + authorRole: row.author_role, + note: row.note, + createdAt: row.created_at, + createdAtEpochMs: row.created_at_epoch_ms, + }; +} diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts new file mode 100644 index 000000000..60cda96bd --- /dev/null +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -0,0 +1,787 @@ +import { applyD1Migrations, env } from "cloudflare:test"; +import { generateKeyPair, SignJWT } from "jose"; +import { beforeAll, describe, expect, it } from "vitest"; + +import type { AccessKeyResolver } from "../src/access-auth.js"; +import { AggregatorClient } from "../src/aggregator-client.js"; +import { computeRunKey, initialTriggerId } from "../src/assessment-lifecycle.js"; +import { createAssessmentRun, createSubject } from "../src/assessment-store.js"; +import { handleConsoleApi, type ConsoleApiDeps } from "../src/console-api.js"; +import { handleConsoleMutation, type ConsoleMutationDeps } from "../src/console-mutation-api.js"; +import { OPERATOR_REQUEST_HEADER } from "../src/mutation-guard.js"; +import { + confirmContact, + ensureContact, + hashConfirmToken, + recipientHash, + recordConfirmSent, +} from "../src/notification-contacts.js"; +import type { ConfirmationPayload, NoticePayload, SendResult } from "../src/notification-send.js"; +import { resolveNoticeForSource, type NotifyDeps } from "../src/notification-triggers.js"; + +const TEAM_DOMAIN = "https://example-team.cloudflareaccess.com"; +const AUDIENCE = "test-audience"; +const ORIGIN = "https://labeler.example.com"; +const LABELER_DID = "did:web:labels.emdashcms.com"; +const LABELER_SERVICE_URL = "https://labels.emdashcms.com"; +const PUBLISHER_DID = "did:plc:publisher000000000000000000"; +const PUBLISHER_EMAIL = "security@publisher.example"; +const RECON_URL = "https://emdashcms.com/plugin-moderation/reconsideration"; +const PEPPER = "recon-pepper"; +const CID = "bafkreif4oaymum54i5qefbwoblrt5zasfjhpyhyvacpseqtehi3queew5m"; +const PRIVATE_NOTE = "PRIVATE-INTERNAL-secret-exploit-detail-do-not-leak"; + +const CONFIG = { + labelerDid: LABELER_DID, + signingKeyVersion: "v1", + serviceUrl: LABELER_SERVICE_URL, + signingPublicKeyMultibase: "zDnaepsL7AXenJkVYdkh5KuKsSU7Ykh7kyXaLLU7auN9FWSiZ", +}; + +interface TestEnv { + DB: D1Database; + TEST_MIGRATIONS: Parameters[1]; +} +const testEnv = env as unknown as TestEnv; + +let resolver: AccessKeyResolver; +let signKey: CryptoKey; +let reviewerToken: string; +let keySeq = 0; + +beforeAll(async () => { + await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS); + const pair = await generateKeyPair("RS256"); + signKey = pair.privateKey; + resolver = (async () => pair.publicKey) as AccessKeyResolver; + reviewerToken = await mintToken({ email: "reviewer@example.com" }); +}); + +async function mintToken(claims: Record): Promise { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ sub: "user-sub-1", ...claims }) + .setProtectedHeader({ alg: "RS256" }) + .setIssuer(TEAM_DOMAIN) + .setAudience(AUDIENCE) + .setIssuedAt(now) + .setExpirationTime(now + 3600) + .sign(signKey); +} + +interface RecordingSender { + confirmations: ConfirmationPayload[]; + notices: NoticePayload[]; + sendConfirmation(p: ConfirmationPayload): Promise; + sendNotice(p: NoticePayload): Promise; +} + +function recordingSender(): RecordingSender { + const confirmations: ConfirmationPayload[] = []; + const notices: NoticePayload[] = []; + return { + confirmations, + notices, + sendConfirmation: async (p) => { + confirmations.push(p); + return { ok: true, providerId: "p" }; + }, + sendNotice: async (p) => { + notices.push(p); + return { ok: true, providerId: "p" }; + }, + }; +} + +/** Aggregator that surfaces the publisher's security email and no verification + * claims, so a notice takes the double-opt-in path against a confirmed contact. */ +function aggregator(): AggregatorClient { + const fetcher = { + fetch: async (url: string) => { + if (url.includes("getPublisherVerification")) + return Response.json({ did: PUBLISHER_DID, verifications: [], labels: [] }); + if (url.includes("getPublisher")) + return Response.json({ + did: PUBLISHER_DID, + profile: { contact: [{ kind: "security", email: PUBLISHER_EMAIL }] }, + }); + return new Response(JSON.stringify({ error: "NotFound" }), { + status: 404, + headers: { "content-type": "application/json" }, + }); + }, + } as unknown as Fetcher; + return new AggregatorClient(fetcher); +} + +function notifyDeps(sender: RecordingSender): NotifyDeps { + return { + db: testEnv.DB, + aggregator: aggregator(), + sender, + pepper: PEPPER, + serviceUrl: LABELER_SERVICE_URL, + reconsiderationUrl: RECON_URL, + }; +} + +/** A mutation-deps whose `defer` collects the post-commit promises so a test can + * await the deferred notice before asserting on the sender. */ +function mutationDeps(overrides: Partial = {}): { + deps: ConsoleMutationDeps; + settle: () => Promise; +} { + const deferred: Promise[] = []; + const deps: ConsoleMutationDeps = { + db: testEnv.DB, + accessConfig: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + config: CONFIG, + createSigner: () => Promise.reject(new Error("no signer needed")), + now: () => new Date(), + afterCommit: async () => {}, + defer: (work) => { + deferred.push(work); + }, + sendDiscoveryJob: async () => {}, + ...overrides, + }; + return { deps, settle: async () => void (await Promise.allSettled(deferred)) }; +} + +function readDeps(): ConsoleApiDeps { + return { + db: testEnv.DB, + config: { + teamDomain: TEAM_DOMAIN, + audience: AUDIENCE, + admins: ["admin@example.com"], + reviewers: ["reviewer@example.com"], + }, + keys: resolver, + expectedOrigin: ORIGIN, + labelerDid: LABELER_DID, + jetstreamConnected: async () => true, + }; +} + +function post(path: string, body: unknown): Request { + const headers = new Headers(); + headers.set(OPERATOR_REQUEST_HEADER, "1"); + headers.set("Content-Type", "application/json"); + headers.set("Cf-Access-Jwt-Assertion", reviewerToken); + return new Request(`${ORIGIN}${path}`, { method: "POST", headers, body: JSON.stringify(body) }); +} + +function getReq(path: string): Request { + const headers = new Headers(); + headers.set(OPERATOR_REQUEST_HEADER, "1"); + headers.set("Cf-Access-Jwt-Assertion", reviewerToken); + return new Request(`${ORIGIN}${path}`, { method: "GET", headers }); +} + +function nextKey(): string { + keySeq += 1; + return `recon-key-${keySeq.toString().padStart(6, "0")}`; +} + +function releaseUri(rkey: string): string { + return `at://${PUBLISHER_DID}/com.emdashcms.experimental.package.release/${rkey}`; +} + +async function bodyData(response: Response): Promise { + const parsed = (await response.json()) as { data: T }; + return parsed.data; +} + +async function bodyError(response: Response): Promise<{ code: string; message: string }> { + const parsed = (await response.json()) as { error: { code: string; message: string } }; + return parsed.error; +} + +async function countRows(sql: string, ...binds: unknown[]): Promise { + const row = await testEnv.DB.prepare(sql) + .bind(...binds) + .first<{ n: number }>(); + return row?.n ?? 0; +} + +/** Seeds a subject + one assessment run, returning its id + uri. */ +async function seedRun(rkey: string, cid = CID): Promise<{ id: string; uri: string }> { + const uri = releaseUri(rkey); + await createSubject(testEnv.DB, { + uri, + cid, + did: PUBLISHER_DID, + collection: "com.emdashcms.experimental.package.release", + rkey, + now: new Date("2026-07-08T08:00:00.000Z"), + }); + const triggerId = initialTriggerId(cid); + const runKey = await computeRunKey({ + uri, + cid, + policyVersion: "v1", + modelId: "m", + promptHash: "p", + scannerSetVersion: "v1", + triggerId, + }); + const { assessment } = await createAssessmentRun(testEnv.DB, { + runKey, + uri, + cid, + trigger: "initial", + triggerId, + policyVersion: "v1", + coverageJson: "{}", + now: new Date("2026-07-08T08:05:00.000Z"), + }); + return { id: assessment.id, uri }; +} + +/** Confirms a contact for the publisher email so the notice sends rather than + * kicking off a double-opt-in confirmation. */ +async function seedConfirmedContact(): Promise { + const hash = await recipientHash(PEPPER, PUBLISHER_EMAIL); + await ensureContact(testEnv.DB, hash, "2026-07-16T00:00:00.000Z"); + const th = await hashConfirmToken("seed"); + await recordConfirmSent(testEnv.DB, hash, th, 1_000); + await confirmContact(testEnv.DB, hash, th, "2026-07-16T00:00:01.000Z"); +} + +/** + * Wraps `db` so the FIRST `getReconsiderationById` SELECT returns `snapshot` (a + * stale `open` view) and then passes through. Reproduces a concurrent resolve + * whose pre-check read the case as open before the winner's guarded UPDATE + * landed: the loser's own UPDATE no-ops while its audit row still commits. + */ +function staleOpenOnce(db: D1Database, snapshot: Record): D1Database { + let used = false; + return new Proxy(db, { + get(target, prop, receiver) { + if (prop === "prepare") { + return (sql: string) => { + const stmt = target.prepare(sql); + if (used || !(sql.includes("FROM reconsiderations") && sql.includes("WHERE id = ?"))) + return stmt; + return new Proxy(stmt, { + get(s, p) { + if (p === "bind") + return (...args: unknown[]) => { + const bound = s.bind(...args); + return new Proxy(bound, { + get(b, bp) { + if (bp === "first") + return async () => { + used = true; + return snapshot; + }; + const value = Reflect.get(b, bp); + return typeof value === "function" ? value.bind(b) : value; + }, + }); + }; + const value = Reflect.get(s, p); + return typeof value === "function" ? value.bind(s) : value; + }, + }); + }; + } + const value = Reflect.get(target, prop, receiver); + return typeof value === "function" ? value.bind(target) : value; + }, + }) as unknown as D1Database; +} + +async function openCase( + assessmentId: string, + deps: ConsoleMutationDeps, + note = "publisher wrote in about this release", +): Promise<{ response: Response; reconsiderationId: string }> { + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", { + assessmentId, + note, + reason: "opening a case", + idempotencyKey: nextKey(), + }), + deps, + ); + if (response.status !== 200) return { response, reconsiderationId: "" }; + const data = await bodyData<{ reconsiderationId: string }>(response.clone()); + return { response, reconsiderationId: data.reconsiderationId }; +} + +describe("reconsideration open", () => { + it("creates the case, its first note, the operational event, and the audit row", async () => { + const { id, uri } = await seedRun("open-basic"); + const { deps } = mutationDeps(); + const { response, reconsiderationId } = await openCase(id, deps); + expect(response.status).toBe(200); + expect(reconsiderationId).toMatch(/^rcn_/); + + const cases = await testEnv.DB.prepare( + `SELECT state, outcome, subject_uri, subject_cid, triggering_assessment_id, opened_by_email, opened_by_role + FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first(); + expect(cases).toMatchObject({ + state: "open", + outcome: null, + subject_uri: uri, + subject_cid: CID, + triggering_assessment_id: id, + opened_by_email: "reviewer@example.com", + opened_by_role: "reviewer", + }); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsideration_notes WHERE reconsideration_id = ?`, + reconsiderationId, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events WHERE event_type = 'reconsideration-opened' AND subject_uri = ?`, + uri, + ), + ).toBe(1); + expect( + await countRows( + `SELECT COUNT(*) n FROM operator_actions WHERE action = 'reconsideration-open' AND subject_uri = ?`, + uri, + ), + ).toBe(1); + }); + + it("409s a second open for a subject that already has an open case", async () => { + const { id } = await seedRun("open-dup"); + const { deps } = mutationDeps(); + const first = await openCase(id, deps); + expect(first.response.status).toBe(200); + + const second = await openCase(id, deps); + expect(second.response.status).toBe(409); + expect((await bodyError(second.response)).code).toBe("RECONSIDERATION_OPEN_EXISTS"); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsiderations WHERE subject_uri = ? AND state = 'open'`, + releaseUri("open-dup"), + ), + ).toBe(1); + }); + + it("404s an unknown assessment id", async () => { + const { deps } = mutationDeps(); + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", { + assessmentId: "asmt_00000000000000000000000000", + note: "no such run", + reason: "opening", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(404); + }); + + it("replays the stored descriptor without a second case", async () => { + const { id } = await seedRun("open-replay"); + const { deps } = mutationDeps(); + const body = { + assessmentId: id, + note: "once", + reason: "opening", + idempotencyKey: nextKey(), + }; + const first = await handleConsoleMutation(post("/admin/api/reconsiderations/open", body), deps); + const firstText = await first.text(); + const second = await handleConsoleMutation( + post("/admin/api/reconsiderations/open", body), + deps, + ); + expect(await second.text()).toBe(firstText); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsiderations WHERE subject_uri = ?`, + releaseUri("open-replay"), + ), + ).toBe(1); + }); +}); + +describe("reconsideration note", () => { + it("appends a note to an open case", async () => { + const { id } = await seedRun("note-open"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "second note", + reason: "adding context", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + expect( + await countRows( + `SELECT COUNT(*) n FROM reconsideration_notes WHERE reconsideration_id = ?`, + reconsiderationId, + ), + ).toBe(2); + }); + + it("404s a note on an unknown case", async () => { + const { deps } = mutationDeps(); + const response = await handleConsoleMutation( + post("/admin/api/reconsiderations/rcn_00000000000000000000000000/note", { + note: "orphan", + reason: "adding", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(404); + }); + + it("allows a note on a resolved case for post-hoc audit", async () => { + const { id } = await seedRun("note-resolved"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + const resolved = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "moot", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(resolved.status).toBe(200); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "post-hoc", + reason: "audit", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + }); +}); + +describe("reconsideration resolve", () => { + it("sets outcome, state, and provenance and fires a notice for granted", async () => { + await seedConfirmedContact(); + const { id, uri } = await seedRun("resolve-granted"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps, PRIVATE_NOTE); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + note: `${PRIVATE_NOTE} — resolving`, + reason: "reviewer granted after rerun", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + + const row = await testEnv.DB.prepare( + `SELECT state, outcome, resolved_by_email, outcome_action_id FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first(); + const descriptor = await bodyData<{ actionId: string }>(response); + expect(row).toMatchObject({ + state: "resolved", + outcome: "granted", + resolved_by_email: "reviewer@example.com", + outcome_action_id: descriptor.actionId, + }); + expect( + await countRows( + `SELECT COUNT(*) n FROM operational_events WHERE event_type = 'reconsideration-resolved' AND action_id = ?`, + descriptor.actionId, + ), + ).toBe(1); + + // The notice went out and its copy contains NONE of the private-note text. + expect(sender.notices).toHaveLength(1); + const notice = sender.notices[0]!; + expect(notice.subject).toBe("Your reconsideration request was granted"); + expect(notice.assessmentUrl).toContain(encodeURIComponent(uri)); + expect(notice.reconsiderationUrl).toBe(RECON_URL); + expect(JSON.stringify(notice)).not.toContain(PRIVATE_NOTE); + }); + + it("fires a notice for denied", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("resolve-denied"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "denied", + reason: "assessment upheld", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was reviewed"); + }); + + it("fires NO notice for withdrawn", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("resolve-withdrawn"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "requester withdrew", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(200); + await settle(); + expect(sender.notices).toHaveLength(0); + expect(sender.confirmations).toHaveLength(0); + }); + + it("409s a double-resolve", async () => { + const { id } = await seedRun("resolve-double"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + + const first = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "denied", + reason: "first resolve", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(first.status).toBe(200); + + const second = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "second resolve", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(second.status).toBe(409); + expect((await bodyError(second)).code).toBe("RECONSIDERATION_RESOLVED"); + }); + + it("rejects an unknown outcome", async () => { + const { id } = await seedRun("resolve-badoutcome"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "maybe", + reason: "bad", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(response.status).toBe(400); + }); + + it("lets a fresh case open after resolution", async () => { + const { id } = await seedRun("resolve-reopen"); + const { deps } = mutationDeps(); + const first = await openCase(id, deps); + await handleConsoleMutation( + post(`/admin/api/reconsiderations/${first.reconsiderationId}/resolve`, { + outcome: "denied", + reason: "upheld", + idempotencyKey: nextKey(), + }), + deps, + ); + const second = await openCase(id, deps); + expect(second.response.status).toBe(200); + expect(second.reconsiderationId).not.toBe(first.reconsiderationId); + }); + + it("does not re-notify when a losing concurrent resolve's key is replayed", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("resolve-concurrent-loser"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + + // The open snapshot both concurrent resolves' pre-checks would have read. + const openSnapshot = await testEnv.DB.prepare(`SELECT * FROM reconsiderations WHERE id = ?`) + .bind(reconsiderationId) + .first(); + expect(openSnapshot?.state).toBe("open"); + + // Winner A resolves granted and notifies. + const winner = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "A wins", + idempotencyKey: nextKey(), + }), + deps, + ); + expect(winner.status).toBe(200); + await settle(); + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was granted"); + + // Loser B: its pre-check sees the stale open snapshot, so it reaches commit; + // its guarded UPDATE no-ops (case already resolved) but its audit row + stored + // descriptor persist with outcome=denied. Fresh path must not notify (it lost). + const loserBody = { outcome: "denied", reason: "B loses", idempotencyKey: nextKey() }; + const loserDeps = mutationDeps({ notify: notifyDeps(sender) }); + const loser = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, loserBody), + { ...loserDeps.deps, db: staleOpenOnce(testEnv.DB, openSnapshot!) }, + ); + expect(loser.status).toBe(200); + await loserDeps.settle(); + const loserDescriptor = await bodyData<{ actionId: string; outcome: string }>(loser); + expect(loserDescriptor.outcome).toBe("denied"); + // The case still records A's win, and no second notice fired. + const caseRow = await testEnv.DB.prepare( + `SELECT outcome, outcome_action_id FROM reconsiderations WHERE id = ?`, + ) + .bind(reconsiderationId) + .first<{ outcome: string; outcome_action_id: string }>(); + expect(caseRow?.outcome).toBe("granted"); + expect(caseRow?.outcome_action_id).not.toBe(loserDescriptor.actionId); + expect(sender.notices).toHaveLength(1); + + // Replaying B's key must NOT fire a second (contradictory 'denied') notice. + const replayDeps = mutationDeps({ notify: notifyDeps(sender) }); + const replay = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, loserBody), + replayDeps.deps, + ); + expect(replay.status).toBe(200); + await replayDeps.settle(); + expect(sender.notices).toHaveLength(1); + expect(sender.notices[0]!.subject).toBe("Your reconsideration request was granted"); + }); +}); + +describe("resolveNoticeForSource sweep parity", () => { + it("re-renders content identical to the live granted notice", async () => { + await seedConfirmedContact(); + const { id } = await seedRun("sweep-parity"); + const sender = recordingSender(); + const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps, PRIVATE_NOTE); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "granted", + reason: "granted", + idempotencyKey: nextKey(), + }), + deps, + ); + await settle(); + const descriptor = await bodyData<{ actionId: string }>(response); + + const rebuilt = await resolveNoticeForSource( + notifyDeps(sender), + "operator", + descriptor.actionId, + ); + expect(rebuilt).not.toBeNull(); + const live = sender.notices[0]!; + expect(rebuilt).toMatchObject({ + subject: live.subject, + publicSummary: live.publicSummary, + effect: live.effect, + assessmentUrl: live.assessmentUrl, + reconsiderationUrl: live.reconsiderationUrl, + }); + expect(JSON.stringify(rebuilt)).not.toContain(PRIVATE_NOTE); + }); + + it("re-renders null for a withdrawn resolve (sweep abandons the row)", async () => { + const { id } = await seedRun("sweep-withdrawn"); + const sender = recordingSender(); + const { deps } = mutationDeps({ notify: notifyDeps(sender) }); + const { reconsiderationId } = await openCase(id, deps); + const response = await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/resolve`, { + outcome: "withdrawn", + reason: "moot", + idempotencyKey: nextKey(), + }), + deps, + ); + const descriptor = await bodyData<{ actionId: string }>(response); + expect( + await resolveNoticeForSource(notifyDeps(sender), "operator", descriptor.actionId), + ).toBeNull(); + }); +}); + +describe("reconsideration read API", () => { + it("lists cases newest-first and returns a case with its note thread", async () => { + const { id } = await seedRun("read-case"); + const { deps } = mutationDeps(); + const { reconsiderationId } = await openCase(id, deps, "first note"); + await handleConsoleMutation( + post(`/admin/api/reconsiderations/${reconsiderationId}/note`, { + note: "second note", + reason: "context", + idempotencyKey: nextKey(), + }), + deps, + ); + + const list = await handleConsoleApi(getReq("/admin/api/reconsiderations"), readDeps()); + expect(list.status).toBe(200); + const listBody = await bodyData<{ items: { id: string; state: string }[] }>(list); + expect(listBody.items.some((c) => c.id === reconsiderationId && c.state === "open")).toBe(true); + + const detail = await handleConsoleApi( + getReq(`/admin/api/reconsiderations/${reconsiderationId}`), + readDeps(), + ); + expect(detail.status).toBe(200); + const detailBody = await bodyData<{ + reconsideration: { id: string; state: string }; + notes: { note: string }[]; + }>(detail); + expect(detailBody.reconsideration.id).toBe(reconsiderationId); + expect(detailBody.notes.map((n) => n.note)).toEqual(["first note", "second note"]); + }); + + it("404s an unknown case", async () => { + const response = await handleConsoleApi( + getReq("/admin/api/reconsiderations/rcn_00000000000000000000000000"), + readDeps(), + ); + expect(response.status).toBe(404); + }); +}); From 83232c904bd5fbb34423d62bbf14f8a347b66837 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 06:35:05 +0100 Subject: [PATCH 2/3] feat(labeler): gate reconsideration resolved-event on the race winner A losing concurrent resolve no-ops its state='open'-guarded UPDATE but its audit row still commits (needed for idempotency replay). Gate the reconsideration-resolved operational_event on the case's outcome_action_id being this action's, via a new gateOnResolvedReconsideration in-batch gate mirroring gateOnIssuedLabelActionKey, so the loser emits no phantom event claiming an outcome the case did not take. The loser still echoes its requested outcome in its own 200 descriptor; case state, event, and notice reflect only the winner. Addresses the emdashbot review tradeoff on #2083. Co-Authored-By: Claude Opus 4.8 --- apps/labeler/src/console-mutation-api.ts | 6 +++++ apps/labeler/src/operational-events.ts | 24 +++++++++++++++++++ .../test/console-reconsiderations.test.ts | 11 ++++++++- 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/labeler/src/console-mutation-api.ts b/apps/labeler/src/console-mutation-api.ts index f6ebb5e0e..088c7b425 100644 --- a/apps/labeler/src/console-mutation-api.ts +++ b/apps/labeler/src/console-mutation-api.ts @@ -1712,6 +1712,11 @@ async function runReconsiderationResolve( }), ); } + // The event is gated on this action winning the `state = 'open'` UPDATE, so a + // losing concurrent resolve (distinct idempotency key) commits its audit row but + // emits no phantom resolved-event. That loser still echoes its own requested + // outcome in its 200 descriptor; the authoritative case state, the resolved + // event, and the outcome notice all reflect only the race winner. statements.push( buildOperationalEventInsert(deps.db, { id: newOperationalEventId(), @@ -1721,6 +1726,7 @@ async function runReconsiderationResolve( subjectUri: existing.subjectUri, payload: { reason: ctx.reason }, now: ctx.now, + gateOnResolvedReconsideration: { reconsiderationId: id, actionId: ctx.actionId }, }), ); diff --git a/apps/labeler/src/operational-events.ts b/apps/labeler/src/operational-events.ts index 090437478..5f40783ab 100644 --- a/apps/labeler/src/operational-events.ts +++ b/apps/labeler/src/operational-events.ts @@ -55,6 +55,14 @@ export interface OperationalEventInsert { * `issued_labels` row) inserts neither the event nor its outbox row. */ gateOnIssuedLabelActionKey?: string; + /** + * Gates the event on this reconsideration having been resolved by this action: + * `EXISTS (SELECT 1 FROM reconsiderations WHERE id = ? AND outcome_action_id = ?)`. + * Batched after the resolve UPDATE (which guards on `state = 'open'`), so a + * losing concurrent resolve — whose UPDATE matched zero rows — inserts no + * phantom resolved-event while its audit row still commits for replay. + */ + gateOnResolvedReconsideration?: { reconsiderationId: string; actionId: string }; } export interface OutboxInsert { @@ -162,6 +170,22 @@ export function buildOperationalEventInsert( .bind(...values, input.gateOnIssuedLabelActionKey); } + if (input.gateOnResolvedReconsideration !== undefined) { + return db + .prepare( + `INSERT INTO operational_events (${columns}) + SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? + WHERE EXISTS ( + SELECT 1 FROM reconsiderations WHERE id = ? AND outcome_action_id = ? + )`, + ) + .bind( + ...values, + input.gateOnResolvedReconsideration.reconsiderationId, + input.gateOnResolvedReconsideration.actionId, + ); + } + if (input.gateOnIssuedLabelActionId !== undefined) { return db .prepare( diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts index 60cda96bd..fe6243d88 100644 --- a/apps/labeler/test/console-reconsiderations.test.ts +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -629,7 +629,7 @@ describe("reconsideration resolve", () => { it("does not re-notify when a losing concurrent resolve's key is replayed", async () => { await seedConfirmedContact(); - const { id } = await seedRun("resolve-concurrent-loser"); + const { id, uri } = await seedRun("resolve-concurrent-loser"); const sender = recordingSender(); const { deps, settle } = mutationDeps({ notify: notifyDeps(sender) }); const { reconsiderationId } = await openCase(id, deps); @@ -651,6 +651,7 @@ describe("reconsideration resolve", () => { ); expect(winner.status).toBe(200); await settle(); + const winnerActionId = (await bodyData<{ actionId: string }>(winner)).actionId; expect(sender.notices).toHaveLength(1); expect(sender.notices[0]!.subject).toBe("Your reconsideration request was granted"); @@ -676,6 +677,14 @@ describe("reconsideration resolve", () => { expect(caseRow?.outcome).toBe("granted"); expect(caseRow?.outcome_action_id).not.toBe(loserDescriptor.actionId); expect(sender.notices).toHaveLength(1); + // Exactly one resolved-event, the winner's — the loser's is gated out. + const events = await testEnv.DB.prepare( + `SELECT action_id FROM operational_events WHERE event_type = 'reconsideration-resolved' AND subject_uri = ?`, + ) + .bind(uri) + .all<{ action_id: string }>(); + expect(events.results).toHaveLength(1); + expect(events.results![0]!.action_id).toBe(winnerActionId); // Replaying B's key must NOT fire a second (contradictory 'denied') notice. const replayDeps = mutationDeps({ notify: notifyDeps(sender) }); From 2ffd8268b7dd30a80ef3ce20d2e9fc98b567f073 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 17 Jul 2026 06:48:38 +0100 Subject: [PATCH 3/3] feat(labeler): fully enforce reconsideration resolve-provenance in the CHECK The table CHECK described a resolved row as carrying full provenance but only enforced outcome + resolved_at NOT NULL, leaving outcome_action_id, resolved_by_id, and resolved_at_epoch_ms to app code alone. Extend both branches so the schema enforces the whole lifecycle invariant: an open row carries zero resolve provenance; a resolved row carries all of it. Tests assert the CHECK rejects a partial-resolved row and an open row with stray provenance. Addresses the emdashbot re-review schema-defensiveness suggestion on #2083. Co-Authored-By: Claude Opus 4.8 --- .../migrations/0009_reconsiderations.sql | 7 +++- .../test/console-reconsiderations.test.ts | 37 +++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/apps/labeler/migrations/0009_reconsiderations.sql b/apps/labeler/migrations/0009_reconsiderations.sql index 86e455b66..eebc808dd 100644 --- a/apps/labeler/migrations/0009_reconsiderations.sql +++ b/apps/labeler/migrations/0009_reconsiderations.sql @@ -37,8 +37,11 @@ CREATE TABLE reconsiderations ( resolved_at_epoch_ms INTEGER, outcome_action_id TEXT REFERENCES operator_actions(id), CHECK ( - (state = 'open' AND outcome IS NULL AND resolved_at IS NULL AND outcome_action_id IS NULL) - OR (state = 'resolved' AND outcome IS NOT NULL AND resolved_at IS NOT NULL) + (state = 'open' AND outcome IS NULL AND resolved_at IS NULL AND resolved_at_epoch_ms IS NULL + AND resolved_by_id IS NULL AND outcome_action_id IS NULL) + OR (state = 'resolved' AND outcome IS NOT NULL AND resolved_at IS NOT NULL + AND resolved_at_epoch_ms IS NOT NULL AND resolved_by_id IS NOT NULL + AND outcome_action_id IS NOT NULL) ) ); diff --git a/apps/labeler/test/console-reconsiderations.test.ts b/apps/labeler/test/console-reconsiderations.test.ts index fe6243d88..354838e27 100644 --- a/apps/labeler/test/console-reconsiderations.test.ts +++ b/apps/labeler/test/console-reconsiderations.test.ts @@ -794,3 +794,40 @@ describe("reconsideration read API", () => { expect(response.status).toBe(404); }); }); + +describe("reconsideration schema invariants", () => { + function insertCase(id: string, uri: string, triggeringId: string, columns: string, values: string) { + return testEnv.DB.prepare( + `INSERT INTO reconsiderations + (id, subject_uri, subject_cid, triggering_assessment_id, + opened_by_id, opened_by_role, opened_at, opened_at_epoch_ms, ${columns}) + VALUES (?, ?, ?, ?, 'op', 'reviewer', '2026-07-16T00:00:00.000Z', 1, ${values})`, + ).bind(id, uri, CID, triggeringId); + } + + it("rejects a resolved row missing its resolve provenance", async () => { + const { id: triggeringId, uri } = await seedRun("schema-partial-resolved"); + await expect( + insertCase( + "rcn_schema_partial_res", + uri, + triggeringId, + "state, outcome, resolved_at, resolved_at_epoch_ms, resolved_by_id", + "'resolved', 'granted', '2026-07-16T01:00:00.000Z', 2, 'op'", + ).run(), + ).rejects.toThrow(); + }); + + it("rejects an open row carrying resolve provenance", async () => { + const { id: triggeringId, uri } = await seedRun("schema-open-provenance"); + await expect( + insertCase( + "rcn_schema_open_prov", + uri, + triggeringId, + "state, resolved_by_id", + "'open', 'op'", + ).run(), + ).rejects.toThrow(); + }); +});