Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/labeler/migrations/0009_reconsiderations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
-- 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 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)
)
Comment on lines +39 to +45

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[suggestion] The migration comment says this CHECK binds state ↔ outcome ↔ resolved-provenance, but the resolved branch only enforces outcome and resolved_at. It allows a resolved row where outcome_action_id is NULL, which would break resolveWon (it tests current?.outcomeActionId === actionId) and would leave no winner reference for the operational event/notice pipeline. Since each resolving path always sets outcome_action_id to the operator action id, the schema can safely require it for resolved rows.

Suggested change
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)
)
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 AND outcome_action_id 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);
53 changes: 53 additions & 0 deletions apps/labeler/src/console-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ import {
serializeOperatorActionView,
serializeOperatorFinding,
serializePublisherHistory,
serializeReconsideration,
serializeReconsiderationNote,
serializeSubjectLabel,
serializeSubjectRecord,
type Page,
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<Response> {
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<ReturnType<typeof serializeReconsideration>> = {
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<Response> {
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<Response> {
requireGet(request);
const [pendingAssessments, deadLetterDepth, automation, jetstreamConnected] = await Promise.all([
Expand Down
Loading
Loading