From c77c03c49203e118dcf2cbec036205a265621672 Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Sat, 4 Apr 2026 02:53:12 -0500 Subject: [PATCH 1/4] docs(phase-28): define checklist item sign-off and sign-off note paths --- ...klist-item-sign-offs-and-sign-off-notes.md | 112 +++++++++++++++ .../audit-catalog-checklist-item-sign-offs.md | 133 ++++++++++++++++++ docs/roadmap.md | 2 +- 3 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr-0028-cross-run-audit-catalog-checklist-item-sign-offs-and-sign-off-notes.md create mode 100644 docs/guides/audit-catalog-checklist-item-sign-offs.md diff --git a/docs/architecture/adr-0028-cross-run-audit-catalog-checklist-item-sign-offs-and-sign-off-notes.md b/docs/architecture/adr-0028-cross-run-audit-catalog-checklist-item-sign-offs-and-sign-off-notes.md new file mode 100644 index 0000000..242c1e4 --- /dev/null +++ b/docs/architecture/adr-0028-cross-run-audit-catalog-checklist-item-sign-offs-and-sign-off-notes.md @@ -0,0 +1,112 @@ +# ADR-0028: Cross-Run Audit Catalog Checklist Item Sign-Offs and Sign-Off Notes + +Date: 2026-04-04 + +## Status + +Accepted on branch for Phase 28 implementation. + +## Context + +Phase 27 added a thin checklist-item-acknowledgment layer over attested +evidenced verified resolved blocked progressed assigned reviewed audit catalog +entries. Operators could record that the current attestation and supporting +evidence set had been acknowledged, but there was still no package-owned way +to capture that a downstream operator had signed off the current +acknowledgment, attestation, and evidence set without expanding into approval +gating, workflow orchestration, artifact-vault behavior, attachment-upload +products, provider-payload persistence, copied artifact persistence, or a +broader collaboration system. + +The next repository-owned gap is still narrower than threaded collaboration, +broader checklist orchestration, broader review workflow engines, +fine-grained RBAC, multi-tenant access, dashboards, search, analytics, +approval products, or attachment-upload products. Runroot needs a stable +checklist-item-signoff layer that remains derived over the existing +checklist-item-acknowledgment, checklist-item-attestation, +checklist-item-evidence, checklist-item-verification, +checklist-item-resolution, checklist-item-blocker, +checklist-item-progress, assignment-checklist, review-assignment, +review-signal, visibility, catalog, and saved-view seams. + +## Decision + +Runroot adds a shared audit-catalog checklist-item-signoff contract +with the following properties: + +- checklist item sign-offs remain additive operator metadata +- checklist item sign-offs reference existing catalog entries, + checklist-item-acknowledgments, checklist-item-attestations, + checklist-item-evidence, checklist-item-verifications, + checklist-item-resolutions, checklist-item-blockers, + checklist-item-progress, assignment checklists, review assignments, and + review signals +- checklist item sign-offs store only stable per-item sign-off state, a thin + optional sign-off note, minimal actor and scope references, and existing + catalog refs +- checklist item sign-offs do not snapshot audit facts, provider + payloads, copied artifacts, or workflow state +- checklist item sign-offs do not change replay or approval source of + truth +- applying a signed-off preset reuses the existing catalog-apply path + +The shared sign-off seam is exposed through: + +- replay query helpers in `@runroot/replay` +- persistence adapters in `@runroot/persistence` +- operator methods in `@runroot/sdk` +- thin HTTP routes in `apps/api` +- thin command routing in `@runroot/cli` +- minimal runs-page presentation in `apps/web` + +Sign-Off visibility stays minimal: + +- list and inspect are scoped to operators already involved in the + acknowledgment, attestation, evidence, verification, resolution, blocker, + progress, and assignment handoff path +- sign-off entries can only reference checklist items that already exist in + the shared checklist-item-acknowledgment layer +- sign-off state stays as a thin per-item enum +- sign-off notes stay as a single thin string +- no threaded comments, checklist workflow engine, approval-gating product, + permission framework, organization directory, multi-tenant surface, + attachment-upload product, or artifact vault is added + +## Consequences + +### Positive + +- attested evidenced verified resolved blocked progressed assigned reviewed + presets can carry thin per-item sign-off state and a single sign-off note + through shared package-owned seams +- inline and queued runs reuse the same checklist-item-signoff contract +- operator surfaces stay thin and reuse the existing catalog apply behavior +- replay and approval semantics remain unchanged + +### Negative + +- checklist item sign-offs are still a derived layer that depends on + checklist-item acknowledgment, checklist-item attestation, + checklist-item evidence, checklist-item verifications, + checklist-item resolutions, checklist-item blockers, + checklist-item progress, assignment checklists, assignments, review + signals, visibility, and catalog integrity +- sign-offs intentionally stay shallow and do not solve approval + products, workflow gating, broader checklist orchestration, threaded + collaboration, payload persistence, binary artifact persistence, RBAC, or + multi-tenant requirements + +## Non-Goals + +This ADR does not introduce: + +- threaded comments +- broader review workflow engines +- broader checklist orchestration +- sign-off-driven approval gating +- attachment-upload or artifact-vault products +- fine-grained RBAC +- multi-tenant access control +- dashboard, search, or analytics products +- broader collaboration beyond thin operator-facing sign-off metadata +- provider payload, copied artifact, or full snapshot persistence diff --git a/docs/guides/audit-catalog-checklist-item-sign-offs.md b/docs/guides/audit-catalog-checklist-item-sign-offs.md new file mode 100644 index 0000000..0c497bb --- /dev/null +++ b/docs/guides/audit-catalog-checklist-item-sign-offs.md @@ -0,0 +1,133 @@ +# Audit Catalog Checklist Item Sign-Offs + +Phase 28 adds a thin shared checklist-item-signoff layer over acknowledged +attested evidenced verified resolved blocked progressed assigned reviewed audit +catalog entries that already carry checklist-item-acknowledgment metadata. + +## What Checklist Item Sign-Offs Record + +The shared contract stores: + +- a reference to an existing acknowledged attested evidenced verified resolved + blocked progressed assigned reviewed catalog entry +- per-item sign-off state for checklist items that already exist in the shared + checklist-item-acknowledgment layer +- minimal operator and scope references used by the current operator seam +- an optional thin sign-off note + +The contract does not store: + +- provider-specific payloads +- copied artifacts +- workflow-state snapshots +- replay or approval state +- threaded comments, broader review workflow engines, or broader checklist + orchestration +- fine-grained RBAC or multi-tenant access rules +- surface-specific route formats +- approval-gating product state +- attachment-upload or artifact-vault product state + +Checklist item sign-offs remain derived operator state. They do not replace +replay, approval, saved views, catalog entries, visibility, review signals, +review assignments, assignment checklists, checklist-item progress, +checklist-item blockers, checklist-item resolutions, checklist-item +verifications, checklist-item evidence, checklist-item attestations, or +checklist-item acknowledgments. + +## Sign-Off, List-Signed-Off, Inspect-Sign-Off, Clear-Sign-Off, And Apply + +The minimum sign-off path is available through the existing seams: + +- SDK: + - `signOffCatalogEntry(id, ...)` + - `listSignedOffCatalogEntries()` + - `getCatalogChecklistItemSignoff(id)` + - `clearCatalogChecklistItemSignoff(id)` + - `applyCatalogEntry(id)` +- API: + - `POST /audit/catalog/:catalogEntryId/sign-off` + - `GET /audit/catalog/signed-off` + - `GET /audit/catalog/:catalogEntryId/sign-off` + - `POST /audit/catalog/:catalogEntryId/sign-off/clear` + - `GET /audit/catalog/:catalogEntryId/apply` +- CLI: + - `audit catalog sign-off` + - `audit catalog signed-off` + - `audit catalog inspect-sign-off` + - `audit catalog clear-sign-off` + - `audit catalog apply` +- Web: + - the runs page presents a thin checklist-item-signoff panel and a minimal + sign-off-note form over the existing catalog, visibility, review-signal, + review-assignment, assignment-checklist, checklist-item-progress, + checklist-item-blocker, checklist-item-resolution, + checklist-item-verification, checklist-item-evidence, + checklist-item-attestation, and checklist-item-acknowledgment surfaces + +## What Applying A Signed-Off Preset Does + +Applying a signed-off preset does not replay a run, reconstruct workflow +state, or change approval semantics. + +It only: + +- resolves the visible catalog entry for the current operator identity +- resolves additive review, assignment, checklist, progress, blocker, + resolution, verification, evidence, attestation, acknowledgment, and + sign-off metadata for that entry +- resolves the referenced saved view and constrained navigation metadata +- reuses the existing catalog-apply and audit-navigation seams +- returns the current navigation state for that signed-off acknowledged preset + +Replay and approval semantics still come only from persisted runtime and +approval events. + +## Local Development + +Set a minimal operator identity for the current process: + +```bash +$env:RUNROOT_OPERATOR_ID="ops_oncall" +$env:RUNROOT_OPERATOR_SCOPE="ops" +``` + +Create and record sign-offs on an acknowledged attested evidenced verified +resolved blocked progressed assigned reviewed shared preset: + +```bash +pnpm dev:queued +pnpm --filter @runroot/cli dev audit saved-views save --name "queued worker" --execution-mode queued --worker-id worker_1 +pnpm --filter @runroot/cli dev audit catalog publish saved_view_1 --name "Queued preset" +pnpm --filter @runroot/cli dev audit catalog share catalog_entry_1 +pnpm --filter @runroot/cli dev audit catalog review catalog_entry_1 --state recommended --note "Ready for sign-off" +pnpm --filter @runroot/cli dev audit catalog assign catalog_entry_1 --assignee ops_backup --handoff-note "Take the overnight follow-up" +pnpm --filter @runroot/cli dev audit catalog checklist catalog_entry_1 --status pending --items-json "[\"Validate worker state\",\"Confirm saved drilldown\"]" +pnpm --filter @runroot/cli dev audit catalog progress catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"completed\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"pending\"}]" +pnpm --filter @runroot/cli dev audit catalog block catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"cleared\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"blocked\"}]" --blocker-note "Waiting for overnight handoff" +pnpm --filter @runroot/cli dev audit catalog resolve catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"resolved\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"unresolved\"}]" --resolution-note "Backup confirmed the closeout" +pnpm --filter @runroot/cli dev audit catalog verify catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"verified\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"unverified\"}]" --verification-note "Owner verified the closeout" +pnpm --filter @runroot/cli dev audit catalog record-evidence catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"references\":[\"run://queued-worker/step/7\",\"note://backup-closeout\"]},{\"item\":\"Confirm saved drilldown\",\"references\":[\"doc://saved-drilldown\"]}]" --evidence-note "Thin evidence references only" +pnpm --filter @runroot/cli dev audit catalog attest catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"attested\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"unattested\"}]" --attestation-note "Owner attested the stable evidence references" +pnpm --filter @runroot/cli dev audit catalog acknowledge catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"acknowledged\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"unacknowledged\"}]" --acknowledgment-note "Backup acknowledged the attested evidence set" +pnpm --filter @runroot/cli dev audit catalog sign-off catalog_entry_1 --items-json "[{\"item\":\"Validate worker state\",\"state\":\"signed-off\"},{\"item\":\"Confirm saved drilldown\",\"state\":\"unsigned\"}]" --signoff-note "Backup signed off the acknowledged evidence set" +pnpm --filter @runroot/cli dev audit catalog signed-off +pnpm --filter @runroot/cli dev audit catalog apply catalog_entry_1 +pnpm --filter @runroot/cli dev audit catalog clear-sign-off catalog_entry_1 +``` + +Both inline-originated and queued-originated presets reuse the same +checklist-item-signoff contract through the configured persistence adapter. + +## What Stays Deferred + +Still out of scope after Phase 28: + +- productized dashboards, discovery products, or broad analytics UX +- open-ended search products +- fine-grained RBAC, org or team management, and multi-tenant access models +- threaded comments, broader checklist orchestration, broader review + workflows, or broader multi-user curation +- full observability backend integrations +- provider payload persistence, copied artifact persistence, approval-gating + products, attachment-upload, or artifact-vault products diff --git a/docs/roadmap.md b/docs/roadmap.md index 42394db..a543ce8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -449,4 +449,4 @@ Status: completed broader multi-user curation, approval products, workflow gating, or broad observability and analytics platform work -Status: scope frozen +Status: completed From a28440f2f5791ab0d4e9184de45dc38d7306038b Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Sat, 4 Apr 2026 02:53:19 -0500 Subject: [PATCH 2/4] feat(phase-28): add catalog checklist item signoff contract --- .../catalog-checklist-item-signoff-store.ts | 865 ++++++++++++++++++ packages/persistence/src/index.ts | 13 + packages/persistence/src/migrations.ts | 37 + packages/replay/src/checklist-item-signoff.ts | 181 ++++ packages/replay/src/index.ts | 16 + packages/replay/src/query.ts | 239 +++++ 6 files changed, 1351 insertions(+) create mode 100644 packages/persistence/src/catalog-checklist-item-signoff-store.ts create mode 100644 packages/replay/src/checklist-item-signoff.ts diff --git a/packages/persistence/src/catalog-checklist-item-signoff-store.ts b/packages/persistence/src/catalog-checklist-item-signoff-store.ts new file mode 100644 index 0000000..68b4543 --- /dev/null +++ b/packages/persistence/src/catalog-checklist-item-signoff-store.ts @@ -0,0 +1,865 @@ +import { mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join, parse, resolve } from "node:path"; + +import { + type ResolvePersistenceConfigOptions, + resolvePersistenceConfig, +} from "@runroot/config"; +import type { + CrossRunAuditCatalogChecklistItemSignoff, + CrossRunAuditCatalogChecklistItemSignoffStore, +} from "@runroot/replay"; +import { compareCrossRunAuditCatalogChecklistItemSignoff } from "@runroot/replay"; +import type { Pool, PoolClient } from "pg"; +import type { + BindParams, + Database as SqliteDatabase, + SqlJsStatic, +} from "sql.js"; +import initSqlJs from "sql.js/dist/sql-asm.js"; + +import { + migratePostgresPersistence, + migrateSqlitePersistence, + type PostgresRuntimePersistenceOptions, + type SqliteRuntimePersistenceOptions, +} from "./database-store"; + +type SqlPrimitive = number | string | null; +type SqlQueryRow = Readonly>; + +interface SqlClient { + readonly dialect: "postgres" | "sqlite"; + execute(sql: string, params?: readonly SqlPrimitive[]): Promise; + queryRows( + sql: string, + params?: readonly SqlPrimitive[], + ): Promise; +} + +type PostgresPoolLike = Pick; + +export interface InMemoryAuditCatalogChecklistItemSignoffStoreOptions { + readonly signoffEntries?: readonly CrossRunAuditCatalogChecklistItemSignoff[]; +} + +export interface FileAuditCatalogChecklistItemSignoffStoreOptions { + readonly filePath: string; + readonly lockRetryDelayMs?: number; + readonly lockTimeoutMs?: number; +} + +export interface PostgresAuditCatalogChecklistItemSignoffStoreOptions + extends Pick {} + +export interface SqliteAuditCatalogChecklistItemSignoffStoreOptions + extends Pick< + SqliteRuntimePersistenceOptions, + "filePath" | "lockRetryDelayMs" | "lockTimeoutMs" + > {} + +export interface ConfiguredAuditCatalogChecklistItemSignoffStoreOptions + extends ResolvePersistenceConfigOptions { + readonly filePath?: string; + readonly lockRetryDelayMs?: number; + readonly lockTimeoutMs?: number; + readonly pool?: PostgresPoolLike; +} + +interface AuditCatalogChecklistItemSignoffSnapshot { + readonly signoffEntries: readonly CrossRunAuditCatalogChecklistItemSignoff[]; +} + +let sqliteModulePromise: Promise | undefined; + +export function createConfiguredAuditCatalogChecklistItemSignoffStore( + options: ConfiguredAuditCatalogChecklistItemSignoffStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemSignoffStore { + const resolved = resolvePersistenceConfig(options); + + switch (resolved.driver) { + case "file": + return createFileAuditCatalogChecklistItemSignoffStore({ + filePath: + options.filePath ?? + resolveAuditCatalogChecklistItemSignoffFilePath( + resolved.workspacePath ?? resolved.location, + ), + ...(options.lockRetryDelayMs !== undefined + ? { lockRetryDelayMs: options.lockRetryDelayMs } + : {}), + ...(options.lockTimeoutMs !== undefined + ? { lockTimeoutMs: options.lockTimeoutMs } + : {}), + }); + case "postgres": + return createPostgresAuditCatalogChecklistItemSignoffStore({ + ...(resolved.databaseUrl ? { databaseUrl: resolved.databaseUrl } : {}), + ...(options.pool ? { pool: options.pool } : {}), + }); + case "sqlite": + return createSqliteAuditCatalogChecklistItemSignoffStore({ + filePath: resolved.sqlitePath ?? resolved.location, + ...(options.lockRetryDelayMs !== undefined + ? { lockRetryDelayMs: options.lockRetryDelayMs } + : {}), + ...(options.lockTimeoutMs !== undefined + ? { lockTimeoutMs: options.lockTimeoutMs } + : {}), + }); + } +} + +export function createInMemoryAuditCatalogChecklistItemSignoffStore( + options: InMemoryAuditCatalogChecklistItemSignoffStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemSignoffStore { + const signoffEntries = [...(options.signoffEntries ?? [])].map((entry) => + clone(entry), + ); + + return { + async deleteCatalogChecklistItemSignoff(catalogEntryId) { + const existingIndex = signoffEntries.findIndex( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + if (existingIndex < 0) { + return undefined; + } + + const [deletedEntry] = signoffEntries.splice(existingIndex, 1); + + return deletedEntry ? clone(deletedEntry) : undefined; + }, + + async getCatalogChecklistItemSignoff(catalogEntryId) { + const signoff = signoffEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + return signoff ? clone(signoff) : undefined; + }, + + async listCatalogChecklistItemSignoffs() { + return signoffEntries + .slice() + .sort(compareCrossRunAuditCatalogChecklistItemSignoff) + .map((entry) => clone(entry)); + }, + + async saveCatalogChecklistItemSignoff(entry) { + const existingIndex = signoffEntries.findIndex( + (candidate) => candidate.catalogEntryId === entry.catalogEntryId, + ); + + if (existingIndex >= 0) { + signoffEntries.splice(existingIndex, 1); + } + + signoffEntries.push(clone(entry)); + signoffEntries.sort(compareCrossRunAuditCatalogChecklistItemSignoff); + + return clone(entry); + }, + }; +} + +export function createFileAuditCatalogChecklistItemSignoffStore( + options: FileAuditCatalogChecklistItemSignoffStoreOptions, +): CrossRunAuditCatalogChecklistItemSignoffStore { + const filePath = resolve(options.filePath); + let accessQueue = Promise.resolve(); + + return { + async deleteCatalogChecklistItemSignoff(catalogEntryId) { + return enqueueAccess(async () => + withMutableSnapshot(filePath, options, async (snapshot) => { + const existingEntry = snapshot.signoffEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + if (!existingEntry) { + return undefined; + } + + await writeAuditCatalogChecklistItemSignoffSnapshot(filePath, { + signoffEntries: snapshot.signoffEntries.filter( + (entry) => entry.catalogEntryId !== catalogEntryId, + ), + }); + + return clone(existingEntry); + }), + ); + }, + + async getCatalogChecklistItemSignoff(catalogEntryId) { + return enqueueAccess(async () => + withReadOnlySnapshot(filePath, (snapshot) => { + const signoff = snapshot.signoffEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + return signoff ? clone(signoff) : undefined; + }), + ); + }, + + async listCatalogChecklistItemSignoffs() { + return enqueueAccess(async () => + withReadOnlySnapshot(filePath, (snapshot) => + snapshot.signoffEntries + .slice() + .sort(compareCrossRunAuditCatalogChecklistItemSignoff) + .map((entry) => clone(entry)), + ), + ); + }, + + async saveCatalogChecklistItemSignoff(entry) { + return enqueueAccess(async () => + withMutableSnapshot(filePath, options, async (snapshot) => { + const nextSignoffEntries = [ + ...snapshot.signoffEntries.filter( + (candidate) => candidate.catalogEntryId !== entry.catalogEntryId, + ), + clone(entry), + ].sort(compareCrossRunAuditCatalogChecklistItemSignoff); + + await writeAuditCatalogChecklistItemSignoffSnapshot(filePath, { + signoffEntries: nextSignoffEntries, + }); + + return clone(entry); + }), + ); + }, + }; + + async function enqueueAccess( + accessOperation: () => Promise, + ): Promise { + const pendingAccess = accessQueue.then(accessOperation, accessOperation); + accessQueue = pendingAccess.then( + () => undefined, + () => undefined, + ); + + return pendingAccess; + } +} + +export function createPostgresAuditCatalogChecklistItemSignoffStore( + options: PostgresAuditCatalogChecklistItemSignoffStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemSignoffStore { + const pool = options.pool ?? createDefaultPool(options.databaseUrl); + let schemaReadyPromise: Promise | undefined; + + return createDatabaseAuditCatalogChecklistItemSignoffStore({ + ensureSchema() { + schemaReadyPromise ??= migratePostgresPersistence({ + ...(options.databaseUrl ? { databaseUrl: options.databaseUrl } : {}), + ...(options.pool ? { pool: options.pool } : {}), + }).then(() => undefined); + + return schemaReadyPromise; + }, + withReadOnlyClient(task) { + return withPostgresClient(pool, task); + }, + withTransaction(task) { + return withPostgresTransaction(pool, task); + }, + }); +} + +export function createSqliteAuditCatalogChecklistItemSignoffStore( + options: SqliteAuditCatalogChecklistItemSignoffStoreOptions, +): CrossRunAuditCatalogChecklistItemSignoffStore { + const filePath = resolve(options.filePath); + let accessQueue = Promise.resolve(); + let schemaReadyPromise: Promise | undefined; + + return createDatabaseAuditCatalogChecklistItemSignoffStore({ + ensureSchema() { + schemaReadyPromise ??= migrateSqlitePersistence({ + filePath, + }).then(() => undefined); + + return schemaReadyPromise; + }, + withReadOnlyClient(task) { + return enqueueAccess(async () => + withSqliteClient( + { + filePath, + mutable: false, + }, + task, + ), + ); + }, + withTransaction(task) { + return enqueueAccess(async () => + withFileLock(filePath, options, async () => + withSqliteClient( + { + filePath, + mutable: true, + }, + task, + ), + ), + ); + }, + }); + + async function enqueueAccess( + action: () => Promise, + ): Promise { + const pendingAccess = accessQueue.then(action, action); + accessQueue = pendingAccess.then( + () => undefined, + () => undefined, + ); + + return pendingAccess; + } +} + +function createDatabaseAuditCatalogChecklistItemSignoffStore(options: { + readonly ensureSchema: () => Promise; + readonly withReadOnlyClient: ( + task: (client: SqlClient) => Promise, + ) => Promise; + readonly withTransaction: ( + task: (client: SqlClient) => Promise, + ) => Promise; +}): CrossRunAuditCatalogChecklistItemSignoffStore { + return { + async deleteCatalogChecklistItemSignoff(catalogEntryId) { + await options.ensureSchema(); + + return options.withTransaction(async (client) => { + const existingRows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_signoff + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + if (!existingRows[0]) { + return undefined; + } + + await client.execute( + `DELETE FROM runroot_audit_catalog_checklist_item_signoff + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + return deserializeRow( + existingRows[0].data, + ); + }); + }, + + async getCatalogChecklistItemSignoff(catalogEntryId) { + await options.ensureSchema(); + + return options.withReadOnlyClient(async (client) => { + const rows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_signoff + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + return rows[0] + ? deserializeRow( + rows[0].data, + ) + : undefined; + }); + }, + + async listCatalogChecklistItemSignoffs() { + await options.ensureSchema(); + + return options.withReadOnlyClient(async (client) => { + const rows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_signoff`, + ); + + return rows + .map((row) => + deserializeRow(row.data), + ) + .sort(compareCrossRunAuditCatalogChecklistItemSignoff); + }); + }, + + async saveCatalogChecklistItemSignoff(entry) { + await options.ensureSchema(); + + return options.withTransaction(async (client) => { + await client.execute( + `INSERT INTO runroot_audit_catalog_checklist_item_signoff ( + catalog_entry_id, + kind, + operator_id, + scope_id, + signoff_note, + signoff_items, + created_at, + updated_at, + data + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (catalog_entry_id) DO UPDATE SET + kind = excluded.kind, + operator_id = excluded.operator_id, + scope_id = excluded.scope_id, + signoff_note = excluded.signoff_note, + signoff_items = excluded.signoff_items, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + data = excluded.data`, + [ + entry.catalogEntryId, + entry.kind, + entry.operatorId, + entry.scopeId, + entry.signoffNote ?? null, + JSON.stringify(entry.items), + entry.createdAt, + entry.updatedAt, + serializeRow(entry), + ], + ); + + return clone(entry); + }); + }, + }; +} + +export function resolveAuditCatalogChecklistItemSignoffFilePath( + workspacePath: string, +): string { + const resolvedPath = resolve(workspacePath); + const parsedPath = parse(resolvedPath); + + return join( + parsedPath.dir, + `${parsedPath.name}.audit-catalog-checklist-item-signoff.json`, + ); +} + +async function withReadOnlySnapshot( + filePath: string, + action: ( + snapshot: AuditCatalogChecklistItemSignoffSnapshot, + ) => TValue | Promise, +): Promise { + const snapshot = await readAuditCatalogChecklistItemSignoffSnapshot(filePath); + + return action(snapshot); +} + +async function withMutableSnapshot( + filePath: string, + options: Pick< + FileAuditCatalogChecklistItemSignoffStoreOptions, + "lockRetryDelayMs" | "lockTimeoutMs" + >, + action: ( + snapshot: AuditCatalogChecklistItemSignoffSnapshot, + ) => Promise, +): Promise { + await ensureParentDirectory(filePath); + + return withFileLock(filePath, options, async () => + action(await readAuditCatalogChecklistItemSignoffSnapshot(filePath)), + ); +} + +async function readAuditCatalogChecklistItemSignoffSnapshot( + filePath: string, +): Promise { + try { + const rawSnapshot = await readFile(filePath, "utf8"); + const parsedSnapshot = JSON.parse( + rawSnapshot, + ) as AuditCatalogChecklistItemSignoffSnapshot; + + return { + signoffEntries: [...(parsedSnapshot.signoffEntries ?? [])].map((entry) => + clone(entry), + ), + }; + } catch (error) { + if (isMissingFileError(error)) { + return { + signoffEntries: [], + }; + } + + throw error; + } +} + +async function writeAuditCatalogChecklistItemSignoffSnapshot( + filePath: string, + snapshot: AuditCatalogChecklistItemSignoffSnapshot, +): Promise { + const tempPath = `${filePath}.tmp`; + const backupPath = `${filePath}.bak`; + + await writeFile(tempPath, `${JSON.stringify(snapshot, null, 2)}\n`, "utf8"); + + try { + await rename(tempPath, filePath); + + return; + } catch (error) { + if (!isExistingFileError(error)) { + throw error; + } + } + + await rm(backupPath, { + force: true, + }); + + try { + await rename(filePath, backupPath); + await rename(tempPath, filePath); + } catch (error) { + await rm(tempPath, { + force: true, + }); + + try { + await rename(backupPath, filePath); + } catch (restoreError) { + if (!isMissingFileError(restoreError)) { + throw restoreError; + } + } + + throw error; + } + + await rm(backupPath, { + force: true, + }); +} + +async function withPostgresClient( + pool: PostgresPoolLike, + task: (client: SqlClient) => Promise, +): Promise { + const client = await pool.connect(); + + try { + return await task(new PostgresSqlClient(client)); + } finally { + client.release(); + } +} + +async function withPostgresTransaction( + pool: PostgresPoolLike, + task: (client: SqlClient) => Promise, +): Promise { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + const result = await task(new PostgresSqlClient(client)); + await client.query("COMMIT"); + + return result; + } catch (error) { + await client.query("ROLLBACK"); + + throw error; + } finally { + client.release(); + } +} + +async function withSqliteClient( + options: { + readonly filePath: string; + readonly mutable: boolean; + }, + task: (client: SqlClient) => Promise, +): Promise { + if (options.mutable) { + await ensureParentDirectory(options.filePath); + } + + const SQL = await loadSqliteModule(); + const database = await openSqliteDatabase(SQL, options.filePath); + + try { + const client = new SqliteSqlClient(database); + + if (!options.mutable) { + return task(client); + } + + await client.execute("BEGIN"); + + try { + const result = await task(client); + await client.execute("COMMIT"); + await writeBinaryFileAtomically(options.filePath, database.export()); + + return result; + } catch (error) { + await client.execute("ROLLBACK"); + + throw error; + } + } finally { + database.close(); + } +} + +async function openSqliteDatabase( + SQL: SqlJsStatic, + filePath: string, +): Promise { + try { + const contents = await readFile(filePath); + + return new SQL.Database(new Uint8Array(contents)); + } catch (error) { + if (isMissingFileError(error)) { + return new SQL.Database(); + } + + throw error; + } +} + +async function loadSqliteModule(): Promise { + sqliteModulePromise ??= initSqlJs(); + + return sqliteModulePromise; +} + +class PostgresSqlClient implements SqlClient { + readonly dialect = "postgres" as const; + + constructor(private readonly client: PoolClient) {} + + async execute( + sql: string, + params: readonly SqlPrimitive[] = [], + ): Promise { + const result = await this.client.query( + convertQuestionMarksToPostgres(sql), + [...params], + ); + + return result.rowCount ?? 0; + } + + async queryRows( + sql: string, + params: readonly SqlPrimitive[] = [], + ): Promise { + const result = await this.client.query( + convertQuestionMarksToPostgres(sql), + [...params], + ); + + return result.rows; + } +} + +class SqliteSqlClient implements SqlClient { + readonly dialect = "sqlite" as const; + + constructor(private readonly database: SqliteDatabase) {} + + async execute( + sql: string, + params: readonly SqlPrimitive[] = [], + ): Promise { + this.database.run(sql, toSqliteParams(params)); + + return this.database.getRowsModified(); + } + + async queryRows( + sql: string, + params: readonly SqlPrimitive[] = [], + ): Promise { + const statement = this.database.prepare(sql, toSqliteParams(params)); + const rows: TRow[] = []; + + try { + while (statement.step()) { + rows.push(statement.getAsObject() as TRow); + } + } finally { + statement.free(); + } + + return rows; + } +} + +function createDefaultPool(databaseUrl?: string): PostgresPoolLike { + if (!databaseUrl) { + throw new Error( + 'Postgres audit catalog checklist item signoff requires DATABASE_URL or an explicit "databaseUrl" option.', + ); + } + + const { Pool } = require("pg") as typeof import("pg"); + + return new Pool({ + connectionString: databaseUrl, + }); +} + +function toSqliteParams(params: readonly SqlPrimitive[]): BindParams { + return [...params]; +} + +function convertQuestionMarksToPostgres(sql: string): string { + let placeholderIndex = 0; + + return sql.replace(/\?/g, () => `$${++placeholderIndex}`); +} + +async function ensureParentDirectory(filePath: string): Promise { + await mkdir(dirname(filePath), { + recursive: true, + }); +} + +async function writeBinaryFileAtomically( + filePath: string, + contents: Uint8Array, +): Promise { + const tempPath = `${filePath}.tmp`; + const backupPath = `${filePath}.bak`; + + await writeFile(tempPath, Buffer.from(contents)); + + try { + await rename(tempPath, filePath); + + return; + } catch (error) { + if (!isExistingFileError(error)) { + throw error; + } + } + + await rm(backupPath, { + force: true, + }); + + try { + await rename(filePath, backupPath); + await rename(tempPath, filePath); + } catch (error) { + await rm(tempPath, { + force: true, + }); + + try { + await rename(backupPath, filePath); + } catch (restoreError) { + if (!isMissingFileError(restoreError)) { + throw restoreError; + } + } + + throw error; + } + + await rm(backupPath, { + force: true, + }); +} + +async function withFileLock( + filePath: string, + options: Pick< + | FileAuditCatalogChecklistItemSignoffStoreOptions + | SqliteAuditCatalogChecklistItemSignoffStoreOptions, + "lockRetryDelayMs" | "lockTimeoutMs" + >, + action: () => Promise, +): Promise { + const lockPath = `${filePath}.lock`; + const retryDelayMs = options.lockRetryDelayMs ?? 25; + const timeoutMs = options.lockTimeoutMs ?? 5_000; + const startedAt = Date.now(); + + while (true) { + try { + const lockHandle = await open(lockPath, "wx"); + + try { + return await action(); + } finally { + await lockHandle.close(); + await rm(lockPath, { + force: true, + }); + } + } catch (error) { + if (!isExistingFileError(error)) { + throw error; + } + + if (Date.now() - startedAt >= timeoutMs) { + throw new Error( + `Timed out waiting for audit catalog checklist item signoff lock at "${lockPath}".`, + ); + } + + await delay(retryDelayMs); + } + } +} + +function delay(durationMs: number): Promise { + return new Promise((resolveDelay) => { + setTimeout(resolveDelay, durationMs); + }); +} + +function serializeRow(value: unknown): string { + return JSON.stringify(value); +} + +function deserializeRow(rawValue: string): TValue { + return JSON.parse(rawValue) as TValue; +} + +function clone(value: TValue): TValue { + return structuredClone(value); +} + +function isExistingFileError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error.code === "EEXIST" || error.code === "EPERM") + ); +} + +function isMissingFileError(error: unknown): boolean { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} diff --git a/packages/persistence/src/index.ts b/packages/persistence/src/index.ts index d2eebd7..5d3b4a3 100644 --- a/packages/persistence/src/index.ts +++ b/packages/persistence/src/index.ts @@ -91,6 +91,19 @@ export { resolveAuditCatalogChecklistItemResolutionsFilePath, type SqliteAuditCatalogChecklistItemResolutionStoreOptions, } from "./catalog-checklist-item-resolution-store"; +export { + type ConfiguredAuditCatalogChecklistItemSignoffStoreOptions, + createConfiguredAuditCatalogChecklistItemSignoffStore, + createFileAuditCatalogChecklistItemSignoffStore, + createInMemoryAuditCatalogChecklistItemSignoffStore, + createPostgresAuditCatalogChecklistItemSignoffStore, + createSqliteAuditCatalogChecklistItemSignoffStore, + type FileAuditCatalogChecklistItemSignoffStoreOptions, + type InMemoryAuditCatalogChecklistItemSignoffStoreOptions, + type PostgresAuditCatalogChecklistItemSignoffStoreOptions, + resolveAuditCatalogChecklistItemSignoffFilePath, + type SqliteAuditCatalogChecklistItemSignoffStoreOptions, +} from "./catalog-checklist-item-signoff-store"; export { type ConfiguredAuditCatalogChecklistItemVerificationStoreOptions, createConfiguredAuditCatalogChecklistItemVerificationStore, diff --git a/packages/persistence/src/migrations.ts b/packages/persistence/src/migrations.ts index 4213ad1..61dcca7 100644 --- a/packages/persistence/src/migrations.ts +++ b/packages/persistence/src/migrations.ts @@ -628,6 +628,43 @@ export const runtimePersistenceMigrations = [ ON runroot_audit_catalog_checklist_item_attestation (operator_id, updated_at DESC, catalog_entry_id ASC)`, ], }, + { + id: "0017_audit_catalog_checklist_item_signoff", + postgres: [ + `CREATE TABLE IF NOT EXISTS runroot_audit_catalog_checklist_item_signoff ( + catalog_entry_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + operator_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + signoff_note TEXT, + signoff_items TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + data TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_signoff_scope + ON runroot_audit_catalog_checklist_item_signoff (scope_id, updated_at DESC, catalog_entry_id ASC)`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_signoff_operator + ON runroot_audit_catalog_checklist_item_signoff (operator_id, updated_at DESC, catalog_entry_id ASC)`, + ], + sqlite: [ + `CREATE TABLE IF NOT EXISTS runroot_audit_catalog_checklist_item_signoff ( + catalog_entry_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + operator_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + signoff_note TEXT, + signoff_items TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + data TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_signoff_scope + ON runroot_audit_catalog_checklist_item_signoff (scope_id, updated_at DESC, catalog_entry_id ASC)`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_signoff_operator + ON runroot_audit_catalog_checklist_item_signoff (operator_id, updated_at DESC, catalog_entry_id ASC)`, + ], + }, { id: "0016_audit_catalog_checklist_item_acknowledgment", postgres: [ diff --git a/packages/replay/src/checklist-item-signoff.ts b/packages/replay/src/checklist-item-signoff.ts new file mode 100644 index 0000000..18cd80a --- /dev/null +++ b/packages/replay/src/checklist-item-signoff.ts @@ -0,0 +1,181 @@ +import type { + CrossRunAuditCatalogChecklistItemAcknowledgmentApplication, + CrossRunAuditCatalogChecklistItemAcknowledgmentView, +} from "./checklist-item-acknowledgment"; + +export type CrossRunAuditCatalogChecklistItemSignoffState = + | "signed-off" + | "unsigned"; + +export interface CrossRunAuditCatalogChecklistItemSignoffItem { + readonly item: string; + readonly state: CrossRunAuditCatalogChecklistItemSignoffState; +} + +export interface CrossRunAuditCatalogChecklistItemSignoff { + readonly signoffNote?: string; + readonly catalogEntryId: string; + readonly createdAt: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemSignoffItem[]; + readonly kind: "catalog-checklist-item-signoff"; + readonly operatorId: string; + readonly scopeId: string; + readonly updatedAt: string; +} + +export interface CreateCrossRunAuditCatalogChecklistItemSignoffInput { + readonly signoffNote?: string; + readonly catalogEntryId: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemSignoffItem[]; + readonly operatorId: string; + readonly scopeId: string; + readonly timestamp: string; +} + +export interface UpdateCrossRunAuditCatalogChecklistItemSignoffInput { + readonly signoffNote?: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemSignoffItem[]; +} + +export interface CrossRunAuditCatalogChecklistItemSignoffStore { + deleteCatalogChecklistItemSignoff( + catalogEntryId: string, + ): Promise; + getCatalogChecklistItemSignoff( + catalogEntryId: string, + ): Promise; + listCatalogChecklistItemSignoffs(): Promise< + readonly CrossRunAuditCatalogChecklistItemSignoff[] + >; + saveCatalogChecklistItemSignoff( + signoff: CrossRunAuditCatalogChecklistItemSignoff, + ): Promise; +} + +export interface CrossRunAuditCatalogChecklistItemSignoffView { + readonly signoff: CrossRunAuditCatalogChecklistItemSignoff; + readonly acknowledgment: CrossRunAuditCatalogChecklistItemAcknowledgmentView; +} + +export interface CrossRunAuditCatalogChecklistItemSignoffCollection { + readonly items: readonly CrossRunAuditCatalogChecklistItemSignoffView[]; + readonly totalCount: number; +} + +export interface CrossRunAuditCatalogChecklistItemSignoffApplication { + readonly application: CrossRunAuditCatalogChecklistItemAcknowledgmentApplication; + readonly signoff: CrossRunAuditCatalogChecklistItemSignoffView; +} + +export function createCrossRunAuditCatalogChecklistItemSignoff( + input: CreateCrossRunAuditCatalogChecklistItemSignoffInput, +): CrossRunAuditCatalogChecklistItemSignoff { + const catalogEntryId = input.catalogEntryId.trim(); + const operatorId = input.operatorId.trim(); + const scopeId = input.scopeId.trim(); + const items = normalizeChecklistItemSignoffItems(input.items); + const signoffNote = normalizeSignoffNote(input.signoffNote); + + if (catalogEntryId.length === 0) { + throw new Error( + "Catalog checklist item signoffs require a catalog entry id.", + ); + } + + if (operatorId.length === 0) { + throw new Error("Catalog checklist item signoffs require an operator id."); + } + + if (scopeId.length === 0) { + throw new Error("Catalog checklist item signoffs require a scope id."); + } + + if (items.length === 0) { + throw new Error( + "Catalog checklist item signoffs require at least one checklist item signoff entry.", + ); + } + + return { + ...(signoffNote ? { signoffNote } : {}), + catalogEntryId, + createdAt: input.timestamp, + items, + kind: "catalog-checklist-item-signoff", + operatorId, + scopeId, + updatedAt: input.timestamp, + }; +} + +export function compareCrossRunAuditCatalogChecklistItemSignoff( + left: CrossRunAuditCatalogChecklistItemSignoff, + right: CrossRunAuditCatalogChecklistItemSignoff, +): number { + return ( + compareSignoffPriority(left, right) || + right.updatedAt.localeCompare(left.updatedAt) || + right.createdAt.localeCompare(left.createdAt) || + left.catalogEntryId.localeCompare(right.catalogEntryId) + ); +} + +export function normalizeChecklistItemSignoffItems( + items: readonly CrossRunAuditCatalogChecklistItemSignoffItem[] | undefined, + allowedItems?: readonly string[], +): readonly CrossRunAuditCatalogChecklistItemSignoffItem[] { + const allowedItemSet = allowedItems + ? new Set(allowedItems.map((item) => item.trim()).filter(Boolean)) + : undefined; + const dedupedItems = new Map< + string, + CrossRunAuditCatalogChecklistItemSignoffState + >(); + + for (const entry of items ?? []) { + const item = entry.item.trim(); + + if (item.length === 0) { + continue; + } + + if (entry.state !== "signed-off" && entry.state !== "unsigned") { + throw new Error( + `Catalog checklist item signoffs require state signed-off|unsigned for "${item}".`, + ); + } + + if (allowedItemSet && !allowedItemSet.has(item)) { + throw new Error( + `Catalog checklist item signoff "${item}" is not defined on the shared checklist item acknowledgment layer.`, + ); + } + + dedupedItems.set(item, entry.state); + } + + return [...dedupedItems.entries()].map(([item, state]) => ({ + item, + state, + })); +} + +function compareSignoffPriority( + left: CrossRunAuditCatalogChecklistItemSignoff, + right: CrossRunAuditCatalogChecklistItemSignoff, +): number { + const leftPriority = left.items.some((item) => item.state === "signed-off") + ? 0 + : 1; + const rightPriority = right.items.some((item) => item.state === "signed-off") + ? 0 + : 1; + + return leftPriority - rightPriority; +} + +function normalizeSignoffNote(value: string | undefined): string | undefined { + const normalizedValue = value?.trim(); + + return normalizedValue ? normalizedValue : undefined; +} diff --git a/packages/replay/src/index.ts b/packages/replay/src/index.ts index faf4beb..9be9cdf 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -117,6 +117,20 @@ export { normalizeChecklistItemResolutionItems, type UpdateCrossRunAuditCatalogChecklistItemResolutionInput, } from "./checklist-item-resolution"; +export { + type CreateCrossRunAuditCatalogChecklistItemSignoffInput, + type CrossRunAuditCatalogChecklistItemSignoff, + type CrossRunAuditCatalogChecklistItemSignoffApplication, + type CrossRunAuditCatalogChecklistItemSignoffCollection, + type CrossRunAuditCatalogChecklistItemSignoffItem, + type CrossRunAuditCatalogChecklistItemSignoffState, + type CrossRunAuditCatalogChecklistItemSignoffStore, + type CrossRunAuditCatalogChecklistItemSignoffView, + compareCrossRunAuditCatalogChecklistItemSignoff, + createCrossRunAuditCatalogChecklistItemSignoff, + normalizeChecklistItemSignoffItems, + type UpdateCrossRunAuditCatalogChecklistItemSignoffInput, +} from "./checklist-item-signoff"; export { type CreateCrossRunAuditCatalogChecklistItemVerificationInput, type CrossRunAuditCatalogChecklistItemVerification, @@ -170,6 +184,7 @@ export { type CrossRunAuditCatalogChecklistItemEvidenceQuery, type CrossRunAuditCatalogChecklistItemProgressQuery, type CrossRunAuditCatalogChecklistItemResolutionQuery, + type CrossRunAuditCatalogChecklistItemSignoffQuery, type CrossRunAuditCatalogChecklistItemVerificationQuery, type CrossRunAuditCatalogQuery, type CrossRunAuditCatalogReviewAssignmentQuery, @@ -187,6 +202,7 @@ export { createCrossRunAuditCatalogChecklistItemEvidenceQuery, createCrossRunAuditCatalogChecklistItemProgressQuery, createCrossRunAuditCatalogChecklistItemResolutionQuery, + createCrossRunAuditCatalogChecklistItemSignoffQuery, createCrossRunAuditCatalogChecklistItemVerificationQuery, createCrossRunAuditCatalogQuery, createCrossRunAuditCatalogReviewAssignmentQuery, diff --git a/packages/replay/src/query.ts b/packages/replay/src/query.ts index 83414d7..b960f24 100644 --- a/packages/replay/src/query.ts +++ b/packages/replay/src/query.ts @@ -91,6 +91,17 @@ import { normalizeChecklistItemResolutionItems, type UpdateCrossRunAuditCatalogChecklistItemResolutionInput, } from "./checklist-item-resolution"; +import { + type CrossRunAuditCatalogChecklistItemSignoff, + type CrossRunAuditCatalogChecklistItemSignoffApplication, + type CrossRunAuditCatalogChecklistItemSignoffCollection, + type CrossRunAuditCatalogChecklistItemSignoffStore, + type CrossRunAuditCatalogChecklistItemSignoffView, + compareCrossRunAuditCatalogChecklistItemSignoff, + createCrossRunAuditCatalogChecklistItemSignoff, + normalizeChecklistItemSignoffItems, + type UpdateCrossRunAuditCatalogChecklistItemSignoffInput, +} from "./checklist-item-signoff"; import { type CrossRunAuditCatalogChecklistItemVerification, type CrossRunAuditCatalogChecklistItemVerificationApplication, @@ -470,6 +481,30 @@ export interface CrossRunAuditCatalogChecklistItemAcknowledgmentQuery { ): Promise; } +export interface CrossRunAuditCatalogChecklistItemSignoffQuery { + applyCatalogEntry( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + clearCatalogChecklistItemSignoff( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + getCatalogChecklistItemSignoff( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + listSignedOffCatalogEntries( + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + setCatalogChecklistItemSignoff( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + input: UpdateCrossRunAuditCatalogChecklistItemSignoffInput, + timestamp: string, + ): Promise; +} + export interface CrossRunAuditCatalogChecklistItemAttestationQuery { applyCatalogEntry( id: string, @@ -2292,6 +2327,156 @@ export function createCrossRunAuditCatalogChecklistItemAcknowledgmentQuery( }; } +export function createCrossRunAuditCatalogChecklistItemSignoffQuery( + store: CrossRunAuditCatalogChecklistItemSignoffStore, + acknowledgmentEntries: CrossRunAuditCatalogChecklistItemAcknowledgmentQuery, +): CrossRunAuditCatalogChecklistItemSignoffQuery { + return { + async applyCatalogEntry(id, viewer) { + const signoff = await resolveCatalogChecklistItemSignoffView( + store, + acknowledgmentEntries, + id, + viewer, + ); + + if (!signoff) { + return undefined; + } + + const application = await acknowledgmentEntries.applyCatalogEntry( + id, + viewer, + ); + + if (!application) { + return undefined; + } + + return { + application, + signoff, + }; + }, + + async clearCatalogChecklistItemSignoff(id, viewer) { + const signoff = await resolveCatalogChecklistItemSignoffView( + store, + acknowledgmentEntries, + id, + viewer, + ); + + if (!signoff) { + return undefined; + } + + await store.deleteCatalogChecklistItemSignoff(id); + + return signoff; + }, + + async getCatalogChecklistItemSignoff(id, viewer) { + return resolveCatalogChecklistItemSignoffView( + store, + acknowledgmentEntries, + id, + viewer, + ); + }, + + async listSignedOffCatalogEntries(viewer) { + const signoffEntries = [ + ...(await store.listCatalogChecklistItemSignoffs()), + ].sort(compareCrossRunAuditCatalogChecklistItemSignoff); + const items = ( + await Promise.all( + signoffEntries.map(async (signoff) => + resolveCatalogChecklistItemSignoffFromValue( + acknowledgmentEntries, + signoff, + viewer, + ), + ), + ) + ).filter( + (signoff): signoff is CrossRunAuditCatalogChecklistItemSignoffView => + signoff !== undefined, + ); + + return { + items, + totalCount: items.length, + }; + }, + + async setCatalogChecklistItemSignoff(id, viewer, input, timestamp) { + const acknowledgment = + await acknowledgmentEntries.getCatalogChecklistItemAcknowledgment( + id, + viewer, + ); + + if (!acknowledgment) { + throw new Error( + `Catalog entry "${id}" is not acknowledged and visible to operator "${viewer.operatorId}".`, + ); + } + + const allowedItems = acknowledgment.acknowledgment.items.map( + (item) => item.item, + ); + const normalizedItems = normalizeChecklistItemSignoffItems( + input.items, + allowedItems, + ); + + if (normalizedItems.length === 0) { + throw new Error( + "Catalog checklist item signoffs require at least one checklist item signoff entry.", + ); + } + + const existingSignoff = await store.getCatalogChecklistItemSignoff(id); + const normalizedSignoffNote = input.signoffNote?.trim(); + const signoff = existingSignoff + ? { + ...(input.signoffNote === undefined + ? existingSignoff.signoffNote + ? { signoffNote: existingSignoff.signoffNote } + : {} + : normalizedSignoffNote + ? { signoffNote: normalizedSignoffNote } + : {}), + catalogEntryId: id, + createdAt: existingSignoff.createdAt, + items: normalizedItems, + kind: existingSignoff.kind, + operatorId: viewer.operatorId, + scopeId: viewer.scopeId, + updatedAt: timestamp, + } + : createCrossRunAuditCatalogChecklistItemSignoff({ + ...(normalizedSignoffNote + ? { signoffNote: normalizedSignoffNote } + : {}), + catalogEntryId: id, + items: normalizedItems, + operatorId: viewer.operatorId, + scopeId: viewer.scopeId, + timestamp, + }); + + await store.saveCatalogChecklistItemSignoff(signoff); + + return { + acknowledgment, + signoff, + }; + }, + }; +} + async function resolveCatalogEntryView( store: CrossRunAuditCatalogStore, savedViews: CrossRunAuditSavedViewQuery, @@ -2820,3 +3005,57 @@ async function resolveCatalogChecklistItemAcknowledgmentFromValue( attestation, }; } + +async function resolveCatalogChecklistItemSignoffView( + store: CrossRunAuditCatalogChecklistItemSignoffStore, + acknowledgmentQuery: CrossRunAuditCatalogChecklistItemAcknowledgmentQuery, + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, +): Promise { + const signoff = await store.getCatalogChecklistItemSignoff(id); + + return signoff + ? resolveCatalogChecklistItemSignoffFromValue( + acknowledgmentQuery, + signoff, + viewer, + ) + : undefined; +} + +async function resolveCatalogChecklistItemSignoffFromValue( + acknowledgmentQuery: CrossRunAuditCatalogChecklistItemAcknowledgmentQuery, + signoff: CrossRunAuditCatalogChecklistItemSignoff, + viewer: CrossRunAuditCatalogVisibilityViewer, +): Promise { + if (signoff.scopeId !== viewer.scopeId) { + return undefined; + } + + const acknowledgment = + await acknowledgmentQuery.getCatalogChecklistItemAcknowledgment( + signoff.catalogEntryId, + viewer, + ); + + if (!acknowledgment) { + return undefined; + } + + const normalizedItems = normalizeChecklistItemSignoffItems( + signoff.items, + acknowledgment.acknowledgment.items.map((item) => item.item), + ); + + if (normalizedItems.length === 0) { + return undefined; + } + + return { + acknowledgment, + signoff: { + ...signoff, + items: normalizedItems, + }, + }; +} From 1a0773ec9d097101ef7163b33858db3f98aedbae Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Sat, 4 Apr 2026 02:53:28 -0500 Subject: [PATCH 3/4] feat(phase-28): wire checklist item signoffs through existing seams --- apps/api/src/server.ts | 91 +++++++++ apps/web/src/app/runs/catalog/route.ts | 120 +++++++++++ apps/web/src/app/runs/page.tsx | 24 +++ apps/web/src/components/console.tsx | 264 +++++++++++++++++++++++++ apps/web/src/lib/runroot-api.ts | 96 +++++++++ packages/cli/src/index.ts | 91 +++++++++ packages/config/src/index.ts | 7 +- packages/sdk/src/errors.ts | 1 + packages/sdk/src/index.ts | 1 + packages/sdk/src/operator-service.ts | 126 ++++++++++++ 10 files changed, 818 insertions(+), 3 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index c61b7b0..80bf416 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -285,6 +285,12 @@ export function buildServer(options: BuildServerOptions = {}) { })), ); + app.get("/audit/catalog/signed-off", async (_request, reply) => + handleOperatorResponse(reply, async () => ({ + signedOff: await operator.listSignedOffCatalogEntries(), + })), + ); + app.post("/audit/saved-views", async (request, reply) => handleOperatorResponse(reply, async () => { const body = request.body as { @@ -534,6 +540,20 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.get("/audit/catalog/:catalogEntryId/sign-off", async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + signoff: await operator.getCatalogChecklistItemSignoff( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/catalog/:catalogEntryId", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -870,6 +890,34 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post("/audit/catalog/:catalogEntryId/sign-off", async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + const body = request.body as { + readonly signoffNote?: string; + readonly items?: unknown; + }; + const items = readChecklistItemSignoffItems(body?.items, "items"); + + if (items.length === 0) { + throw new OperatorInputError( + "items must include at least one checklist item signoff entry.", + ); + } + + return { + signoff: await operator.signOffCatalogEntry(params.catalogEntryId, { + ...(body?.signoffNote !== undefined + ? { signoffNote: body.signoffNote } + : {}), + items, + }), + }; + }), + ); + app.post( "/audit/catalog/:catalogEntryId/review/clear", async (request, reply) => @@ -1031,6 +1079,22 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post( + "/audit/catalog/:catalogEntryId/sign-off/clear", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + signoff: await operator.clearCatalogChecklistItemSignoff( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/saved-views/:savedViewId/apply", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -1598,3 +1662,30 @@ function readChecklistItemAcknowledgmentItems( return value; } + +function readChecklistItemSignoffItems( + value: unknown, + fieldName: string, +): readonly { + readonly item: string; + readonly state: "signed-off" | "unsigned"; +}[] { + if ( + !Array.isArray(value) || + !value.every( + (entry) => + typeof entry === "object" && + entry !== null && + "item" in entry && + typeof entry.item === "string" && + "state" in entry && + (entry.state === "signed-off" || entry.state === "unsigned"), + ) + ) { + throw new OperatorInputError( + `${fieldName} must be an array of { item, state } objects with state signed-off|unsigned.`, + ); + } + + return value; +} diff --git a/apps/web/src/app/runs/catalog/route.ts b/apps/web/src/app/runs/catalog/route.ts index c1c276c..9f41d90 100644 --- a/apps/web/src/app/runs/catalog/route.ts +++ b/apps/web/src/app/runs/catalog/route.ts @@ -551,6 +551,53 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "sign-off") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + const signoffItems = readChecklistItemSignoffItems( + formData.get("signoffItems"), + ); + const signoffNoteValue = formData.get("signoffNote"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to update checklist item sign-off metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + if (signoffItems.length === 0) { + appendFlashMessage( + redirectUrl, + "error", + "At least one checklist item sign-off entry is required.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const signoff = + await createRunrootApiClient().setAuditCatalogChecklistItemSignoff( + catalogEntryId, + { + ...(typeof signoffNoteValue === "string" + ? { signoffNote: signoffNoteValue } + : {}), + items: signoffItems, + }, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item sign-offs for ${signoff.acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} updated.`, + ); + + return Response.redirect(redirectUrl, 303); + } + if (intent === "clear-review") { const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); @@ -767,6 +814,33 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "clear-sign-off") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to clear checklist item sign-off metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const signoff = + await createRunrootApiClient().clearAuditCatalogChecklistItemSignoff( + catalogEntryId, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item sign-offs for ${signoff.acknowledgment.attestation.evidence.verification.resolution.blocker.progress.checklist.assignment.review.visibility.catalogEntry.entry.name} cleared.`, + ); + + return Response.redirect(redirectUrl, 303); + } + if (intent === "clear-progress") { const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); @@ -1216,3 +1290,49 @@ function readChecklistItemAcknowledgmentItems( }; }); } + +function readChecklistItemSignoffItems( + value: FormDataEntryValue | null, +): readonly { + readonly item: string; + readonly state: "signed-off" | "unsigned"; +}[] { + if (typeof value !== "string") { + return []; + } + + return value + .split(/\r?\n/u) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf(":"); + + if (separatorIndex < 0) { + return { + item: line, + state: "unsigned" as const, + }; + } + + const rawState = line.slice(0, separatorIndex).trim(); + const item = line.slice(separatorIndex + 1).trim(); + + if (item.length === 0) { + throw new Error( + "Checklist item sign-off lines require a non-empty item.", + ); + } + + if (rawState !== "signed-off" && rawState !== "unsigned") { + throw new Error( + `Checklist item sign-off state must be signed-off or unsigned for "${item}".`, + ); + } + + return { + item, + state: rawState, + }; + }); +} diff --git a/apps/web/src/app/runs/page.tsx b/apps/web/src/app/runs/page.tsx index 6c4efeb..1c8d878 100644 --- a/apps/web/src/app/runs/page.tsx +++ b/apps/web/src/app/runs/page.tsx @@ -9,6 +9,7 @@ import { ChecklistItemEvidencesView, ChecklistItemProgressView, ChecklistItemResolutionsView, + ChecklistItemSignoffsView, ChecklistItemVerificationsView, ConsoleShell, CrossRunAuditNavigationView, @@ -30,6 +31,7 @@ import { type ApiAuditCatalogChecklistItemEvidenceView, type ApiAuditCatalogChecklistItemProgressView, type ApiAuditCatalogChecklistItemResolutionView, + type ApiAuditCatalogChecklistItemSignoffView, type ApiAuditCatalogChecklistItemVerificationView, type ApiAuditCatalogEntryApplication, type ApiAuditCatalogReviewAssignmentView, @@ -64,6 +66,7 @@ export default async function RunsPage({ const drilldownFilters = readAuditDrilldownFilters(resolvedSearchParams); const [ runs, + signedOffEntries, acknowledgedEntries, blockedEntries, evidencedEntries, @@ -81,6 +84,7 @@ export default async function RunsPage({ catalogChecklistItemBlocker, catalogChecklistItemResolution, catalogChecklistItemEvidence, + catalogChecklistItemSignoff, catalogChecklistItemAcknowledgment, catalogChecklistItemAttestation, catalogChecklistItemVerification, @@ -90,6 +94,7 @@ export default async function RunsPage({ catalogReviewAssignment, ] = await Promise.all([ api.listRuns(), + api.listSignedOffAuditCatalogEntries(), api.listAcknowledgedAuditCatalogEntries(), api.listBlockedAuditCatalogEntries(), api.listEvidencedAuditCatalogEntries(), @@ -128,6 +133,11 @@ export default async function RunsPage({ .getAuditCatalogChecklistItemEvidence(catalogEntryId) .catch(() => undefined) : Promise.resolve(undefined), + catalogEntryId + ? api + .getAuditCatalogChecklistItemSignoff(catalogEntryId) + .catch(() => undefined) + : Promise.resolve(undefined), catalogEntryId ? api .getAuditCatalogChecklistItemAcknowledgment(catalogEntryId) @@ -176,6 +186,9 @@ export default async function RunsPage({ let activeCatalogChecklistItemEvidence: | ApiAuditCatalogChecklistItemEvidenceView | undefined; + let activeCatalogChecklistItemSignoff: + | ApiAuditCatalogChecklistItemSignoffView + | undefined; let activeCatalogChecklistItemAcknowledgment: | ApiAuditCatalogChecklistItemAcknowledgmentView | undefined; @@ -200,6 +213,7 @@ export default async function RunsPage({ activeCatalogChecklistItemBlocker = catalogChecklistItemBlocker; activeCatalogChecklistItemResolution = catalogChecklistItemResolution; activeCatalogChecklistItemEvidence = catalogChecklistItemEvidence; + activeCatalogChecklistItemSignoff = catalogChecklistItemSignoff; activeCatalogChecklistItemAcknowledgment = catalogChecklistItemAcknowledgment; activeCatalogChecklistItemAttestation = catalogChecklistItemAttestation; @@ -241,6 +255,12 @@ export default async function RunsPage({ ? { activeCatalogChecklistItemEvidence } : {})} /> + ) { + return ( +
+
+
+
+ Phase 28 / Checklist Item Sign-Offs +
+

Checklist item sign-offs

+

+ Track thin per-item sign-offs and a single sign-off note on + acknowledged presets without turning the console into an approval + product, workflow engine, or collaboration surface. +

+
+
+ {signedOffEntries.totalCount} signed-off preset(s) +
+
+ + {activeCatalogChecklistItemSignoff ? ( +
+ Active sign-offs:{" "} + + { + activeCatalogChecklistItemSignoff.acknowledgment.attestation + .evidence.verification.resolution.blocker.progress.checklist + .assignment.review.visibility.catalogEntry.entry.name + } + + {" · "} + {formatSignoffSummary( + activeCatalogChecklistItemSignoff.signoff.items, + )} + {activeCatalogChecklistItemSignoff.signoff.signoffNote + ? ` · ${activeCatalogChecklistItemSignoff.signoff.signoffNote}` + : ""} +
+ ) : null} + + {signedOffEntries.items.length === 0 ? ( +

+ No checklist item sign-offs yet. Record acknowledgments first, then + save thin sign-off metadata through the shared operator seam. +

+ ) : ( +
    + {signedOffEntries.items.map((signoffView) => ( +
  1. +
    +
    + + { + signoffView.acknowledgment.attestation.evidence + .verification.resolution.blocker.progress.checklist + .assignment.review.visibility.catalogEntry.entry.name + } + +
    + {formatSignoffSummary(signoffView.signoff.items)} + {" · "} + {formatAcknowledgmentSummary( + signoffView.acknowledgment.acknowledgment.items, + )} + {" · "} + {formatAttestationSummary( + signoffView.acknowledgment.attestation.attestation.items, + )} +
    +
    + {formatTimestamp(signoffView.signoff.updatedAt)} +
    +
      + {signoffView.signoff.items.map((item) => ( +
    • + {item.state}: {item.item} +
    • + ))} +
    + {signoffView.signoff.signoffNote ? ( +

    {signoffView.signoff.signoffNote}

    + ) : null} +
    + operator {signoffView.signoff.operatorId} · scope{" "} + {signoffView.signoff.scopeId} +
    +
    + + Apply signed-off preset + +
    + + + + +
    +
    + {activeCatalogChecklistItemSignoff?.signoff.catalogEntryId === + signoffView.signoff.catalogEntryId ? ( +
    Active sign-offs selected
    + ) : null} +
  2. + ))} +
+ )} +
+ ); +} + export function CatalogReviewAssignmentsView({ activeCatalogReviewAssignment, assignedEntries, @@ -1613,6 +1767,7 @@ export function CatalogReviewSignalsView({ export function AuditViewCatalogsView({ activeCatalogEntry, activeCatalogChecklistItemBlocker, + activeCatalogChecklistItemSignoff, activeCatalogChecklistItemAcknowledgment, activeCatalogChecklistItemAttestation, activeCatalogChecklistItemEvidence, @@ -1620,6 +1775,7 @@ export function AuditViewCatalogsView({ activeCatalogChecklistItemVerification, activeCatalogChecklistItemProgress, assignedEntries, + signedOffEntries, acknowledgedEntries, attestedEntries, blockedEntries, @@ -1633,6 +1789,7 @@ export function AuditViewCatalogsView({ }: Readonly<{ activeCatalogEntry?: ApiAuditCatalogVisibilityView; activeCatalogChecklistItemBlocker?: ApiAuditCatalogChecklistItemBlockerView; + activeCatalogChecklistItemSignoff?: ApiAuditCatalogChecklistItemSignoffView; activeCatalogChecklistItemAcknowledgment?: ApiAuditCatalogChecklistItemAcknowledgmentView; activeCatalogChecklistItemAttestation?: ApiAuditCatalogChecklistItemAttestationView; activeCatalogChecklistItemEvidence?: ApiAuditCatalogChecklistItemEvidenceView; @@ -1640,6 +1797,7 @@ export function AuditViewCatalogsView({ activeCatalogChecklistItemVerification?: ApiAuditCatalogChecklistItemVerificationView; activeCatalogChecklistItemProgress?: ApiAuditCatalogChecklistItemProgressView; assignedEntries: ApiAuditCatalogReviewAssignmentCollection; + signedOffEntries: ApiAuditCatalogChecklistItemSignoffCollection; acknowledgedEntries: ApiAuditCatalogChecklistItemAcknowledgmentCollection; attestedEntries: ApiAuditCatalogChecklistItemAttestationCollection; blockedEntries: ApiAuditCatalogChecklistItemBlockerCollection; @@ -1739,6 +1897,17 @@ export function AuditViewCatalogsView({ ] as const, ), ); + const signoffsByCatalogEntryId = new Map( + signedOffEntries.items.map( + (item) => + [ + item.acknowledgment.attestation.evidence.verification.resolution + .blocker.progress.checklist.assignment.review.visibility + .catalogEntry.entry.id, + item, + ] as const, + ), + ); return (
@@ -1825,6 +1994,15 @@ export function AuditViewCatalogsView({ : acknowledgmentsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); + const signoff = + activeCatalogChecklistItemSignoff?.acknowledgment.attestation + .evidence.verification.resolution.blocker.progress.checklist + .assignment.review.visibility.catalogEntry.entry.id === + catalogEntry.catalogEntry.entry.id + ? activeCatalogChecklistItemSignoff + : signoffsByCatalogEntryId.get( + catalogEntry.catalogEntry.entry.id, + ); const reviewSignal = reviewSignalsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); @@ -1867,6 +2045,14 @@ export function AuditViewCatalogsView({ attestation.attestation.items, )}` : ""} + {acknowledgment + ? ` · ${formatAcknowledgmentSummary( + acknowledgment.acknowledgment.items, + )}` + : ""} + {signoff + ? ` · ${formatSignoffSummary(signoff.signoff.items)}` + : ""} @@ -2581,6 +2767,57 @@ export function AuditViewCatalogsView({ ) : null} + {acknowledgment ? ( +
+ + + +
+