From 3f94d35f6bf48799012310d8813e9937d7a7e960 Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Fri, 3 Apr 2026 01:18:13 -0500 Subject: [PATCH 1/4] docs(phase-27): define checklist item acknowledgment and acknowledgment note paths --- ...cknowledgments-and-acknowledgment-notes.md | 108 ++++++++++++++ ...-catalog-checklist-item-acknowledgments.md | 132 ++++++++++++++++++ docs/roadmap.md | 2 +- 3 files changed, 241 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr-0027-cross-run-audit-catalog-checklist-item-acknowledgments-and-acknowledgment-notes.md create mode 100644 docs/guides/audit-catalog-checklist-item-acknowledgments.md diff --git a/docs/architecture/adr-0027-cross-run-audit-catalog-checklist-item-acknowledgments-and-acknowledgment-notes.md b/docs/architecture/adr-0027-cross-run-audit-catalog-checklist-item-acknowledgments-and-acknowledgment-notes.md new file mode 100644 index 0000000..95378ac --- /dev/null +++ b/docs/architecture/adr-0027-cross-run-audit-catalog-checklist-item-acknowledgments-and-acknowledgment-notes.md @@ -0,0 +1,108 @@ +# ADR-0027: Cross-Run Audit Catalog Checklist Item Acknowledgments and Acknowledgment Notes + +Date: 2026-04-03 + +## Status + +Accepted on branch for Phase 27 implementation. + +## Context + +Phase 26 added a thin checklist-item-attestation layer over evidenced verified +resolved blocked progressed assigned reviewed audit catalog entries. Operators +could record whether stable evidence references had been attested, but there +was still no package-owned way to capture that a downstream operator had +acknowledged the current attestation and supporting 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-acknowledgment layer that remains derived over the existing +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-acknowledgment contract +with the following properties: + +- checklist item acknowledgments remain additive operator metadata +- checklist item acknowledgments reference existing catalog entries, + 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 acknowledgments store only stable per-item acknowledgment + state, a thin optional acknowledgment note, minimal actor and scope + references, and existing catalog refs +- checklist item acknowledgments do not snapshot audit facts, provider + payloads, copied artifacts, or workflow state +- checklist item acknowledgments do not change replay or approval source of + truth +- applying an acknowledged preset reuses the existing catalog-apply path + +The shared acknowledgment 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` + +Acknowledgment visibility stays minimal: + +- list and inspect are scoped to operators already involved in the attestation, + evidence, verification, resolution, blocker, progress, and assignment + handoff path +- acknowledgment entries can only reference checklist items that already exist + in the shared checklist-item-attestation layer +- acknowledgment state stays as a thin per-item enum +- acknowledgment 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 acknowledgment state and a single + acknowledgment note through shared package-owned seams +- inline and queued runs reuse the same checklist-item-acknowledgment contract +- operator surfaces stay thin and reuse the existing catalog apply behavior +- replay and approval semantics remain unchanged + +### Negative + +- checklist item acknowledgments are still a derived layer that depends on + 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 +- acknowledgments 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 +- acknowledgment-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 acknowledgment metadata +- provider payload, copied artifact, or full snapshot persistence diff --git a/docs/guides/audit-catalog-checklist-item-acknowledgments.md b/docs/guides/audit-catalog-checklist-item-acknowledgments.md new file mode 100644 index 0000000..8ef7ef1 --- /dev/null +++ b/docs/guides/audit-catalog-checklist-item-acknowledgments.md @@ -0,0 +1,132 @@ +# Audit Catalog Checklist Item Acknowledgments + +Phase 27 adds a thin shared checklist-item-acknowledgment layer over attested +evidenced verified resolved blocked progressed assigned reviewed audit catalog +entries that already carry checklist-item-attestation metadata. + +## What Checklist Item Acknowledgments Record + +The shared contract stores: + +- a reference to an existing attested evidenced verified resolved blocked + progressed assigned reviewed catalog entry +- per-item acknowledgment state for checklist items that already exist in the + shared checklist-item-attestation layer +- minimal operator and scope references used by the current operator seam +- an optional thin acknowledgment 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 acknowledgments 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, or checklist-item attestations. + +## Acknowledge, List-Acknowledged, Inspect-Acknowledgment, Clear-Acknowledgment, And Apply + +The minimum acknowledgment path is available through the existing seams: + +- SDK: + - `acknowledgeCatalogEntry(id, ...)` + - `listAcknowledgedCatalogEntries()` + - `getCatalogChecklistItemAcknowledgment(id)` + - `clearCatalogChecklistItemAcknowledgment(id)` + - `applyCatalogEntry(id)` +- API: + - `POST /audit/catalog/:catalogEntryId/acknowledgment` + - `GET /audit/catalog/acknowledged` + - `GET /audit/catalog/:catalogEntryId/acknowledgment` + - `POST /audit/catalog/:catalogEntryId/acknowledgment/clear` + - `GET /audit/catalog/:catalogEntryId/apply` +- CLI: + - `audit catalog acknowledge` + - `audit catalog acknowledged` + - `audit catalog inspect-acknowledgment` + - `audit catalog clear-acknowledgment` + - `audit catalog apply` +- Web: + - the runs page presents a thin checklist-item-acknowledgment panel and a + minimal acknowledgment-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, and checklist-item-attestation surfaces + +## What Applying An Acknowledged Preset Does + +Applying an acknowledged 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, and acknowledgment + 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 acknowledged attested 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 acknowledgments on an 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 acknowledgment" +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 acknowledged +pnpm --filter @runroot/cli dev audit catalog apply catalog_entry_1 +pnpm --filter @runroot/cli dev audit catalog clear-acknowledgment catalog_entry_1 +``` + +Both inline-originated and queued-originated presets reuse the same +checklist-item-acknowledgment contract through the configured persistence +adapter. + +## What Stays Deferred + +Still out of scope after Phase 27: + +- 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 ee1f24d..3f01718 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -427,4 +427,4 @@ Status: completed engines, broader multi-user curation, approval products, or broad observability and analytics platform work -Status: scope frozen +Status: completed From 79dc6d247c878a676d3f793184b110b7e61d2d8b Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Fri, 3 Apr 2026 01:18:36 -0500 Subject: [PATCH 2/4] feat(phase-27): add catalog checklist item acknowledgment contract --- ...hecklist-item-acknowledgment-store.test.ts | 148 +++ ...log-checklist-item-acknowledgment-store.ts | 870 ++++++++++++++++++ packages/persistence/src/index.ts | 15 +- packages/persistence/src/migrations.ts | 37 + .../src/checklist-item-acknowledgment.test.ts | 99 ++ .../src/checklist-item-acknowledgment.ts | 193 ++++ packages/replay/src/index.ts | 16 + packages/replay/src/query.ts | 245 +++++ 8 files changed, 1622 insertions(+), 1 deletion(-) create mode 100644 packages/persistence/src/catalog-checklist-item-acknowledgment-store.test.ts create mode 100644 packages/persistence/src/catalog-checklist-item-acknowledgment-store.ts create mode 100644 packages/replay/src/checklist-item-acknowledgment.test.ts create mode 100644 packages/replay/src/checklist-item-acknowledgment.ts diff --git a/packages/persistence/src/catalog-checklist-item-acknowledgment-store.test.ts b/packages/persistence/src/catalog-checklist-item-acknowledgment-store.test.ts new file mode 100644 index 0000000..c4964a9 --- /dev/null +++ b/packages/persistence/src/catalog-checklist-item-acknowledgment-store.test.ts @@ -0,0 +1,148 @@ +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { createCrossRunAuditCatalogChecklistItemAcknowledgment } from "@runroot/replay"; +import { newDb } from "pg-mem"; +import { describe, expect, it } from "vitest"; + +import { + createFileAuditCatalogChecklistItemAcknowledgmentStore, + createPostgresAuditCatalogChecklistItemAcknowledgmentStore, + createSqliteAuditCatalogChecklistItemAcknowledgmentStore, + resolveAuditCatalogChecklistItemAcknowledgmentFilePath, +} from "./catalog-checklist-item-acknowledgment-store"; + +function createAcknowledgment( + catalogEntryId: string, + timestamp = "2026-04-02T04:15:00.000Z", + acknowledgmentNote = "Operator acknowledged that the cited evidence is sufficient", +) { + return createCrossRunAuditCatalogChecklistItemAcknowledgment({ + acknowledgmentNote, + catalogEntryId, + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + { + item: "Close backup handoff", + state: "unacknowledged", + }, + ], + operatorId: "ops_oncall", + scopeId: "ops", + timestamp, + }); +} + +describe("@runroot/persistence audit catalog checklist item acknowledgment stores", () => { + it("persists audit catalog checklist item acknowledgment through the Postgres adapter", async () => { + const memoryDatabase = newDb({ + noAstCoverageCheck: true, + }); + const { Pool } = memoryDatabase.adapters.createPg(); + const pool = new Pool(); + const firstStore = + createPostgresAuditCatalogChecklistItemAcknowledgmentStore({ + pool, + }); + const secondStore = + createPostgresAuditCatalogChecklistItemAcknowledgmentStore({ + pool, + }); + + try { + await firstStore.saveCatalogChecklistItemAcknowledgment( + createAcknowledgment("catalog_entry_postgres"), + ); + + expect( + await secondStore.listCatalogChecklistItemAcknowledgments(), + ).toEqual([createAcknowledgment("catalog_entry_postgres")]); + } finally { + await pool.end(); + } + }); + + it("persists audit catalog checklist item acknowledgment through the SQLite adapter", async () => { + const workspaceRoot = await mkdtemp( + join(tmpdir(), "runroot-acknowledgment-sqlite-"), + ); + const filePath = join(workspaceRoot, "runroot.sqlite"); + const firstStore = createSqliteAuditCatalogChecklistItemAcknowledgmentStore( + { + filePath, + }, + ); + const secondStore = + createSqliteAuditCatalogChecklistItemAcknowledgmentStore({ + filePath, + }); + + await firstStore.saveCatalogChecklistItemAcknowledgment( + createAcknowledgment("catalog_entry_sqlite"), + ); + + expect(await secondStore.listCatalogChecklistItemAcknowledgments()).toEqual( + [createAcknowledgment("catalog_entry_sqlite")], + ); + }); + + it("persists audit catalog checklist item acknowledgment through the file-sidecar compatibility path", async () => { + const workspaceRoot = await mkdtemp( + join(tmpdir(), "runroot-acknowledgment-file-"), + ); + const workspacePath = join(workspaceRoot, "workspace.json"); + const fileStore = createFileAuditCatalogChecklistItemAcknowledgmentStore({ + filePath: + resolveAuditCatalogChecklistItemAcknowledgmentFilePath(workspacePath), + }); + + await fileStore.saveCatalogChecklistItemAcknowledgment( + createAcknowledgment("catalog_entry_file"), + ); + + expect(await fileStore.listCatalogChecklistItemAcknowledgments()).toEqual([ + createAcknowledgment("catalog_entry_file"), + ]); + }); + + it("overwrites and clears audit catalog checklist item acknowledgment through the file-sidecar compatibility path", async () => { + const workspaceRoot = await mkdtemp( + join(tmpdir(), "runroot-acknowledgment-file-overwrite-"), + ); + const workspacePath = join(workspaceRoot, "workspace.json"); + const fileStore = createFileAuditCatalogChecklistItemAcknowledgmentStore({ + filePath: + resolveAuditCatalogChecklistItemAcknowledgmentFilePath(workspacePath), + }); + const originalEntry = createAcknowledgment("catalog_entry_overwrite"); + const updatedEntry = createAcknowledgment( + "catalog_entry_overwrite", + "2026-04-02T04:15:10.000Z", + "Operator re-acknowledged the evidence after the queued handoff", + ); + + await fileStore.saveCatalogChecklistItemAcknowledgment(originalEntry); + await fileStore.saveCatalogChecklistItemAcknowledgment(updatedEntry); + + expect( + await fileStore.getCatalogChecklistItemAcknowledgment( + "catalog_entry_overwrite", + ), + ).toEqual(updatedEntry); + expect(await fileStore.listCatalogChecklistItemAcknowledgments()).toEqual([ + updatedEntry, + ]); + expect( + await fileStore.deleteCatalogChecklistItemAcknowledgment( + "catalog_entry_overwrite", + ), + ).toEqual(updatedEntry); + expect(await fileStore.listCatalogChecklistItemAcknowledgments()).toEqual( + [], + ); + }); +}); diff --git a/packages/persistence/src/catalog-checklist-item-acknowledgment-store.ts b/packages/persistence/src/catalog-checklist-item-acknowledgment-store.ts new file mode 100644 index 0000000..284a918 --- /dev/null +++ b/packages/persistence/src/catalog-checklist-item-acknowledgment-store.ts @@ -0,0 +1,870 @@ +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 { + CrossRunAuditCatalogChecklistItemAcknowledgment, + CrossRunAuditCatalogChecklistItemAcknowledgmentStore, +} from "@runroot/replay"; +import { compareCrossRunAuditCatalogChecklistItemAcknowledgment } 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 InMemoryAuditCatalogChecklistItemAcknowledgmentStoreOptions { + readonly acknowledgmentEntries?: readonly CrossRunAuditCatalogChecklistItemAcknowledgment[]; +} + +export interface FileAuditCatalogChecklistItemAcknowledgmentStoreOptions { + readonly filePath: string; + readonly lockRetryDelayMs?: number; + readonly lockTimeoutMs?: number; +} + +export interface PostgresAuditCatalogChecklistItemAcknowledgmentStoreOptions + extends Pick {} + +export interface SqliteAuditCatalogChecklistItemAcknowledgmentStoreOptions + extends Pick< + SqliteRuntimePersistenceOptions, + "filePath" | "lockRetryDelayMs" | "lockTimeoutMs" + > {} + +export interface ConfiguredAuditCatalogChecklistItemAcknowledgmentStoreOptions + extends ResolvePersistenceConfigOptions { + readonly filePath?: string; + readonly lockRetryDelayMs?: number; + readonly lockTimeoutMs?: number; + readonly pool?: PostgresPoolLike; +} + +interface AuditCatalogChecklistItemAcknowledgmentSnapshot { + readonly acknowledgmentEntries: readonly CrossRunAuditCatalogChecklistItemAcknowledgment[]; +} + +let sqliteModulePromise: Promise | undefined; + +export function createConfiguredAuditCatalogChecklistItemAcknowledgmentStore( + options: ConfiguredAuditCatalogChecklistItemAcknowledgmentStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + const resolved = resolvePersistenceConfig(options); + + switch (resolved.driver) { + case "file": + return createFileAuditCatalogChecklistItemAcknowledgmentStore({ + filePath: + options.filePath ?? + resolveAuditCatalogChecklistItemAcknowledgmentFilePath( + resolved.workspacePath ?? resolved.location, + ), + ...(options.lockRetryDelayMs !== undefined + ? { lockRetryDelayMs: options.lockRetryDelayMs } + : {}), + ...(options.lockTimeoutMs !== undefined + ? { lockTimeoutMs: options.lockTimeoutMs } + : {}), + }); + case "postgres": + return createPostgresAuditCatalogChecklistItemAcknowledgmentStore({ + ...(resolved.databaseUrl ? { databaseUrl: resolved.databaseUrl } : {}), + ...(options.pool ? { pool: options.pool } : {}), + }); + case "sqlite": + return createSqliteAuditCatalogChecklistItemAcknowledgmentStore({ + filePath: resolved.sqlitePath ?? resolved.location, + ...(options.lockRetryDelayMs !== undefined + ? { lockRetryDelayMs: options.lockRetryDelayMs } + : {}), + ...(options.lockTimeoutMs !== undefined + ? { lockTimeoutMs: options.lockTimeoutMs } + : {}), + }); + } +} + +export function createInMemoryAuditCatalogChecklistItemAcknowledgmentStore( + options: InMemoryAuditCatalogChecklistItemAcknowledgmentStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + const acknowledgmentEntries = [...(options.acknowledgmentEntries ?? [])].map( + (entry) => clone(entry), + ); + + return { + async deleteCatalogChecklistItemAcknowledgment(catalogEntryId) { + const existingIndex = acknowledgmentEntries.findIndex( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + if (existingIndex < 0) { + return undefined; + } + + const [deletedEntry] = acknowledgmentEntries.splice(existingIndex, 1); + + return deletedEntry ? clone(deletedEntry) : undefined; + }, + + async getCatalogChecklistItemAcknowledgment(catalogEntryId) { + const acknowledgment = acknowledgmentEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + return acknowledgment ? clone(acknowledgment) : undefined; + }, + + async listCatalogChecklistItemAcknowledgments() { + return acknowledgmentEntries + .slice() + .sort(compareCrossRunAuditCatalogChecklistItemAcknowledgment) + .map((entry) => clone(entry)); + }, + + async saveCatalogChecklistItemAcknowledgment(entry) { + const existingIndex = acknowledgmentEntries.findIndex( + (candidate) => candidate.catalogEntryId === entry.catalogEntryId, + ); + + if (existingIndex >= 0) { + acknowledgmentEntries.splice(existingIndex, 1); + } + + acknowledgmentEntries.push(clone(entry)); + acknowledgmentEntries.sort( + compareCrossRunAuditCatalogChecklistItemAcknowledgment, + ); + + return clone(entry); + }, + }; +} + +export function createFileAuditCatalogChecklistItemAcknowledgmentStore( + options: FileAuditCatalogChecklistItemAcknowledgmentStoreOptions, +): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + const filePath = resolve(options.filePath); + let accessQueue = Promise.resolve(); + + return { + async deleteCatalogChecklistItemAcknowledgment(catalogEntryId) { + return enqueueAccess(async () => + withMutableSnapshot(filePath, options, async (snapshot) => { + const existingEntry = snapshot.acknowledgmentEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + if (!existingEntry) { + return undefined; + } + + await writeAuditCatalogChecklistItemAcknowledgmentSnapshot(filePath, { + acknowledgmentEntries: snapshot.acknowledgmentEntries.filter( + (entry) => entry.catalogEntryId !== catalogEntryId, + ), + }); + + return clone(existingEntry); + }), + ); + }, + + async getCatalogChecklistItemAcknowledgment(catalogEntryId) { + return enqueueAccess(async () => + withReadOnlySnapshot(filePath, (snapshot) => { + const acknowledgment = snapshot.acknowledgmentEntries.find( + (entry) => entry.catalogEntryId === catalogEntryId, + ); + + return acknowledgment ? clone(acknowledgment) : undefined; + }), + ); + }, + + async listCatalogChecklistItemAcknowledgments() { + return enqueueAccess(async () => + withReadOnlySnapshot(filePath, (snapshot) => + snapshot.acknowledgmentEntries + .slice() + .sort(compareCrossRunAuditCatalogChecklistItemAcknowledgment) + .map((entry) => clone(entry)), + ), + ); + }, + + async saveCatalogChecklistItemAcknowledgment(entry) { + return enqueueAccess(async () => + withMutableSnapshot(filePath, options, async (snapshot) => { + const nextAcknowledgmentEntries = [ + ...snapshot.acknowledgmentEntries.filter( + (candidate) => candidate.catalogEntryId !== entry.catalogEntryId, + ), + clone(entry), + ].sort(compareCrossRunAuditCatalogChecklistItemAcknowledgment); + + await writeAuditCatalogChecklistItemAcknowledgmentSnapshot(filePath, { + acknowledgmentEntries: nextAcknowledgmentEntries, + }); + + return clone(entry); + }), + ); + }, + }; + + async function enqueueAccess( + accessOperation: () => Promise, + ): Promise { + const pendingAccess = accessQueue.then(accessOperation, accessOperation); + accessQueue = pendingAccess.then( + () => undefined, + () => undefined, + ); + + return pendingAccess; + } +} + +export function createPostgresAuditCatalogChecklistItemAcknowledgmentStore( + options: PostgresAuditCatalogChecklistItemAcknowledgmentStoreOptions = {}, +): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + const pool = options.pool ?? createDefaultPool(options.databaseUrl); + let schemaReadyPromise: Promise | undefined; + + return createDatabaseAuditCatalogChecklistItemAcknowledgmentStore({ + 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 createSqliteAuditCatalogChecklistItemAcknowledgmentStore( + options: SqliteAuditCatalogChecklistItemAcknowledgmentStoreOptions, +): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + const filePath = resolve(options.filePath); + let accessQueue = Promise.resolve(); + let schemaReadyPromise: Promise | undefined; + + return createDatabaseAuditCatalogChecklistItemAcknowledgmentStore({ + 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 createDatabaseAuditCatalogChecklistItemAcknowledgmentStore(options: { + readonly ensureSchema: () => Promise; + readonly withReadOnlyClient: ( + task: (client: SqlClient) => Promise, + ) => Promise; + readonly withTransaction: ( + task: (client: SqlClient) => Promise, + ) => Promise; +}): CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + return { + async deleteCatalogChecklistItemAcknowledgment(catalogEntryId) { + await options.ensureSchema(); + + return options.withTransaction(async (client) => { + const existingRows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_acknowledgment + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + if (!existingRows[0]) { + return undefined; + } + + await client.execute( + `DELETE FROM runroot_audit_catalog_checklist_item_acknowledgment + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + return deserializeRow( + existingRows[0].data, + ); + }); + }, + + async getCatalogChecklistItemAcknowledgment(catalogEntryId) { + await options.ensureSchema(); + + return options.withReadOnlyClient(async (client) => { + const rows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_acknowledgment + WHERE catalog_entry_id = ?`, + [catalogEntryId], + ); + + return rows[0] + ? deserializeRow( + rows[0].data, + ) + : undefined; + }); + }, + + async listCatalogChecklistItemAcknowledgments() { + await options.ensureSchema(); + + return options.withReadOnlyClient(async (client) => { + const rows = await client.queryRows<{ data: string }>( + `SELECT data + FROM runroot_audit_catalog_checklist_item_acknowledgment`, + ); + + return rows + .map((row) => + deserializeRow( + row.data, + ), + ) + .sort(compareCrossRunAuditCatalogChecklistItemAcknowledgment); + }); + }, + + async saveCatalogChecklistItemAcknowledgment(entry) { + await options.ensureSchema(); + + return options.withTransaction(async (client) => { + await client.execute( + `INSERT INTO runroot_audit_catalog_checklist_item_acknowledgment ( + catalog_entry_id, + kind, + operator_id, + scope_id, + acknowledgment_note, + acknowledgment_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, + acknowledgment_note = excluded.acknowledgment_note, + acknowledgment_items = excluded.acknowledgment_items, + created_at = excluded.created_at, + updated_at = excluded.updated_at, + data = excluded.data`, + [ + entry.catalogEntryId, + entry.kind, + entry.operatorId, + entry.scopeId, + entry.acknowledgmentNote ?? null, + JSON.stringify(entry.items), + entry.createdAt, + entry.updatedAt, + serializeRow(entry), + ], + ); + + return clone(entry); + }); + }, + }; +} + +export function resolveAuditCatalogChecklistItemAcknowledgmentFilePath( + workspacePath: string, +): string { + const resolvedPath = resolve(workspacePath); + const parsedPath = parse(resolvedPath); + + return join( + parsedPath.dir, + `${parsedPath.name}.audit-catalog-checklist-item-acknowledgment.json`, + ); +} + +async function withReadOnlySnapshot( + filePath: string, + action: ( + snapshot: AuditCatalogChecklistItemAcknowledgmentSnapshot, + ) => TValue | Promise, +): Promise { + const snapshot = + await readAuditCatalogChecklistItemAcknowledgmentSnapshot(filePath); + + return action(snapshot); +} + +async function withMutableSnapshot( + filePath: string, + options: Pick< + FileAuditCatalogChecklistItemAcknowledgmentStoreOptions, + "lockRetryDelayMs" | "lockTimeoutMs" + >, + action: ( + snapshot: AuditCatalogChecklistItemAcknowledgmentSnapshot, + ) => Promise, +): Promise { + await ensureParentDirectory(filePath); + + return withFileLock(filePath, options, async () => + action(await readAuditCatalogChecklistItemAcknowledgmentSnapshot(filePath)), + ); +} + +async function readAuditCatalogChecklistItemAcknowledgmentSnapshot( + filePath: string, +): Promise { + try { + const rawSnapshot = await readFile(filePath, "utf8"); + const parsedSnapshot = JSON.parse( + rawSnapshot, + ) as AuditCatalogChecklistItemAcknowledgmentSnapshot; + + return { + acknowledgmentEntries: [ + ...(parsedSnapshot.acknowledgmentEntries ?? []), + ].map((entry) => clone(entry)), + }; + } catch (error) { + if (isMissingFileError(error)) { + return { + acknowledgmentEntries: [], + }; + } + + throw error; + } +} + +async function writeAuditCatalogChecklistItemAcknowledgmentSnapshot( + filePath: string, + snapshot: AuditCatalogChecklistItemAcknowledgmentSnapshot, +): 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 acknowledgment 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< + | FileAuditCatalogChecklistItemAcknowledgmentStoreOptions + | SqliteAuditCatalogChecklistItemAcknowledgmentStoreOptions, + "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 acknowledgment 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 21a327a..d2eebd7 100644 --- a/packages/persistence/src/index.ts +++ b/packages/persistence/src/index.ts @@ -13,6 +13,19 @@ export { resolveAuditCatalogAssignmentChecklistsFilePath, type SqliteAuditCatalogAssignmentChecklistStoreOptions, } from "./catalog-assignment-checklist-store"; +export { + type ConfiguredAuditCatalogChecklistItemAcknowledgmentStoreOptions, + createConfiguredAuditCatalogChecklistItemAcknowledgmentStore, + createFileAuditCatalogChecklistItemAcknowledgmentStore, + createInMemoryAuditCatalogChecklistItemAcknowledgmentStore, + createPostgresAuditCatalogChecklistItemAcknowledgmentStore, + createSqliteAuditCatalogChecklistItemAcknowledgmentStore, + type FileAuditCatalogChecklistItemAcknowledgmentStoreOptions, + type InMemoryAuditCatalogChecklistItemAcknowledgmentStoreOptions, + type PostgresAuditCatalogChecklistItemAcknowledgmentStoreOptions, + resolveAuditCatalogChecklistItemAcknowledgmentFilePath, + type SqliteAuditCatalogChecklistItemAcknowledgmentStoreOptions, +} from "./catalog-checklist-item-acknowledgment-store"; export { type ConfiguredAuditCatalogChecklistItemAttestationStoreOptions, createConfiguredAuditCatalogChecklistItemAttestationStore, @@ -212,7 +225,7 @@ export const persistencePackageBoundary = { kind: "package", phaseOwned: 2, responsibility: - "Repository contracts, checkpoint storage, database adapters, dispatch queue persistence seams, tool-history storage adapters, additive saved-audit-view adapters, additive audit-view-catalog adapters, additive catalog-visibility adapters, additive catalog-review-signal adapters, additive catalog-review-assignment adapters, additive catalog-assignment-checklist adapters, additive checklist-item-progress adapters, additive checklist-item-blocker adapters, additive checklist-item-resolution adapters, additive checklist-item-verification adapters, additive checklist-item-evidence adapters, and additive checklist-item-attestation adapters.", + "Repository contracts, checkpoint storage, database adapters, dispatch queue persistence seams, tool-history storage adapters, additive saved-audit-view adapters, additive audit-view-catalog adapters, additive catalog-visibility adapters, additive catalog-review-signal adapters, additive catalog-review-assignment adapters, additive catalog-assignment-checklist adapters, additive checklist-item-progress adapters, additive checklist-item-blocker adapters, additive checklist-item-resolution adapters, additive checklist-item-verification adapters, additive checklist-item-evidence adapters, additive checklist-item-attestation adapters, and additive checklist-item-acknowledgment adapters.", publicSurface: [ "repository interfaces", "storage adapters", diff --git a/packages/persistence/src/migrations.ts b/packages/persistence/src/migrations.ts index e85de6b..4213ad1 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: "0016_audit_catalog_checklist_item_acknowledgment", + postgres: [ + `CREATE TABLE IF NOT EXISTS runroot_audit_catalog_checklist_item_acknowledgment ( + catalog_entry_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + operator_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + acknowledgment_note TEXT, + acknowledgment_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_acknowledgment_scope + ON runroot_audit_catalog_checklist_item_acknowledgment (scope_id, updated_at DESC, catalog_entry_id ASC)`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_acknowledgment_operator + ON runroot_audit_catalog_checklist_item_acknowledgment (operator_id, updated_at DESC, catalog_entry_id ASC)`, + ], + sqlite: [ + `CREATE TABLE IF NOT EXISTS runroot_audit_catalog_checklist_item_acknowledgment ( + catalog_entry_id TEXT PRIMARY KEY, + kind TEXT NOT NULL, + operator_id TEXT NOT NULL, + scope_id TEXT NOT NULL, + acknowledgment_note TEXT, + acknowledgment_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_acknowledgment_scope + ON runroot_audit_catalog_checklist_item_acknowledgment (scope_id, updated_at DESC, catalog_entry_id ASC)`, + `CREATE INDEX IF NOT EXISTS idx_runroot_audit_catalog_checklist_item_acknowledgment_operator + ON runroot_audit_catalog_checklist_item_acknowledgment (operator_id, updated_at DESC, catalog_entry_id ASC)`, + ], + }, ] as const satisfies readonly PersistenceMigration[]; export function getRuntimePersistenceMigrationStatements( diff --git a/packages/replay/src/checklist-item-acknowledgment.test.ts b/packages/replay/src/checklist-item-acknowledgment.test.ts new file mode 100644 index 0000000..0348045 --- /dev/null +++ b/packages/replay/src/checklist-item-acknowledgment.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; + +import { + compareCrossRunAuditCatalogChecklistItemAcknowledgment, + createCrossRunAuditCatalogChecklistItemAcknowledgment, + normalizeChecklistItemAcknowledgmentItems, +} from "./checklist-item-acknowledgment"; + +describe("@runroot/replay audit catalog checklist item acknowledgment", () => { + it("creates checklist item acknowledgment with normalized metadata", () => { + const acknowledgment = + createCrossRunAuditCatalogChecklistItemAcknowledgment({ + acknowledgmentNote: " Evidence is sufficient for the queued closeout ", + catalogEntryId: " catalog_entry_acknowledgment ", + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + { + item: "Close backup handoff", + state: "unacknowledged", + }, + ], + operatorId: " ops_oncall ", + scopeId: " ops ", + timestamp: "2026-04-02T04:00:00.000Z", + }); + + expect(acknowledgment).toEqual({ + acknowledgmentNote: "Evidence is sufficient for the queued closeout", + catalogEntryId: "catalog_entry_acknowledgment", + createdAt: "2026-04-02T04:00:00.000Z", + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + { + item: "Close backup handoff", + state: "unacknowledged", + }, + ], + kind: "catalog-checklist-item-acknowledgment", + operatorId: "ops_oncall", + scopeId: "ops", + updatedAt: "2026-04-02T04:00:00.000Z", + }); + }); + + it("restricts checklist item acknowledgment to shared evidence items", () => { + expect(() => + normalizeChecklistItemAcknowledgmentItems( + [ + { + item: "Missing item", + state: "acknowledged", + }, + ], + ["Validate queued follow-up", "Close backup handoff"], + ), + ).toThrow( + 'Catalog checklist item acknowledgment "Missing item" is not defined on the shared checklist item attestation layer.', + ); + }); + + it("sorts newer acknowledgment entries ahead of older acknowledgment entries", () => { + const olderEntry = createCrossRunAuditCatalogChecklistItemAcknowledgment({ + catalogEntryId: "catalog_entry_older", + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + ], + operatorId: "ops_oncall", + scopeId: "ops", + timestamp: "2026-04-02T04:00:00.000Z", + }); + const newerEntry = createCrossRunAuditCatalogChecklistItemAcknowledgment({ + catalogEntryId: "catalog_entry_newer", + items: [ + { + item: "Validate queued follow-up", + state: "acknowledged", + }, + ], + operatorId: "ops_oncall", + scopeId: "ops", + timestamp: "2026-04-02T04:00:01.000Z", + }); + + expect( + [olderEntry, newerEntry].sort( + compareCrossRunAuditCatalogChecklistItemAcknowledgment, + ), + ).toEqual([newerEntry, olderEntry]); + }); +}); diff --git a/packages/replay/src/checklist-item-acknowledgment.ts b/packages/replay/src/checklist-item-acknowledgment.ts new file mode 100644 index 0000000..df81594 --- /dev/null +++ b/packages/replay/src/checklist-item-acknowledgment.ts @@ -0,0 +1,193 @@ +import type { + CrossRunAuditCatalogChecklistItemAttestationApplication, + CrossRunAuditCatalogChecklistItemAttestationView, +} from "./checklist-item-attestation"; + +export type CrossRunAuditCatalogChecklistItemAcknowledgmentState = + | "acknowledged" + | "unacknowledged"; + +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentItem { + readonly item: string; + readonly state: CrossRunAuditCatalogChecklistItemAcknowledgmentState; +} + +export interface CrossRunAuditCatalogChecklistItemAcknowledgment { + readonly acknowledgmentNote?: string; + readonly catalogEntryId: string; + readonly createdAt: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemAcknowledgmentItem[]; + readonly kind: "catalog-checklist-item-acknowledgment"; + readonly operatorId: string; + readonly scopeId: string; + readonly updatedAt: string; +} + +export interface CreateCrossRunAuditCatalogChecklistItemAcknowledgmentInput { + readonly acknowledgmentNote?: string; + readonly catalogEntryId: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemAcknowledgmentItem[]; + readonly operatorId: string; + readonly scopeId: string; + readonly timestamp: string; +} + +export interface UpdateCrossRunAuditCatalogChecklistItemAcknowledgmentInput { + readonly acknowledgmentNote?: string; + readonly items: readonly CrossRunAuditCatalogChecklistItemAcknowledgmentItem[]; +} + +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentStore { + deleteCatalogChecklistItemAcknowledgment( + catalogEntryId: string, + ): Promise; + getCatalogChecklistItemAcknowledgment( + catalogEntryId: string, + ): Promise; + listCatalogChecklistItemAcknowledgments(): Promise< + readonly CrossRunAuditCatalogChecklistItemAcknowledgment[] + >; + saveCatalogChecklistItemAcknowledgment( + acknowledgment: CrossRunAuditCatalogChecklistItemAcknowledgment, + ): Promise; +} + +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentView { + readonly acknowledgment: CrossRunAuditCatalogChecklistItemAcknowledgment; + readonly attestation: CrossRunAuditCatalogChecklistItemAttestationView; +} + +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentCollection { + readonly items: readonly CrossRunAuditCatalogChecklistItemAcknowledgmentView[]; + readonly totalCount: number; +} + +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentApplication { + readonly application: CrossRunAuditCatalogChecklistItemAttestationApplication; + readonly acknowledgment: CrossRunAuditCatalogChecklistItemAcknowledgmentView; +} + +export function createCrossRunAuditCatalogChecklistItemAcknowledgment( + input: CreateCrossRunAuditCatalogChecklistItemAcknowledgmentInput, +): CrossRunAuditCatalogChecklistItemAcknowledgment { + const catalogEntryId = input.catalogEntryId.trim(); + const operatorId = input.operatorId.trim(); + const scopeId = input.scopeId.trim(); + const items = normalizeChecklistItemAcknowledgmentItems(input.items); + const acknowledgmentNote = normalizeAcknowledgmentNote( + input.acknowledgmentNote, + ); + + if (catalogEntryId.length === 0) { + throw new Error( + "Catalog checklist item acknowledgments require a catalog entry id.", + ); + } + + if (operatorId.length === 0) { + throw new Error( + "Catalog checklist item acknowledgments require an operator id.", + ); + } + + if (scopeId.length === 0) { + throw new Error( + "Catalog checklist item acknowledgments require a scope id.", + ); + } + + if (items.length === 0) { + throw new Error( + "Catalog checklist item acknowledgments require at least one checklist item acknowledgment entry.", + ); + } + + return { + ...(acknowledgmentNote ? { acknowledgmentNote } : {}), + catalogEntryId, + createdAt: input.timestamp, + items, + kind: "catalog-checklist-item-acknowledgment", + operatorId, + scopeId, + updatedAt: input.timestamp, + }; +} + +export function compareCrossRunAuditCatalogChecklistItemAcknowledgment( + left: CrossRunAuditCatalogChecklistItemAcknowledgment, + right: CrossRunAuditCatalogChecklistItemAcknowledgment, +): number { + return ( + compareAcknowledgmentPriority(left, right) || + right.updatedAt.localeCompare(left.updatedAt) || + right.createdAt.localeCompare(left.createdAt) || + left.catalogEntryId.localeCompare(right.catalogEntryId) + ); +} + +export function normalizeChecklistItemAcknowledgmentItems( + items: + | readonly CrossRunAuditCatalogChecklistItemAcknowledgmentItem[] + | undefined, + allowedItems?: readonly string[], +): readonly CrossRunAuditCatalogChecklistItemAcknowledgmentItem[] { + const allowedItemSet = allowedItems + ? new Set(allowedItems.map((item) => item.trim()).filter(Boolean)) + : undefined; + const dedupedItems = new Map< + string, + CrossRunAuditCatalogChecklistItemAcknowledgmentState + >(); + + for (const entry of items ?? []) { + const item = entry.item.trim(); + + if (item.length === 0) { + continue; + } + + if (entry.state !== "acknowledged" && entry.state !== "unacknowledged") { + throw new Error( + `Catalog checklist item acknowledgments require state acknowledged|unacknowledged for "${item}".`, + ); + } + + if (allowedItemSet && !allowedItemSet.has(item)) { + throw new Error( + `Catalog checklist item acknowledgment "${item}" is not defined on the shared checklist item attestation layer.`, + ); + } + + dedupedItems.set(item, entry.state); + } + + return [...dedupedItems.entries()].map(([item, state]) => ({ + item, + state, + })); +} + +function compareAcknowledgmentPriority( + left: CrossRunAuditCatalogChecklistItemAcknowledgment, + right: CrossRunAuditCatalogChecklistItemAcknowledgment, +): number { + const leftPriority = left.items.some((item) => item.state === "acknowledged") + ? 0 + : 1; + const rightPriority = right.items.some( + (item) => item.state === "acknowledged", + ) + ? 0 + : 1; + + return leftPriority - rightPriority; +} + +function normalizeAcknowledgmentNote( + 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 a22d60d..faf4beb 100644 --- a/packages/replay/src/index.ts +++ b/packages/replay/src/index.ts @@ -34,6 +34,20 @@ export { createCrossRunAuditCatalogEntry, type PublishCrossRunAuditCatalogEntryInput, } from "./catalog"; +export { + type CreateCrossRunAuditCatalogChecklistItemAcknowledgmentInput, + type CrossRunAuditCatalogChecklistItemAcknowledgment, + type CrossRunAuditCatalogChecklistItemAcknowledgmentApplication, + type CrossRunAuditCatalogChecklistItemAcknowledgmentCollection, + type CrossRunAuditCatalogChecklistItemAcknowledgmentItem, + type CrossRunAuditCatalogChecklistItemAcknowledgmentState, + type CrossRunAuditCatalogChecklistItemAcknowledgmentStore, + type CrossRunAuditCatalogChecklistItemAcknowledgmentView, + compareCrossRunAuditCatalogChecklistItemAcknowledgment, + createCrossRunAuditCatalogChecklistItemAcknowledgment, + normalizeChecklistItemAcknowledgmentItems, + type UpdateCrossRunAuditCatalogChecklistItemAcknowledgmentInput, +} from "./checklist-item-acknowledgment"; export { type CreateCrossRunAuditCatalogChecklistItemAttestationInput, type CrossRunAuditCatalogChecklistItemAttestation, @@ -150,6 +164,7 @@ export { } from "./navigation"; export { type CrossRunAuditCatalogAssignmentChecklistQuery, + type CrossRunAuditCatalogChecklistItemAcknowledgmentQuery, type CrossRunAuditCatalogChecklistItemAttestationQuery, type CrossRunAuditCatalogChecklistItemBlockerQuery, type CrossRunAuditCatalogChecklistItemEvidenceQuery, @@ -166,6 +181,7 @@ export { type CrossRunAuditReader, type CrossRunAuditSavedViewQuery, createCrossRunAuditCatalogAssignmentChecklistQuery, + createCrossRunAuditCatalogChecklistItemAcknowledgmentQuery, createCrossRunAuditCatalogChecklistItemAttestationQuery, createCrossRunAuditCatalogChecklistItemBlockerQuery, createCrossRunAuditCatalogChecklistItemEvidenceQuery, diff --git a/packages/replay/src/query.ts b/packages/replay/src/query.ts index 532bf2f..83414d7 100644 --- a/packages/replay/src/query.ts +++ b/packages/replay/src/query.ts @@ -25,6 +25,17 @@ import { createCrossRunAuditCatalogEntry, type PublishCrossRunAuditCatalogEntryInput, } from "./catalog"; +import { + type CrossRunAuditCatalogChecklistItemAcknowledgment, + type CrossRunAuditCatalogChecklistItemAcknowledgmentApplication, + type CrossRunAuditCatalogChecklistItemAcknowledgmentCollection, + type CrossRunAuditCatalogChecklistItemAcknowledgmentStore, + type CrossRunAuditCatalogChecklistItemAcknowledgmentView, + compareCrossRunAuditCatalogChecklistItemAcknowledgment, + createCrossRunAuditCatalogChecklistItemAcknowledgment, + normalizeChecklistItemAcknowledgmentItems, + type UpdateCrossRunAuditCatalogChecklistItemAcknowledgmentInput, +} from "./checklist-item-acknowledgment"; import { type CrossRunAuditCatalogChecklistItemAttestation, type CrossRunAuditCatalogChecklistItemAttestationApplication, @@ -433,6 +444,32 @@ export interface CrossRunAuditCatalogChecklistItemEvidenceQuery { ): Promise; } +export interface CrossRunAuditCatalogChecklistItemAcknowledgmentQuery { + applyCatalogEntry( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise< + CrossRunAuditCatalogChecklistItemAcknowledgmentApplication | undefined + >; + clearCatalogChecklistItemAcknowledgment( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + getCatalogChecklistItemAcknowledgment( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + listAcknowledgedCatalogEntries( + viewer: CrossRunAuditCatalogVisibilityViewer, + ): Promise; + setCatalogChecklistItemAcknowledgment( + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, + input: UpdateCrossRunAuditCatalogChecklistItemAcknowledgmentInput, + timestamp: string, + ): Promise; +} + export interface CrossRunAuditCatalogChecklistItemAttestationQuery { applyCatalogEntry( id: string, @@ -2100,6 +2137,161 @@ export function createCrossRunAuditCatalogChecklistItemAttestationQuery( }; } +export function createCrossRunAuditCatalogChecklistItemAcknowledgmentQuery( + store: CrossRunAuditCatalogChecklistItemAcknowledgmentStore, + attestationEntries: CrossRunAuditCatalogChecklistItemAttestationQuery, +): CrossRunAuditCatalogChecklistItemAcknowledgmentQuery { + return { + async applyCatalogEntry(id, viewer) { + const acknowledgment = + await resolveCatalogChecklistItemAcknowledgmentView( + store, + attestationEntries, + id, + viewer, + ); + + if (!acknowledgment) { + return undefined; + } + + const application = await attestationEntries.applyCatalogEntry( + id, + viewer, + ); + + if (!application) { + return undefined; + } + + return { + acknowledgment, + application, + }; + }, + + async clearCatalogChecklistItemAcknowledgment(id, viewer) { + const acknowledgment = + await resolveCatalogChecklistItemAcknowledgmentView( + store, + attestationEntries, + id, + viewer, + ); + + if (!acknowledgment) { + return undefined; + } + + await store.deleteCatalogChecklistItemAcknowledgment(id); + + return acknowledgment; + }, + + async getCatalogChecklistItemAcknowledgment(id, viewer) { + return resolveCatalogChecklistItemAcknowledgmentView( + store, + attestationEntries, + id, + viewer, + ); + }, + + async listAcknowledgedCatalogEntries(viewer) { + const acknowledgmentEntries = [ + ...(await store.listCatalogChecklistItemAcknowledgments()), + ].sort(compareCrossRunAuditCatalogChecklistItemAcknowledgment); + const items = ( + await Promise.all( + acknowledgmentEntries.map(async (acknowledgment) => + resolveCatalogChecklistItemAcknowledgmentFromValue( + attestationEntries, + acknowledgment, + viewer, + ), + ), + ) + ).filter( + ( + acknowledgment, + ): acknowledgment is CrossRunAuditCatalogChecklistItemAcknowledgmentView => + acknowledgment !== undefined, + ); + + return { + items, + totalCount: items.length, + }; + }, + + async setCatalogChecklistItemAcknowledgment(id, viewer, input, timestamp) { + const attestation = + await attestationEntries.getCatalogChecklistItemAttestation(id, viewer); + + if (!attestation) { + throw new Error( + `Catalog entry "${id}" is not attested and visible to operator "${viewer.operatorId}".`, + ); + } + + const allowedItems = attestation.attestation.items.map( + (item) => item.item, + ); + const normalizedItems = normalizeChecklistItemAcknowledgmentItems( + input.items, + allowedItems, + ); + + if (normalizedItems.length === 0) { + throw new Error( + "Catalog checklist item acknowledgments require at least one checklist item acknowledgment entry.", + ); + } + + const existingAcknowledgment = + await store.getCatalogChecklistItemAcknowledgment(id); + const normalizedAcknowledgmentNote = input.acknowledgmentNote?.trim(); + const acknowledgment = existingAcknowledgment + ? { + ...(input.acknowledgmentNote === undefined + ? existingAcknowledgment.acknowledgmentNote + ? { + acknowledgmentNote: + existingAcknowledgment.acknowledgmentNote, + } + : {} + : normalizedAcknowledgmentNote + ? { acknowledgmentNote: normalizedAcknowledgmentNote } + : {}), + catalogEntryId: id, + createdAt: existingAcknowledgment.createdAt, + items: normalizedItems, + kind: existingAcknowledgment.kind, + operatorId: viewer.operatorId, + scopeId: viewer.scopeId, + updatedAt: timestamp, + } + : createCrossRunAuditCatalogChecklistItemAcknowledgment({ + ...(normalizedAcknowledgmentNote + ? { acknowledgmentNote: normalizedAcknowledgmentNote } + : {}), + catalogEntryId: id, + items: normalizedItems, + operatorId: viewer.operatorId, + scopeId: viewer.scopeId, + timestamp, + }); + + await store.saveCatalogChecklistItemAcknowledgment(acknowledgment); + + return { + acknowledgment, + attestation, + }; + }, + }; +} + async function resolveCatalogEntryView( store: CrossRunAuditCatalogStore, savedViews: CrossRunAuditSavedViewQuery, @@ -2575,3 +2767,56 @@ async function resolveCatalogChecklistItemAttestationFromValue( evidence, }; } + +async function resolveCatalogChecklistItemAcknowledgmentView( + store: CrossRunAuditCatalogChecklistItemAcknowledgmentStore, + attestationQuery: CrossRunAuditCatalogChecklistItemAttestationQuery, + id: string, + viewer: CrossRunAuditCatalogVisibilityViewer, +): Promise { + const acknowledgment = await store.getCatalogChecklistItemAcknowledgment(id); + + return acknowledgment + ? resolveCatalogChecklistItemAcknowledgmentFromValue( + attestationQuery, + acknowledgment, + viewer, + ) + : undefined; +} + +async function resolveCatalogChecklistItemAcknowledgmentFromValue( + attestationQuery: CrossRunAuditCatalogChecklistItemAttestationQuery, + acknowledgment: CrossRunAuditCatalogChecklistItemAcknowledgment, + viewer: CrossRunAuditCatalogVisibilityViewer, +): Promise { + if (acknowledgment.scopeId !== viewer.scopeId) { + return undefined; + } + + const attestation = await attestationQuery.getCatalogChecklistItemAttestation( + acknowledgment.catalogEntryId, + viewer, + ); + + if (!attestation) { + return undefined; + } + + const normalizedItems = normalizeChecklistItemAcknowledgmentItems( + acknowledgment.items, + attestation.attestation.items.map((item) => item.item), + ); + + if (normalizedItems.length === 0) { + return undefined; + } + + return { + acknowledgment: { + ...acknowledgment, + items: normalizedItems, + }, + attestation, + }; +} From 89137bf12abc0b4d278917a341f523a407639266 Mon Sep 17 00:00:00 2001 From: xiwuqi Date: Fri, 3 Apr 2026 01:18:54 -0500 Subject: [PATCH 3/4] feat(phase-27): wire checklist item acknowledgments through existing seams --- apps/api/src/server.ts | 102 ++++++++ apps/web/src/app/runs/catalog/route.ts | 120 +++++++++ apps/web/src/app/runs/page.tsx | 25 ++ apps/web/src/components/console.tsx | 330 +++++++++++++++++++++++++ apps/web/src/lib/runroot-api.ts | 96 +++++++ packages/cli/src/index.ts | 88 +++++++ packages/config/src/index.ts | 7 +- packages/sdk/src/errors.ts | 1 + packages/sdk/src/index.ts | 1 + packages/sdk/src/operator-service.ts | 134 ++++++++++ 10 files changed, 901 insertions(+), 3 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 15f6f4b..c61b7b0 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -279,6 +279,12 @@ export function buildServer(options: BuildServerOptions = {}) { })), ); + app.get("/audit/catalog/acknowledged", async (_request, reply) => + handleOperatorResponse(reply, async () => ({ + acknowledged: await operator.listAcknowledgedCatalogEntries(), + })), + ); + app.post("/audit/saved-views", async (request, reply) => handleOperatorResponse(reply, async () => { const body = request.body as { @@ -512,6 +518,22 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.get( + "/audit/catalog/:catalogEntryId/acknowledgment", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + acknowledgment: await operator.getCatalogChecklistItemAcknowledgment( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/catalog/:catalogEntryId", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -812,6 +834,42 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post( + "/audit/catalog/:catalogEntryId/acknowledgment", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + const body = request.body as { + readonly acknowledgmentNote?: string; + readonly items?: unknown; + }; + const items = readChecklistItemAcknowledgmentItems( + body?.items, + "items", + ); + + if (items.length === 0) { + throw new OperatorInputError( + "items must include at least one checklist item acknowledgment entry.", + ); + } + + return { + acknowledgment: await operator.acknowledgeCatalogEntry( + params.catalogEntryId, + { + ...(body?.acknowledgmentNote !== undefined + ? { acknowledgmentNote: body.acknowledgmentNote } + : {}), + items, + }, + ), + }; + }), + ); + app.post( "/audit/catalog/:catalogEntryId/review/clear", async (request, reply) => @@ -956,6 +1014,23 @@ export function buildServer(options: BuildServerOptions = {}) { }), ); + app.post( + "/audit/catalog/:catalogEntryId/acknowledgment/clear", + async (request, reply) => + handleOperatorResponse(reply, async () => { + const params = request.params as { + readonly catalogEntryId: string; + }; + + return { + acknowledgment: + await operator.clearCatalogChecklistItemAcknowledgment( + params.catalogEntryId, + ), + }; + }), + ); + app.get("/audit/saved-views/:savedViewId/apply", async (request, reply) => handleOperatorResponse(reply, async () => { const params = request.params as { @@ -1496,3 +1571,30 @@ function readChecklistItemAttestationItems( return value; } + +function readChecklistItemAcknowledgmentItems( + value: unknown, + fieldName: string, +): readonly { + readonly item: string; + readonly state: "acknowledged" | "unacknowledged"; +}[] { + if ( + !Array.isArray(value) || + !value.every( + (entry) => + typeof entry === "object" && + entry !== null && + "item" in entry && + typeof entry.item === "string" && + "state" in entry && + (entry.state === "acknowledged" || entry.state === "unacknowledged"), + ) + ) { + throw new OperatorInputError( + `${fieldName} must be an array of { item, state } objects with state acknowledged|unacknowledged.`, + ); + } + + return value; +} diff --git a/apps/web/src/app/runs/catalog/route.ts b/apps/web/src/app/runs/catalog/route.ts index d7f2483..c1c276c 100644 --- a/apps/web/src/app/runs/catalog/route.ts +++ b/apps/web/src/app/runs/catalog/route.ts @@ -504,6 +504,53 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "acknowledge") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + const acknowledgmentItems = readChecklistItemAcknowledgmentItems( + formData.get("acknowledgmentItems"), + ); + const acknowledgmentNoteValue = formData.get("acknowledgmentNote"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to update checklist item acknowledgment metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + if (acknowledgmentItems.length === 0) { + appendFlashMessage( + redirectUrl, + "error", + "At least one checklist item acknowledgment entry is required.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const acknowledgment = + await createRunrootApiClient().setAuditCatalogChecklistItemAcknowledgment( + catalogEntryId, + { + ...(typeof acknowledgmentNoteValue === "string" + ? { acknowledgmentNote: acknowledgmentNoteValue } + : {}), + items: acknowledgmentItems, + }, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item acknowledgments for ${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"); @@ -693,6 +740,33 @@ export async function POST(request: Request) { return Response.redirect(redirectUrl, 303); } + if (intent === "clear-acknowledgment") { + const catalogEntryId = readTrimmedFormValue(formData, "catalogEntryId"); + + if (!catalogEntryId) { + appendFlashMessage( + redirectUrl, + "error", + "A catalog entry id is required to clear checklist item acknowledgment metadata.", + ); + + return Response.redirect(redirectUrl, 303); + } + + const acknowledgment = + await createRunrootApiClient().clearAuditCatalogChecklistItemAcknowledgment( + catalogEntryId, + ); + + appendFlashMessage( + redirectUrl, + "notice", + `Checklist item acknowledgments for ${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"); @@ -1096,3 +1170,49 @@ function readChecklistItemAttestationItems( }; }); } + +function readChecklistItemAcknowledgmentItems( + value: FormDataEntryValue | null, +): readonly { + readonly item: string; + readonly state: "acknowledged" | "unacknowledged"; +}[] { + 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: "unacknowledged" 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 acknowledgment lines require a non-empty item.", + ); + } + + if (rawState !== "acknowledged" && rawState !== "unacknowledged") { + throw new Error( + `Checklist item acknowledgment state must be acknowledged or unacknowledged 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 5a9be54..6c4efeb 100644 --- a/apps/web/src/app/runs/page.tsx +++ b/apps/web/src/app/runs/page.tsx @@ -3,6 +3,7 @@ import { AuditViewCatalogsView, CatalogReviewAssignmentsView, CatalogReviewSignalsView, + ChecklistItemAcknowledgmentsView, ChecklistItemAttestationsView, ChecklistItemBlockersView, ChecklistItemEvidencesView, @@ -23,6 +24,7 @@ import { } from "../../lib/navigation"; import { type ApiAuditCatalogAssignmentChecklistView, + type ApiAuditCatalogChecklistItemAcknowledgmentView, type ApiAuditCatalogChecklistItemAttestationView, type ApiAuditCatalogChecklistItemBlockerView, type ApiAuditCatalogChecklistItemEvidenceView, @@ -62,6 +64,7 @@ export default async function RunsPage({ const drilldownFilters = readAuditDrilldownFilters(resolvedSearchParams); const [ runs, + acknowledgedEntries, blockedEntries, evidencedEntries, attestedEntries, @@ -78,6 +81,7 @@ export default async function RunsPage({ catalogChecklistItemBlocker, catalogChecklistItemResolution, catalogChecklistItemEvidence, + catalogChecklistItemAcknowledgment, catalogChecklistItemAttestation, catalogChecklistItemVerification, catalogChecklistItemProgress, @@ -86,6 +90,7 @@ export default async function RunsPage({ catalogReviewAssignment, ] = await Promise.all([ api.listRuns(), + api.listAcknowledgedAuditCatalogEntries(), api.listBlockedAuditCatalogEntries(), api.listEvidencedAuditCatalogEntries(), api.listAttestedAuditCatalogEntries(), @@ -123,6 +128,11 @@ export default async function RunsPage({ .getAuditCatalogChecklistItemEvidence(catalogEntryId) .catch(() => undefined) : Promise.resolve(undefined), + catalogEntryId + ? api + .getAuditCatalogChecklistItemAcknowledgment(catalogEntryId) + .catch(() => undefined) + : Promise.resolve(undefined), catalogEntryId ? api .getAuditCatalogChecklistItemAttestation(catalogEntryId) @@ -166,6 +176,9 @@ export default async function RunsPage({ let activeCatalogChecklistItemEvidence: | ApiAuditCatalogChecklistItemEvidenceView | undefined; + let activeCatalogChecklistItemAcknowledgment: + | ApiAuditCatalogChecklistItemAcknowledgmentView + | undefined; let activeCatalogChecklistItemAttestation: | ApiAuditCatalogChecklistItemAttestationView | undefined; @@ -187,6 +200,8 @@ export default async function RunsPage({ activeCatalogChecklistItemBlocker = catalogChecklistItemBlocker; activeCatalogChecklistItemResolution = catalogChecklistItemResolution; activeCatalogChecklistItemEvidence = catalogChecklistItemEvidence; + activeCatalogChecklistItemAcknowledgment = + catalogChecklistItemAcknowledgment; activeCatalogChecklistItemAttestation = catalogChecklistItemAttestation; activeCatalogChecklistItemVerification = catalogChecklistItemVerification; activeCatalogChecklistItemProgress = catalogChecklistItemProgress; @@ -226,6 +241,12 @@ export default async function RunsPage({ ? { activeCatalogChecklistItemEvidence } : {})} /> + ) { + return ( +
+
+
+
+ Phase 27 / Checklist Item Acknowledgments +
+

Checklist item acknowledgments

+

+ Track thin per-item acknowledgments and a single acknowledgment note + on attested presets without turning the console into an approval + product, workflow engine, or collaboration surface. +

+
+
+ {acknowledgedEntries.totalCount} acknowledged preset(s) +
+
+ + {activeCatalogChecklistItemAcknowledgment ? ( +
+ Active acknowledgments:{" "} + + { + activeCatalogChecklistItemAcknowledgment.attestation.evidence + .verification.resolution.blocker.progress.checklist.assignment + .review.visibility.catalogEntry.entry.name + } + + {" · "} + {formatAcknowledgmentSummary( + activeCatalogChecklistItemAcknowledgment.acknowledgment.items, + )} + {activeCatalogChecklistItemAcknowledgment.acknowledgment + .acknowledgmentNote + ? ` · ${activeCatalogChecklistItemAcknowledgment.acknowledgment.acknowledgmentNote}` + : ""} +
+ ) : null} + + {acknowledgedEntries.items.length === 0 ? ( +

+ No checklist item acknowledgments yet. Record attestations first, then + save thin acknowledgment metadata through the shared operator seam. +

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

    + {acknowledgmentView.acknowledgment.acknowledgmentNote} +

    + ) : null} +
    + operator {acknowledgmentView.acknowledgment.operatorId} · scope{" "} + {acknowledgmentView.acknowledgment.scopeId} +
    +
    + + Apply acknowledged preset + +
    + + + + +
    +
    + {activeCatalogChecklistItemAcknowledgment?.acknowledgment + .catalogEntryId === + acknowledgmentView.acknowledgment.catalogEntryId ? ( +
    + Active acknowledgments selected +
    + ) : null} +
  2. + ))} +
+ )} +
+ ); +} + export function CatalogReviewAssignmentsView({ activeCatalogReviewAssignment, assignedEntries, @@ -1445,12 +1613,14 @@ export function CatalogReviewSignalsView({ export function AuditViewCatalogsView({ activeCatalogEntry, activeCatalogChecklistItemBlocker, + activeCatalogChecklistItemAcknowledgment, activeCatalogChecklistItemAttestation, activeCatalogChecklistItemEvidence, activeCatalogChecklistItemResolution, activeCatalogChecklistItemVerification, activeCatalogChecklistItemProgress, assignedEntries, + acknowledgedEntries, attestedEntries, blockedEntries, catalogEntries, @@ -1463,12 +1633,14 @@ export function AuditViewCatalogsView({ }: Readonly<{ activeCatalogEntry?: ApiAuditCatalogVisibilityView; activeCatalogChecklistItemBlocker?: ApiAuditCatalogChecklistItemBlockerView; + activeCatalogChecklistItemAcknowledgment?: ApiAuditCatalogChecklistItemAcknowledgmentView; activeCatalogChecklistItemAttestation?: ApiAuditCatalogChecklistItemAttestationView; activeCatalogChecklistItemEvidence?: ApiAuditCatalogChecklistItemEvidenceView; activeCatalogChecklistItemResolution?: ApiAuditCatalogChecklistItemResolutionView; activeCatalogChecklistItemVerification?: ApiAuditCatalogChecklistItemVerificationView; activeCatalogChecklistItemProgress?: ApiAuditCatalogChecklistItemProgressView; assignedEntries: ApiAuditCatalogReviewAssignmentCollection; + acknowledgedEntries: ApiAuditCatalogChecklistItemAcknowledgmentCollection; attestedEntries: ApiAuditCatalogChecklistItemAttestationCollection; blockedEntries: ApiAuditCatalogChecklistItemBlockerCollection; catalogEntries: ApiAuditCatalogVisibilityCollection; @@ -1557,6 +1729,16 @@ export function AuditViewCatalogsView({ ] as const, ), ); + const acknowledgmentsByCatalogEntryId = new Map( + acknowledgedEntries.items.map( + (item) => + [ + item.attestation.evidence.verification.resolution.blocker.progress + .checklist.assignment.review.visibility.catalogEntry.entry.id, + item, + ] as const, + ), + ); return (
@@ -1634,6 +1816,15 @@ export function AuditViewCatalogsView({ : attestationsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); + const acknowledgment = + activeCatalogChecklistItemAcknowledgment?.attestation.evidence + .verification.resolution.blocker.progress.checklist + .assignment.review.visibility.catalogEntry.entry.id === + catalogEntry.catalogEntry.entry.id + ? activeCatalogChecklistItemAcknowledgment + : acknowledgmentsByCatalogEntryId.get( + catalogEntry.catalogEntry.entry.id, + ); const reviewSignal = reviewSignalsByCatalogEntryId.get( catalogEntry.catalogEntry.entry.id, ); @@ -1783,6 +1974,20 @@ export function AuditViewCatalogsView({ {attestation.attestation.attestationNote}

) : null} + {acknowledgment?.acknowledgment.items.length ? ( +

+ acknowledgments:{" "} + {acknowledgment.acknowledgment.items + .map((item) => `${item.state}: ${item.item}`) + .join(", ")} +

+ ) : null} + {acknowledgment?.acknowledgment.acknowledgmentNote ? ( +

+ acknowledgment note:{" "} + {acknowledgment.acknowledgment.acknowledgmentNote} +

+ ) : null}
saved view {catalogEntry.catalogEntry.savedView.id} {catalogEntry.catalogEntry.savedView.refs.auditViewRunId @@ -1822,6 +2027,9 @@ export function AuditViewCatalogsView({ {attestation ? ` · attestation owner ${attestation.attestation.operatorId}` : ""} + {acknowledgment + ? ` · acknowledgment owner ${acknowledgment.acknowledgment.operatorId}` + : ""}
summary filters{" "} @@ -2313,6 +2521,66 @@ export function AuditViewCatalogsView({
) : null} + {attestation ? ( +
+ + + +
+