From fc8dc662dd86834ffc813cf0a12eed2a784af6cf Mon Sep 17 00:00:00 2001 From: "quality-runtime[bot]" <330432719+quality-runtime[bot]@users.noreply.github.com> Date: Sun, 20 Sep 2026 00:24:40 +0200 Subject: [PATCH] feat: record evidence and attest it Evidence records that a control was operated, dated by when it happened. Attesting it is a signature: it requires If-Match quoting the version that was read, is refused under impersonation, and after it PostgreSQL will not let the application change or remove the row. Files follow separately. --- ARCHITECTURE.md | 2 + apps/server/app.ts | 2 + apps/server/audit.test.ts | 20 + apps/server/audit.ts | 4 +- apps/server/auth.ts | 2 +- apps/server/concurrency.test.ts | 388 ++++++++- apps/server/controls.test.ts | 36 +- apps/server/controls.ts | 6 +- apps/server/documented-setup.test.ts | 13 + apps/server/evidence.test.ts | 976 ++++++++++++++++++++++ apps/server/evidence.ts | 585 +++++++++++++ apps/server/openapi.test.ts | 116 ++- apps/server/openapi.ts | 119 ++- apps/server/organization.ts | 8 + apps/server/preconditions.ts | 6 +- apps/server/privileges.test.ts | 107 +-- apps/server/validation.ts | 33 +- docs/adr/0012-evidence-and-attestation.md | 63 ++ docs/adr/0019-conditional-writes.md | 6 +- docs/data-model.md | 22 +- docs/development.md | 2 +- docs/product.md | 6 +- docs/security.md | 2 +- 23 files changed, 2396 insertions(+), 128 deletions(-) create mode 100644 apps/server/evidence.test.ts create mode 100644 apps/server/evidence.ts create mode 100644 docs/adr/0012-evidence-and-attestation.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d24a362..8f18d78 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -235,6 +235,8 @@ Changes made by a domain mutation request are audited. What a _foreign key_ does **VERSION-01 — Historical state is preserved where required** Controlled or finalized records must not silently lose historical state. +"Silently" is load-bearing, and the qualifications are deliberate. Attested evidence cannot be changed or removed by the application at all ([ADR 0012](docs/adr/0012-evidence-and-attestation.md)). A record that never claimed anything is not controlled and may be discarded outright — a draft control, for one ([ADR 0017](docs/adr/0017-discarding-a-draft-control.md)). And removing a tenant removes its history, which is why that is an operator's act with a credential the server does not hold ([ADR 0014](docs/adr/0014-the-runtime-role-owns-nothing.md)) rather than something the API offers. + **EXT-01 — Extensions add rather than patch** Customization prefers explicit composition points over modifications to core implementation. diff --git a/apps/server/app.ts b/apps/server/app.ts index 0effe74..eb066fe 100644 --- a/apps/server/app.ts +++ b/apps/server/app.ts @@ -12,6 +12,7 @@ import { controls } from "./controls.ts"; import { failure } from "./responses.ts"; import { openApiDocument, openApiPath, referencePath } from "./openapi.ts"; import { organizationContext } from "./organization.ts"; +import { evidence } from "./evidence.ts"; import { history } from "./history.ts"; import { requirements } from "./requirements.ts"; import { standards } from "./standards.ts"; @@ -108,5 +109,6 @@ export function createApp({ .route(tenant, history) .route(tenant, standards) .route(tenant, requirements) + .route(tenant, evidence) ); } diff --git a/apps/server/audit.test.ts b/apps/server/audit.test.ts index 2e1099e..c4a161f 100644 --- a/apps/server/audit.test.ts +++ b/apps/server/audit.test.ts @@ -506,6 +506,26 @@ describe("reading an organization's history", () => { expect(data.every((event) => event.resourceId === control.id)).toBe(true); }); + it("includes control and evidence events in the same history", async () => { + const control = await given(acme, { name: "With evidence" }); + const recorded = await app.request( + `/api/v1/organizations/${acme.organizationId}/controls/${control.id}/evidence`, + { + method: "POST", + headers: { cookie: acme.cookie, "content-type": "application/json" }, + body: JSON.stringify({ title: "Minutes", occurredAt: "2026-07-01T09:00:00.000Z" }), + }, + ); + expect(recorded.status).toBe(201); + const evidenceId = (await json<{ data: { id: string } }>(recorded)).data.id; + + const { data } = await readHistory(acme); + + expect(data.some((event) => event.resourceId === control.id)).toBe(true); + const theirs = data.find((event) => event.resourceId === evidenceId); + expect(theirs?.resourceType).toBe("evidence"); + }); + it("narrows to one record by the identifier alone", async () => { const mine = await given(acme, { name: "Mine" }); const other = await given(acme, { name: "Another" }); diff --git a/apps/server/audit.ts b/apps/server/audit.ts index 4b5586d..34d28a7 100644 --- a/apps/server/audit.ts +++ b/apps/server/audit.ts @@ -20,7 +20,7 @@ type AuditFields = schema.AuditFields; * out which record an identifier names. One source, so a new entity cannot be * recordable and unreadable. */ -export const resourceTypes = ["control", "standard"] as const; +export const resourceTypes = ["control", "evidence", "standard"] as const; export type ResourceType = (typeof resourceTypes)[number]; @@ -41,7 +41,7 @@ export type Change = Records & ( | { action: "created"; before?: never; after: AuditFields } | { action: "deleted"; before: AuditFields; after?: never } - | { action: "updated"; before?: AuditFields; after: AuditFields } + | { action: "updated" | "attested"; before?: AuditFields; after: AuditFields } ); /** Who the change is attributed to, resolved once per request. */ diff --git a/apps/server/auth.ts b/apps/server/auth.ts index 0f05fa9..d46e745 100644 --- a/apps/server/auth.ts +++ b/apps/server/auth.ts @@ -32,7 +32,7 @@ export const authOptions = { // and a foreign key's cascade answers to neither row-level security nor // table privileges — it would take the audit log and every attestation // with it. Removing a tenant is an operator's job, not a self-serve - // route an owner can reach (ADR 0005, ADR 0014). + // route an owner can reach (ADR 0005, ADR 0012, ADR 0014). organization({ disableOrganizationDeletion: true }), admin(), twoFactor(), diff --git a/apps/server/concurrency.test.ts b/apps/server/concurrency.test.ts index 32c35f6..e5a108b 100644 --- a/apps/server/concurrency.test.ts +++ b/apps/server/concurrency.test.ts @@ -199,6 +199,21 @@ const requirements = async (): Promise<[string, string]> => { return [rows[0]!.id, rows[1]!.id]; }; +const evidenceFor = async (controlId: string, title: string) => { + const response = await request(`/controls/${controlId}/evidence`, { + method: "POST", + body: JSON.stringify({ title, occurredAt: "2026-07-01T09:00:00.000Z" }), + }); + expect(response.status).toBe(201); + return (await json<{ data: { id: string } }>(response)).data.id; +}; + +const tagOf = async (path: string) => { + const response = await request(path); + expect(response.status).toBe(200); + return response.headers.get("etag")!; +}; + beforeAll(async () => { if (!usable) return; @@ -338,51 +353,163 @@ describe.skipIf(!usable)("what a lock actually prevents", () => { expect((await json<{ data: { status: string } }>(response)).data.status).toBe("retired"); }); - it("lets two amendments through in turn, and records what each replaced", async () => { - // Serialised rather than refused: neither names a version, so neither is - // asking to be protected. What must not happen is an audit event claiming - // to have replaced something it did not — the second amendment's `before` - // has to be what the first wrote, not what it read before the first ran. - // - // `Promise.all` alone would not force that: it starts both requests, it - // does not make their reads overlap. Both have to be waiting on the same - // lock before either is let go. - const id = await control("Original"); + it("refuses a conditional discard of evidence amended while it waited", async () => { + // The race that shipped. The tag was compared against an unlocked read, so + // the amendment below landed between the comparison and the delete and the + // evidence went anyway — with the caller none the wiser. + const id = await control("For the evidence"); + const evidenceId = await evidenceFor(id, "Read, then amended"); + const read = await tagOf(`/evidence/${evidenceId}`); - const [first, second] = await holding( - `select * from "control" where "id" = $1 for update`, - [id], + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], async ({ session, blocked }) => { - const both = Promise.all([ - request(`/controls/${id}`, { - method: "PATCH", - body: JSON.stringify({ name: "One" }), - }), - request(`/controls/${id}`, { - method: "PATCH", - body: JSON.stringify({ name: "Two" }), - }), + const discarding = request(`/evidence/${evidenceId}`, { + method: "DELETE", + headers: { "if-match": read }, + }); + await blocked(); + await session.query(`update "evidence" set "title" = 'Amended' where "id" = $1`, [ + evidenceId, ]); - await blocked(2); await session.query("commit"); - return both; + return discarding; }, ); - expect([first.status, second.status]).toEqual([200, 200]); - const { data } = await json<{ data: { action: string; before: { name?: string } | null }[] }>( - await request(`/history?resource=${id}`), + expect(response.status).toBe(412); + // And the evidence is still there, carrying the amendment. + const { data } = await json<{ data: { title: string } }>( + await request(`/evidence/${evidenceId}`), ); - const replaced = data - .filter((event) => event.action === "updated") - .map((event) => event.before?.name); + expect(data.title).toBe("Amended"); + }); - expect(replaced).toHaveLength(2); - // One replaced the original; the other replaced whatever the first wrote. - expect(replaced).toContain("Original"); - expect(new Set(replaced).size).toBe(2); + it("refuses a conditional amendment of evidence amended while it waited", async () => { + const id = await control("For the other amendment"); + const evidenceId = await evidenceFor(id, "Also read first"); + const read = await tagOf(`/evidence/${evidenceId}`); + + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const amending = request(`/evidence/${evidenceId}`, { + method: "PATCH", + body: JSON.stringify({ title: "Mine" }), + headers: { "if-match": read }, + }); + await blocked(); + await session.query(`update "evidence" set "title" = 'Theirs' where "id" = $1`, [ + evidenceId, + ]); + await session.query("commit"); + return amending; + }, + ); + + expect(response.status).toBe(412); + const { data } = await json<{ data: { title: string } }>( + await request(`/evidence/${evidenceId}`), + ); + expect(data.title).toBe("Theirs"); }); + it("refuses an attestation of evidence amended while it waited", async () => { + // Attesting takes no lock before comparing — an attested row could not be + // locked anyway — so it repeats the version in its `UPDATE` instead. This + // is what that repetition is for. + const id = await control("For the signature"); + const evidenceId = await evidenceFor(id, "About to be signed"); + const read = await tagOf(`/evidence/${evidenceId}`); + + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const attesting = request(`/evidence/${evidenceId}/attestation`, { + method: "PUT", + headers: { "if-match": read }, + }); + await blocked(); + await session.query(`update "evidence" set "title" = 'Changed first' where "id" = $1`, [ + evidenceId, + ]); + await session.query("commit"); + return attesting; + }, + ); + + expect(response.status).toBe(412); + const { data } = await json<{ data: { attestation: unknown } }>( + await request(`/evidence/${evidenceId}`), + ); + expect(data.attestation).toBeNull(); + }); + + it.each([ + ["the discard", "discard"], + ["the recording", "record"], + ])( + "never both discards a control and records evidence against it, %s first", + async (_case, first) => { + // This was argued from lock conflicts before it was shown: recording + // evidence needs `for key share` on its control for the foreign key check, + // which conflicts with the `for update` a discard holds. So the two cannot + // interleave — and whichever loses must lose *cleanly*, not with a foreign + // key violation surfacing as a 500. + // + // Both orders, and each forced: a lock queue is first-come, so starting one + // request and waiting for it to join the queue before starting the other + // decides which wins. Leaving it to chance would make this pass whenever + // the order happened to be the harmless one. + const id = await control(`Contested ${first}`); + const discard = () => request(`/controls/${id}`, { method: "DELETE" }); + const record = () => + request(`/controls/${id}/evidence`, { + method: "POST", + body: JSON.stringify({ title: "Racing", occurredAt: "2026-07-01T09:00:00.000Z" }), + }); + + const [discarded, recorded] = await holding( + `select * from "control" where "id" = $1 for update`, + [id], + async ({ session, blocked }) => { + const ahead = first === "discard" ? discard() : record(); + await blocked(1); + const behind = first === "discard" ? record() : discard(); + await blocked(2); + await session.query("commit"); + const settled = await Promise.all([ahead, behind]); + return first === "discard" ? settled : [settled[1]!, settled[0]!]; + }, + ); + + // Neither is a server error, whichever went first. + expect(discarded.status).not.toBe(500); + expect(recorded.status).not.toBe(500); + + const gone = (await request(`/controls/${id}`)).status === 404; + if (first === "discard") { + // The discard was ahead, so it took the control and the recording found + // nothing to record against. + expect(discarded.status).toBe(204); + expect(gone).toBe(true); + expect(recorded.status).toBe(404); + } else { + // The recording was ahead, so the control now carries evidence and may + // not be discarded at all. + expect(recorded.status).toBe(201); + expect(gone).toBe(false); + expect(discarded.status).toBe(409); + expect((await json<{ error: { code: string } }>(discarded)).error.code).toBe( + "has_evidence", + ); + } + }, + ); + it("refuses a conditional remapping of a control remapped while it waited", async () => { // The last mutation that was last-writer-wins. A set has no `xmin`, so its // version is its contents — and the handler already holds `for update` on @@ -501,6 +628,199 @@ describe.skipIf(!usable)("what a lock actually prevents", () => { if (expected === "same") expect(after).toEqual(before); else expect(after).toHaveLength(1); }); + + it.each([ + ["amending", "PATCH"], + ["discarding", "DELETE"], + ])("says gone, not signed, when evidence is discarded while %s it", async (_case, method) => { + // A locked read is governed by the UPDATE policy, so an empty result means + // the row is attested — or that it was deleted while this transaction + // waited for the lock. The two are indistinguishable from the lock alone, + // and answering "already attested" for something discarded and never + // signed is a confident wrong answer. + const id = await control(`Discarded while ${method}`); + const evidenceId = await evidenceFor(id, "About to be discarded"); + + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const attempt = request(`/evidence/${evidenceId}`, { + method, + ...(method === "PATCH" ? { body: JSON.stringify({ title: "Mine" }) } : {}), + }); + await blocked(); + await session.query('delete from "evidence" where "id" = $1', [evidenceId]); + await session.query("commit"); + return attempt; + }, + ); + + expect(response.status).toBe(404); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("not_found"); + }); + + it("lets two amendments through in turn, and records what each replaced", async () => { + // Serialised rather than refused: neither names a version, so neither is + // asking to be protected. What must not happen is an audit event claiming + // to have replaced something it did not — the second amendment's `before` + // has to be what the first wrote, not what it read before the first ran. + // + // `Promise.all` alone would not force that: it starts both requests, it + // does not make their reads overlap. Both have to be waiting on the same + // lock before either is let go. + const id = await control("Original"); + + const [first, second] = await holding( + `select * from "control" where "id" = $1 for update`, + [id], + async ({ session, blocked }) => { + const both = Promise.all([ + request(`/controls/${id}`, { + method: "PATCH", + body: JSON.stringify({ name: "One" }), + }), + request(`/controls/${id}`, { + method: "PATCH", + body: JSON.stringify({ name: "Two" }), + }), + ]); + await blocked(2); + await session.query("commit"); + return both; + }, + ); + + expect([first.status, second.status]).toEqual([200, 200]); + const { data } = await json<{ data: { action: string; before: { name?: string } | null }[] }>( + await request(`/history?resource=${id}`), + ); + const replaced = data + .filter((event) => event.action === "updated") + .map((event) => event.before?.name); + + expect(replaced).toHaveLength(2); + // One replaced the original; the other replaced whatever the first wrote. + expect(replaced).toContain("Original"); + expect(new Set(replaced).size).toBe(2); + }); + + it.each([ + ["attested", 409, "already_attested"], + ["discarded", 404, "not_found"], + ])( + "answers %s rather than stale when evidence is %s while an attestation waits", + async (what, status, code) => { + // Attesting waits on the UPDATE lock and checks the version there. + // Matching nothing has three causes; only an amendment is "stale". + const id = await control(`Attestation meets ${what}`); + const evidenceId = await evidenceFor(id, `To be ${what} first`); + const read = await tagOf(`/evidence/${evidenceId}`); + + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const attesting = request(`/evidence/${evidenceId}/attestation`, { + method: "PUT", + headers: { "if-match": read }, + }); + await blocked(); + await session.query( + what === "attested" + ? `update "evidence" set "attested_at" = now(), "attested_by_id" = 'usr_0000000000000000', + "attested_by_label" = 'Someone else' where "id" = $1` + : `delete from "evidence" where "id" = $1`, + [evidenceId], + ); + await session.query("commit"); + return attesting; + }, + ); + + expect(response.status).toBe(status); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe(code); + }, + ); + + it.each([ + ["amending", "PATCH"], + ["discarding", "DELETE"], + ])("says signed, not gone, when evidence is attested while %s it", async (_case, method) => { + // The other half of what an empty locked read can mean: attested rows are + // invisible to the UPDATE policy that governs the lock, so the re-read is + // what tells this from a discard. + const id = await control(`Attested while ${method}`); + const evidenceId = await evidenceFor(id, "About to be signed"); + + const response = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const attempt = request(`/evidence/${evidenceId}`, { + method, + ...(method === "PATCH" ? { body: JSON.stringify({ title: "Mine" }) } : {}), + }); + await blocked(); + await session.query( + `update "evidence" set "attested_at" = now(), "attested_by_id" = 'usr_0000000000000000', + "attested_by_label" = 'Someone else' where "id" = $1`, + [evidenceId], + ); + await session.query("commit"); + return attempt; + }, + ); + + expect(response.status).toBe(409); + expect((await json<{ error: { code: string } }>(response)).error.code).toBe("already_attested"); + }); + + it("lets two evidence amendments through in turn, and records what each replaced", async () => { + // Serialised rather than refused: neither names a version, so neither is + // asking to be protected. What must not happen is an audit event claiming + // to have replaced something it did not — the second amendment's `before` + // has to be what the first wrote, not what it read before the first ran. + // + // `Promise.all` alone would not force that: it starts both requests, it + // does not make their reads overlap. Both have to be waiting on the same + // lock before either is let go. + const id = await control("Amended twice"); + const evidenceId = await evidenceFor(id, "Original"); + + const [first, second] = await holding( + `select * from "evidence" where "id" = $1 for update`, + [evidenceId], + async ({ session, blocked }) => { + const both = Promise.all([ + request(`/evidence/${evidenceId}`, { + method: "PATCH", + body: JSON.stringify({ title: "One" }), + }), + request(`/evidence/${evidenceId}`, { + method: "PATCH", + body: JSON.stringify({ title: "Two" }), + }), + ]); + await blocked(2); + await session.query("commit"); + return both; + }, + ); + + expect([first.status, second.status]).toEqual([200, 200]); + const { data } = await json<{ data: { action: string; before: { title?: string } | null }[] }>( + await request(`/history?resource=${evidenceId}`), + ); + const replaced = data + .filter((event) => event.action === "updated") + .map((event) => event.before?.title); + + expect(replaced).toHaveLength(2); + // One replaced the original; the other replaced whatever the first wrote. + expect(replaced).toContain("Original"); + expect(new Set(replaced).size).toBe(2); + }); }); describe.skipIf(!usable)("tenants sharing a connection pool", () => { diff --git a/apps/server/controls.test.ts b/apps/server/controls.test.ts index 19843ad..732ebee 100644 --- a/apps/server/controls.test.ts +++ b/apps/server/controls.test.ts @@ -440,20 +440,6 @@ async function tenantWithControls(names: string[]) { return tenant; } -/** - * Evidence against a control, written as the tenant. No route records evidence - * yet, but the table and the foreign key restricting a control's deletion do. - */ -const recordEvidence = (controlId: string) => - withOrganization(db, acme.organizationId, (tx) => - tx.insert(schema.evidence).values({ - organizationId: acme.organizationId, - controlId, - title: "Minutes", - occurredAt: new Date("2026-07-01T09:00:00.000Z"), - }), - ); - describe("discarding a draft", () => { it("says what to do instead of reviving a retired control", async () => { // Retired is final, so a retired control never becomes a draft that could @@ -672,14 +658,22 @@ describe("discarding a draft", () => { it("refuses a draft that carries evidence, rather than failing on a foreign key", async () => { const created = await given(acme, { name: "Has evidence" }); - await recordEvidence(created.id); + const recorded = await app.request( + `/api/v1/organizations/${acme.organizationId}/controls/${created.id}/evidence`, + { + method: "POST", + headers: { cookie: acme.cookie, "content-type": "application/json" }, + body: JSON.stringify({ title: "Minutes", occurredAt: "2026-07-01T09:00:00.000Z" }), + }, + ); + expect(recorded.status).toBe(201); const response = await discard(acme, created.id); expect(response.status).toBe(409); const { error } = await json(response); expect(error.code).toBe("has_evidence"); - expect(error.details?.[0]?.message).toContain("cannot be discarded"); + expect(error.details?.[0]?.message).toContain("Discard its evidence first"); expect((await request(acme, `/${created.id}`)).status).toBe(200); }); @@ -804,7 +798,15 @@ describe("changing only what you read", () => { if (code === "was_in_effect") { expect((await patchWith(created.id, undefined, { status: "active" })).status).toBe(200); } else { - await recordEvidence(created.id); + const recorded = await app.request( + `/api/v1/organizations/${acme.organizationId}/controls/${created.id}/evidence`, + { + method: "POST", + headers: { cookie: acme.cookie, "content-type": "application/json" }, + body: JSON.stringify({ title: "Attached", occurredAt: "2026-07-01T09:00:00.000Z" }), + }, + ); + expect(recorded.status).toBe(201); } const stale = await request(acme, `/${created.id}`, { diff --git a/apps/server/controls.ts b/apps/server/controls.ts index 8b74d1f..bb26580 100644 --- a/apps/server/controls.ts +++ b/apps/server/controls.ts @@ -576,7 +576,11 @@ export const controls = new Hono() if (result.outcome === "has_evidence") { return c.json( failure("has_evidence", "The control carries evidence.", [ - { path: "", message: "A control that has evidence cannot be discarded." }, + { + path: "", + message: + "Discard its evidence first. Attested evidence cannot be discarded, and then neither can the control.", + }, ]), 409, ); diff --git a/apps/server/documented-setup.test.ts b/apps/server/documented-setup.test.ts index ab81694..5bb3681 100644 --- a/apps/server/documented-setup.test.ts +++ b/apps/server/documented-setup.test.ts @@ -253,6 +253,19 @@ describe.each(documents)("the setup in $path", ({ path, heading }) => { const history = await request(`/history?resource=${controlId}`); expect(history.status).toBe(200); + const evidence = await request( + `/controls/${controlId}/evidence`, + asJson({ title: "Q3 review", occurredAt: "2026-07-01T09:00:00.000Z" }), + ); + expect(evidence.status).toBe(201); + const evidenceId = (await json<{ data: { id: string } }>(evidence)).data.id; + const read = await request(`/evidence/${evidenceId}`); + const attested = await request(`/evidence/${evidenceId}/attestation`, { + method: "PUT", + headers: { "if-match": read.headers.get("etag")! }, + }); + expect(attested.status).toBe(200); + const standard = await request( "/standards", asJson({ diff --git a/apps/server/evidence.test.ts b/apps/server/evidence.test.ts new file mode 100644 index 0000000..b6fa001 --- /dev/null +++ b/apps/server/evidence.test.ts @@ -0,0 +1,976 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Recording evidence, and what attesting it settles. + * + * Evidence is the first finalised record here (VERSION-01), so the tests that + * matter most are the ones that go round the routes: an attested row is not + * changeable through a tenant context at all, whatever the application asks. + * Requests run as a non-superuser role that owns the tables, so the policies + * are in force as they are in a deployment. + */ + +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { schema, withOrganization } from "@qualityruntime/db"; +import { and, asc, eq, sql } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { beforeAll, describe, expect, it } from "vite-plus/test"; +import { createApp } from "./app.ts"; +import { createAuth } from "./auth.ts"; + +const migrationsFolder = fileURLToPath(new URL("../../packages/db/migrations", import.meta.url)); + +const createTestDatabase = (client: PGlite) => drizzle({ client, schema, casing: "snake_case" }); + +let db: ReturnType; +let app: ReturnType; + +type Tenant = { cookie: string; organizationId: string; userId: string }; +let acme: Tenant; +let globex: Tenant; +let control: string; +let theirControl: string; + +const json = async (response: Response): Promise => (await response.json()) as T; + +type Attestation = { at: string; by: { id: string; label: string | null } } | null; +type Evidence = { + id: string; + title: string; + description: string | null; + occurredAt: string; + attestation: Attestation; +}; +type Page = { data: T[]; nextCursor: string | null }; +type Failure = { + error: { code: string; message: string; details?: { path: string; message: string }[] }; +}; + +type Request = Omit & { headers?: Record }; + +const request = (tenant: Tenant, path: string, init: Request = {}) => + app.request(`/api/v1/organizations/${tenant.organizationId}${path}`, { + ...init, + headers: { + cookie: tenant.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + // Merged, not replaced: an attestation carries `if-match`. + ...init.headers, + }, + }); + +async function record( + tenant: Tenant, + controlId: string, + body: Record = {}, +): Promise { + const response = await request(tenant, `/controls/${controlId}/evidence`, { + method: "POST", + body: JSON.stringify({ + title: "Q3 access review", + occurredAt: "2026-07-01T09:00:00.000Z", + ...body, + }), + }); + expect(response.status).toBe(201); + return (await json<{ data: Evidence }>(response)).data; +} + +/** Signs a user up and returns their identifier. */ +async function signUp(email: string): Promise { + const response = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: "Grace Hopper", email, password: "correct horse" }), + }); + expect(response.status).toBe(200); + return (await json<{ user: { id: string } }>(response)).user.id; +} + +/** Reads the evidence, then attests exactly what it read. */ +async function attest(tenant: Tenant, id: string, ifMatch?: string) { + const tag = ifMatch ?? (await request(tenant, `/evidence/${id}`)).headers.get("etag") ?? ""; + return request(tenant, `/evidence/${id}/attestation`, { + method: "PUT", + headers: { "if-match": tag }, + }); +} + +const amend = (tenant: Tenant, id: string, body: unknown) => + request(tenant, `/evidence/${id}`, { method: "PATCH", body: JSON.stringify(body) }); + +const discard = (tenant: Tenant, id: string) => + request(tenant, `/evidence/${id}`, { method: "DELETE" }); + +/** Every audit event recorded against one piece of evidence. */ +const historyOf = (tenant: Tenant, id: string) => + withOrganization(db, tenant.organizationId, (tx) => + tx + .select() + .from(schema.auditEvent) + .where( + and(eq(schema.auditEvent.resourceType, "evidence"), eq(schema.auditEvent.resourceId, id)), + ) + // In the order it happened; without this the order is the plan's. + .orderBy(asc(schema.auditEvent.createdAt), asc(schema.auditEvent.id)), + ); + +beforeAll(async () => { + const client = new PGlite(); + db = createTestDatabase(client); + await migrate(db, { migrationsFolder }); + app = createApp({ + db, + auth: createAuth(db, { + baseURL: "http://localhost", + secret: "test-secret-of-at-least-32-characters", + }), + }); + + const tenant = async (slug: string): Promise => { + const signedUp = await app.request("/api/auth/sign-up/email", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: "Ada Lovelace", + email: `${slug}@example.test`, + password: "correct horse", + }), + }); + expect(signedUp.status).toBe(200); + const cookie = signedUp.headers + .getSetCookie() + .map((value) => value.split(";", 1)[0]) + .join("; "); + const userId = (await json<{ user: { id: string } }>(signedUp)).user.id; + const created = await app.request("/api/auth/organization/create", { + method: "POST", + headers: { "content-type": "application/json", cookie }, + body: JSON.stringify({ name: slug, slug }), + }); + expect(created.status).toBe(200); + return { cookie, userId, organizationId: (await json<{ id: string }>(created)).id }; + }; + + acme = await tenant("acme"); + globex = await tenant("globex"); + + await client.exec(` + create role qualityruntime_app nosuperuser nobypassrls; + grant all on all tables in schema public to qualityruntime_app; + alter table "control" owner to qualityruntime_app; + alter table "audit_event" owner to qualityruntime_app; + alter table "standard" owner to qualityruntime_app; + alter table "requirement" owner to qualityruntime_app; + alter table "control_requirement" owner to qualityruntime_app; + alter table "evidence" owner to qualityruntime_app; + set role qualityruntime_app; + `); + + const newControl = async (owner: Tenant, name: string) => { + const response = await request(owner, "/controls", { + method: "POST", + body: JSON.stringify({ name }), + }); + expect(response.status).toBe(201); + return (await json<{ data: { id: string } }>(response)).data.id; + }; + control = await newControl(acme, "Access review"); + theirControl = await newControl(globex, "Globex only"); +}, 60_000); + +describe("recording evidence", () => { + it("records it against the control, unattested", async () => { + const evidence = await record(acme, control, { title: "Recorded" }); + + expect(evidence.id).toMatch(/^evd_[0-9a-z]{16}$/); + expect(evidence.attestation).toBeNull(); + expect(evidence.occurredAt).toBe("2026-07-01T09:00:00.000Z"); + }); + + it("is readable by its own identifier", async () => { + const evidence = await record(acme, control, { title: "Findable" }); + + const response = await request(acme, `/evidence/${evidence.id}`); + + expect(response.status).toBe(200); + expect((await json<{ data: Evidence }>(response)).data.title).toBe("Findable"); + }); + + it.each([ + ["no collected date", { occurredAt: undefined }], + ["a collected date that is not a date", { occurredAt: "last Tuesday" }], + ["no title", { title: undefined }], + ])("refuses evidence with %s", async (_case, body) => { + const response = await request(acme, `/controls/${control}/evidence`, { + method: "POST", + body: JSON.stringify({ title: "T", occurredAt: "2026-07-01T09:00:00.000Z", ...body }), + }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it("answers 404 for another organization's control", async () => { + const response = await request(acme, `/controls/${theirControl}/evidence`, { + method: "POST", + body: JSON.stringify({ title: "Smuggled", occurredAt: "2026-07-01T09:00:00.000Z" }), + }); + + expect(response.status).toBe(404); + }); + + it("lists a control's evidence, most recently occurred first", async () => { + const own = await request(acme, "/controls", { + method: "POST", + body: JSON.stringify({ name: "Listed" }), + }); + const listed = (await json<{ data: { id: string } }>(own)).data.id; + for (const day of ["2026-01-01", "2026-03-01", "2026-02-01"]) { + await record(acme, listed, { title: day, occurredAt: `${day}T00:00:00.000Z` }); + } + + const { data } = await json>( + await request(acme, `/controls/${listed}/evidence?limit=100`), + ); + + // By when the thing happened, not when it was typed in. + expect(data.map((row) => row.title)).toEqual(["2026-03-01", "2026-02-01", "2026-01-01"]); + }); +}); + +describe("amending evidence", () => { + it("changes a draft", async () => { + const evidence = await record(acme, control, { title: "Before" }); + + const response = await amend(acme, evidence.id, { title: "After" }); + + expect(response.status).toBe(200); + expect((await json<{ data: Evidence }>(response)).data.title).toBe("After"); + }); + + it("writes nothing, and keeps its version, when nothing changes", async () => { + // An UPDATE would move the version anyway, staling the tag an attester is + // about to quote while history said nothing happened. The same instant in + // another offset is the same value. + const evidence = await record(acme, control, { + title: "Unchanged", + occurredAt: "2026-07-01T09:00:00.000Z", + }); + const read = (await request(acme, `/evidence/${evidence.id}`)).headers.get("etag"); + + const response = await amend(acme, evidence.id, { + title: "Unchanged", + occurredAt: "2026-07-01T11:00:00.000+02:00", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("etag")).toBe(read); + const events = await historyOf(acme, evidence.id); + expect(events.map((event) => event.action)).toEqual(["created"]); + }); + + it("records what changed", async () => { + const evidence = await record(acme, control, { title: "Audited" }); + + await amend(acme, evidence.id, { title: "Audited again" }); + + const history = await historyOf(acme, evidence.id); + expect(history.map((event) => event.action)).toEqual(["created", "updated"]); + expect(history.at(-1)).toMatchObject({ + before: { title: "Audited" }, + after: { title: "Audited again" }, + }); + }); + + it("refuses a body asking for no change", async () => { + const evidence = await record(acme, control, { title: "Unchanged" }); + + expect((await amend(acme, evidence.id, {})).status).toBe(400); + }); +}); + +describe("attesting", () => { + it("records who vouched, and when", async () => { + const evidence = await record(acme, control, { title: "Vouched for" }); + + const response = await attest(acme, evidence.id); + + expect(response.status).toBe(200); + const { data } = await json<{ data: Evidence }>(response); + expect(data.attestation?.by).toEqual({ id: acme.userId, label: "Ada Lovelace" }); + expect(Date.parse(data.attestation!.at)).toBeGreaterThan(0); + }); + + it("is one act, not a repeatable one", async () => { + const evidence = await record(acme, control, { title: "Once" }); + expect((await attest(acme, evidence.id)).status).toBe(200); + + const again = await attest(acme, evidence.id); + + expect(again.status).toBe(409); + expect((await json(again)).error.code).toBe("already_attested"); + }); + + it("refuses to change attested evidence", async () => { + const evidence = await record(acme, control, { title: "Settled" }); + await attest(acme, evidence.id); + + const response = await amend(acme, evidence.id, { title: "Rewritten" }); + + expect(response.status).toBe(409); + expect((await json(response)).error.code).toBe("already_attested"); + const { data } = await json<{ data: Evidence }>( + await request(acme, `/evidence/${evidence.id}`), + ); + expect(data.title).toBe("Settled"); + }); + + it("refuses to attest while impersonating", async () => { + // An administrator acting as a member may do that member's work. Vouching + // is a signature, and signing as somebody else is forgery however it is + // logged. + const evidence = await record(acme, control, { title: "Not yours to sign" }); + const administrator = await signUp("administrator@example.test"); + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = ${administrator} + where "user_id" = ${acme.userId}`, + ), + ); + + const response = await attest(acme, evidence.id); + + await withOrganization(db, acme.organizationId, (tx) => + tx.execute( + sql`update "session" set "impersonated_by" = null where "user_id" = ${acme.userId}`, + ), + ); + expect(response.status).toBe(403); + expect((await json(response)).error.code).toBe("impersonated"); + const { data } = await json<{ data: Evidence }>( + await request(acme, `/evidence/${evidence.id}`), + ); + expect(data.attestation).toBeNull(); + }); + + it("refuses to sign without saying what was read", async () => { + const evidence = await record(acme, control, { title: "Unread" }); + + const response = await request(acme, `/evidence/${evidence.id}/attestation`, { method: "PUT" }); + + expect(response.status).toBe(428); + expect((await json(response)).error.code).toBe("precondition_required"); + }); + + it.each([ + ["a wildcard", () => "*"], + ["a list holding the right tag", (tag: string) => `${tag}, "other"`], + ["the right tag made weak", (tag: string) => `W/${tag}`], + ["an empty value", () => ""], + ])("refuses to sign with %s instead of the exact strong tag", async (_case, header) => { + // Everywhere else If-Match asks "has this moved?"; a signature is of one + // version in particular, so only that tag, exactly, will do (ADR 0012). + const evidence = await record(acme, control, { title: "Signed exactly" }); + const tag = (await request(acme, `/evidence/${evidence.id}`)).headers.get("etag")!; + + const response = await attest(acme, evidence.id, header(tag)); + + expect(response.status).toBe(412); + const after = await json<{ data: Evidence }>(await request(acme, `/evidence/${evidence.id}`)); + expect(after.data.attestation).toBeNull(); + }); + + it("attests what was just recorded, with the tag it came back with", async () => { + const recorded = await request(acme, `/controls/${control}/evidence`, { + method: "POST", + body: JSON.stringify({ title: "Signed at once", occurredAt: "2026-07-01T09:00:00.000Z" }), + }); + const { data } = await json<{ data: Evidence }>(recorded); + const tag = recorded.headers.get("etag"); + expect(tag).toMatch(/^".+"$/); + + // Sent directly: the `attest` helper would read the tag itself if absent. + const response = await request(acme, `/evidence/${data.id}/attestation`, { + method: "PUT", + headers: { "if-match": tag! }, + }); + + expect(response.status).toBe(200); + }); + + it("refuses to sign content that changed since it was read", async () => { + // Alice reads the draft, Bob amends it, Alice signs. Without the + // precondition she would have endorsed what Bob wrote. + const evidence = await record(acme, control, { title: "As Alice read it" }); + const tag = (await request(acme, `/evidence/${evidence.id}`)).headers.get("etag")!; + await amend(acme, evidence.id, { title: "As Bob left it" }); + + const response = await attest(acme, evidence.id, tag); + + expect(response.status).toBe(412); + expect((await json(response)).error.code).toBe("precondition_failed"); + const { data } = await json<{ data: Evidence }>( + await request(acme, `/evidence/${evidence.id}`), + ); + expect(data.attestation).toBeNull(); + }); + + it("offers the tag an attestation has to quote", async () => { + const evidence = await record(acme, control, { title: "Tagged" }); + + const response = await request(acme, `/evidence/${evidence.id}`); + + expect(response.headers.get("etag")).toMatch(/^"\d+"$/); + }); + + it("records the attestation in history", async () => { + const evidence = await record(acme, control, { title: "Historied" }); + + await attest(acme, evidence.id); + + const history = await historyOf(acme, evidence.id); + expect(history.map((event) => event.action)).toEqual(["created", "attested"]); + }); +}); + +describe("dates this API will not take", () => { + it.each([ + ["four minutes ahead, inside the allowance", 4, 201], + ["six minutes ahead, past it", 6, 400], + ])("treats a date %s as published", async (_case, minutes, status) => { + // Five minutes absorb a client clock running ahead; a minute either side + // keeps the test clear of the boundary. + const response = await request(acme, `/controls/${control}/evidence`, { + method: "POST", + body: JSON.stringify({ + title: `A little ahead: ${minutes}`, + occurredAt: new Date(Date.now() + minutes * 60 * 1000).toISOString(), + }), + }); + + expect(response.status).toBe(status); + }); + + it("refuses evidence of something that has not happened", async () => { + const response = await request(acme, `/controls/${control}/evidence`, { + method: "POST", + body: JSON.stringify({ + title: "Next year's review", + occurredAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString(), + }), + }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it.each([ + ["year zero", "0000-01-01T00:00:00.000Z"], + ["a year that grows past four digits once the offset is applied", "9999-12-31T23:59:59-01:00"], + ["a year that shrinks below one", "0001-01-01T00:00:00+01:00"], + ])("refuses %s", async (_case, occurredAt) => { + // Each parses as a JavaScript date. The positive offset moves year 0001 + // back into year zero; the negative offset moves year 9999 into the + // expanded +010000 form. Neither fits the API's UTC year range, and + // without that bound each reaches PostgreSQL as a 500 rather than being + // refused as a bad request. + const response = await request(acme, `/controls/${control}/evidence`, { + method: "POST", + body: JSON.stringify({ title: "Out of range", occurredAt }), + }); + + expect(response.status).toBe(400); + expect((await json(response)).error.code).toBe("invalid_request"); + }); + + it("keeps an offset date as the instant it names", async () => { + const evidence = await record(acme, control, { + title: "Offset", + occurredAt: "2026-07-01T10:00:00+01:00", + }); + + expect(evidence.occurredAt).toBe("2026-07-01T09:00:00.000Z"); + }); +}); + +describe("what the database itself refuses", () => { + /** Attested evidence, and a draft beside it, both of this organization. */ + const pair = async () => { + const attested = await record(acme, control, { title: "Attested" }); + await attest(acme, attested.id); + const draft = await record(acme, control, { title: "Draft" }); + return { attested: attested.id, draft: draft.id }; + }; + + it("will not update an attested row, whatever the application asks", async () => { + // Round the routes entirely. The policy's USING clause sees only + // unattested rows, so an attested one is not there to update (VERSION-01). + const { attested } = await pair(); + + const updated = await withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.evidence) + .set({ title: "Rewritten behind the API" }) + .where(eq(schema.evidence.id, attested)) + .returning(), + ); + + expect(updated).toEqual([]); + }); + + it("will not delete an attested row", async () => { + const { attested } = await pair(); + + const deleted = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.evidence).where(eq(schema.evidence.id, attested)).returning(), + ); + + expect(deleted).toEqual([]); + }); + + it("still allows a draft to be changed and discarded", async () => { + // The finality is about attested rows, not about the table. + const { draft } = await pair(); + + const updated = await withOrganization(db, acme.organizationId, (tx) => + tx + .update(schema.evidence) + .set({ title: "Still a draft" }) + .where(eq(schema.evidence.id, draft)) + .returning(), + ); + const deleted = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.evidence).where(eq(schema.evidence.id, draft)).returning(), + ); + + expect(updated).toHaveLength(1); + expect(deleted).toHaveLength(1); + }); + + it.each([ + ["a time with nobody behind it", { attestedAt: new Date() }], + ["an empty attester", { attestedAt: new Date(), attestedById: " " }], + ["a name with no time", { attestedByLabel: "Ada Lovelace" }], + ])("will not store %s", async (_case, values) => { + // Half an attestation is not an attestation, and the third would make the + // row final while still reading as unattested. + const draft = await record(acme, control, { title: "Halfway" }); + + const attempt = withOrganization(db, acme.organizationId, (tx) => + tx.update(schema.evidence).set(values).where(eq(schema.evidence.id, draft.id)), + ); + + await expect(attempt).rejects.toThrow(); + }); +}); + +describe("evidence and tenants", () => { + it("hides another organization's evidence", async () => { + const theirs = await record(globex, theirControl, { title: "Globex only" }); + + const retrieved = await request(acme, `/evidence/${theirs.id}`); + const absent = await request(acme, "/evidence/evd_0000000000000000"); + + expect(retrieved.status).toBe(404); + expect(await json(retrieved)).toEqual(await json(absent)); + }); + + it.each([ + ["an id of the wrong shape", "not-an-id"], + ["an id carrying another table's prefix", "ctl_v1stgxr8z5jdhi6b"], + ])("answers 404 for %s", async (_case, id) => { + expect((await request(acme, `/evidence/${id}`)).status).toBe(404); + }); +}); + +describe("discarding evidence", () => { + it("removes an unattested record", async () => { + const evidence = await record(acme, control, { title: "Thought better of it" }); + + const response = await discard(acme, evidence.id); + + expect(response.status).toBe(204); + expect((await request(acme, `/evidence/${evidence.id}`)).status).toBe(404); + }); + + it("refuses attested evidence, and says what to do instead", async () => { + const evidence = await record(acme, control, { title: "Signed" }); + expect((await attest(acme, evidence.id)).status).toBe(200); + + const response = await discard(acme, evidence.id); + + expect(response.status).toBe(409); + const { error } = await json(response); + expect(error.code).toBe("already_attested"); + expect(error.details?.[0]?.message).toContain("record a correction instead"); + expect((await request(acme, `/evidence/${evidence.id}`)).status).toBe(200); + }); + + it("will not let the database remove an attested one either", async () => { + const evidence = await record(acme, control, { title: "Final" }); + expect((await attest(acme, evidence.id)).status).toBe(200); + + const removed = await withOrganization(db, acme.organizationId, (tx) => + tx.delete(schema.evidence).where(eq(schema.evidence.id, evidence.id)).returning(), + ); + + expect(removed).toEqual([]); + }); + + it("lets the control it belonged to be discarded afterwards", async () => { + const ours = await json<{ data: { id: string } }>( + await app.request(`/api/v1/organizations/${acme.organizationId}/controls`, { + method: "POST", + headers: { cookie: acme.cookie, "content-type": "application/json" }, + body: JSON.stringify({ name: "Abandoned with evidence" }), + }), + ); + const evidence = await record(acme, ours.data.id, { title: "Recorded by mistake" }); + + const blocked = await request(acme, `/controls/${ours.data.id}`, { method: "DELETE" }); + expect(blocked.status).toBe(409); + expect((await json(blocked)).error.code).toBe("has_evidence"); + + expect((await discard(acme, evidence.id)).status).toBe(204); + const now = await request(acme, `/controls/${ours.data.id}`, { method: "DELETE" }); + + expect(now.status).toBe(204); + }); + + it("answers 404 for an id of the wrong shape, without asking the database", async () => { + // A NUL cannot go into a `text` column, so an id carrying one reaches + // PostgreSQL as a 500 unless it is refused on the way in. + const malformed = await discard(acme, "not-an-id"); + const nul = await discard(acme, encodeURIComponent("evd_v1stgxr8z5jdhi6\u0000")); + + expect(malformed.status).toBe(404); + expect((await json(malformed)).error.message).toBe("No such evidence."); + expect(nul.status).toBe(404); + }); + + it("answers 404 for another organization's evidence, and leaves it alone", async () => { + const theirs = await record(globex, theirControl, { title: "Theirs" }); + + const response = await discard(acme, theirs.id); + + expect(response.status).toBe(404); + expect((await json(response)).error.message).toBe("No such evidence."); + expect((await request(globex, `/evidence/${theirs.id}`)).status).toBe(200); + }); +}); + +describe("amending and discarding only what you read", () => { + const tagOf = async (id: string) => { + const response = await request(acme, `/evidence/${id}`); + expect(response.status).toBe(200); + return response.headers.get("etag")!; + }; + + it("refuses an amendment against a version that has moved", async () => { + const evidence = await record(acme, control, { title: "Contested" }); + const read = await tagOf(evidence.id); + expect((await amend(acme, evidence.id, { title: "First wins" })).status).toBe(200); + + const late = await request(acme, `/evidence/${evidence.id}`, { + method: "PATCH", + headers: { "if-match": read, "content-type": "application/json" }, + body: JSON.stringify({ title: "Second, unaware" }), + }); + + expect(late.status).toBe(412); + expect((await json(late)).error.code).toBe("precondition_failed"); + const { data } = await json<{ data: { title: string } }>( + await request(acme, `/evidence/${evidence.id}`), + ); + expect(data.title).toBe("First wins"); + }); + + it("answers an amendment with the new tag", async () => { + const evidence = await record(acme, control, { title: "Chained" }); + + const response = await request(acme, `/evidence/${evidence.id}`, { + method: "PATCH", + headers: { "if-match": await tagOf(evidence.id), "content-type": "application/json" }, + body: JSON.stringify({ title: "Amended" }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("etag")).toBe(await tagOf(evidence.id)); + }); + + it("refuses a discard against a version that has moved", async () => { + const evidence = await record(acme, control, { title: "About to go" }); + const read = await tagOf(evidence.id); + expect((await amend(acme, evidence.id, { title: "Changed underneath" })).status).toBe(200); + + const stale = await request(acme, `/evidence/${evidence.id}`, { + method: "DELETE", + headers: { "if-match": read }, + }); + + expect(stale.status).toBe(412); + expect((await request(acme, `/evidence/${evidence.id}`)).status).toBe(200); + }); + + it("discards against the version just read", async () => { + // The other half of the refusal above: a correct tag must still work, or + // the route could refuse every conditional request and look right. + const evidence = await record(acme, control, { title: "Agreed to go" }); + + const gone = await request(acme, `/evidence/${evidence.id}`, { + method: "DELETE", + headers: { "if-match": await tagOf(evidence.id) }, + }); + + expect(gone.status).toBe(204); + }); + + it("says attested rather than stale when both are true", async () => { + // The row is read unlocked first precisely so this answers 409: locking + // first would find nothing, because an attested row cannot be locked, and + // "cannot be locked" would come back as "does not exist". + const evidence = await record(acme, control, { title: "Signed and stale" }); + const read = await tagOf(evidence.id); + expect((await attest(acme, evidence.id)).status).toBe(200); + + const refused = await request(acme, `/evidence/${evidence.id}`, { + method: "DELETE", + headers: { "if-match": read }, + }); + + expect(refused.status).toBe(409); + expect((await json(refused)).error.code).toBe("already_attested"); + }); + + it("still requires If-Match to attest, which is not the same thing", async () => { + // Optional for an amendment, required for a signature: attesting means + // attesting something in particular (ADR 0012). + const evidence = await record(acme, control, { title: "Signed" }); + + const without = await request(acme, `/evidence/${evidence.id}/attestation`, { + method: "PUT", + }); + + expect(without.status).toBe(428); + expect((await json(without)).error.code).toBe("precondition_required"); + }); +}); + +describe("the evidence of the controls mapped to a requirement", () => { + /** A standard of three clauses, and controls mapped to the first two. */ + let clause: string[]; + let backups: string; + let access: string; + + const newControl = async (name: string) => { + const response = await request(acme, "/controls", { + method: "POST", + body: JSON.stringify({ name }), + }); + return (await json<{ data: { id: string } }>(response)).data.id; + }; + + const mapTo = (controlId: string, requirementIds: string[]) => + request(acme, `/controls/${controlId}/requirements`, { + method: "PUT", + body: JSON.stringify({ requirementIds }), + }); + + const evidenceOf = async (requirementId: string, query = "?limit=100") => { + const response = await request(acme, `/requirements/${requirementId}/evidence${query}`); + expect(response.status).toBe(200); + return json>(response); + }; + + beforeAll(async () => { + const imported = await request(acme, "/standards", { + method: "POST", + body: JSON.stringify({ + name: "ISO 27001", + edition: "2022", + requirements: ["A.8.13", "A.5.15", "A.5.16"].map((reference) => ({ + reference, + title: reference, + })), + }), + }); + expect(imported.status).toBe(201); + const standardId = (await json<{ data: { id: string } }>(imported)).data.id; + const listed = await json>( + await request(acme, `/standards/${standardId}/requirements`), + ); + clause = listed.data.map((row) => row.id); + + backups = await newControl("Backups"); + access = await newControl("Access review"); + expect((await mapTo(backups, [clause[0]!])).status).toBe(200); + expect((await mapTo(access, [clause[0]!, clause[1]!])).status).toBe(200); + + await record(acme, backups, { title: "Restore test", occurredAt: "2026-02-01T00:00:00.000Z" }); + await record(acme, access, { title: "Q1 review", occurredAt: "2026-03-01T00:00:00.000Z" }); + await record(acme, access, { title: "Q4 review", occurredAt: "2025-12-01T00:00:00.000Z" }); + }); + + it("gathers it across controls, most recently occurred first", async () => { + const { data, nextCursor } = await evidenceOf(clause[0]!); + + expect(data.map((row) => [row.title, row.controlId])).toEqual([ + ["Q1 review", access], + ["Restore test", backups], + ["Q4 review", access], + ]); + expect(nextCursor).toBeNull(); + }); + + it("keeps to the controls mapped to that requirement", async () => { + const { data } = await evidenceOf(clause[1]!); + + expect(data.map((row) => row.title)).toEqual(["Q1 review", "Q4 review"]); + }); + + it("is an empty page for a requirement no control answers to", async () => { + const { data, nextCursor } = await evidenceOf(clause[2]!); + + expect(data).toEqual([]); + expect(nextCursor).toBeNull(); + }); + + it("pages without repeating or losing a row", async () => { + const first = await evidenceOf(clause[0]!, "?limit=2"); + const second = await evidenceOf( + clause[0]!, + `?limit=2&cursor=${encodeURIComponent(first.nextCursor!)}`, + ); + + expect([...first.data, ...second.data].map((row) => row.title)).toEqual([ + "Q1 review", + "Restore test", + "Q4 review", + ]); + expect(second.nextCursor).toBeNull(); + }); + + it("refuses a cursor from the control's own evidence", async () => { + const { nextCursor } = await json>( + await request(acme, `/controls/${access}/evidence?limit=1`), + ); + + const response = await request( + acme, + `/requirements/${clause[1]!}/evidence?cursor=${encodeURIComponent(nextCursor!)}`, + ); + + expect(response.status).toBe(400); + expect((await json(response)).error.details?.map((d) => d.path)).toContain("cursor"); + }); + + it("carries attestations, as the control's own list does", async () => { + const control = await newControl("Attested"); + const evidence = await record(acme, control, { + title: "Signed", + occurredAt: "2026-04-01T00:00:00.000Z", + }); + expect((await attest(acme, evidence.id)).status).toBe(200); + // Mapped after it was attested: an attestation endorses the evidence, not + // the mapping, so it is listed all the same. + expect((await mapTo(control, [clause[2]!])).status).toBe(200); + + const { data } = await evidenceOf(clause[2]!); + + expect(data).toHaveLength(1); + expect(data[0]!.attestation?.by.id).toBe(acme.userId); + }); + + it("includes a retired control's evidence, and drops an unmapped one's", async () => { + const control = await newControl("Retiring"); + await record(acme, control, { title: "Last run", occurredAt: "2026-05-01T00:00:00.000Z" }); + expect((await mapTo(control, [clause[1]!])).status).toBe(200); + for (const status of ["active", "retired"]) { + const response = await request(acme, `/controls/${control}`, { + method: "PATCH", + body: JSON.stringify({ status }), + }); + expect(response.status).toBe(200); + } + + expect((await evidenceOf(clause[1]!)).data.map((row) => row.title)).toContain("Last run"); + + expect((await mapTo(control, [])).status).toBe(200); + expect((await evidenceOf(clause[1]!)).data.map((row) => row.title)).not.toContain("Last run"); + // Out of the view, not gone. + const own = await json>(await request(acme, `/controls/${control}/evidence`)); + expect(own.data.map((row) => row.title)).toEqual(["Last run"]); + }); + + it("answers 404 for another organization's requirement, as for one that does not exist", async () => { + const theirs = await request(globex, "/standards", { + method: "POST", + body: JSON.stringify({ + name: "ISO 27001", + edition: "2022", + requirements: [{ reference: "A.8.13", title: "Backup" }], + }), + }); + const theirStandard = (await json<{ data: { id: string } }>(theirs)).data.id; + const theirClause = ( + await json>( + await request(globex, `/standards/${theirStandard}/requirements`), + ) + ).data[0]!.id; + await record(globex, theirControl, { title: "Theirs" }); + const mapped = await request(globex, `/controls/${theirControl}/requirements`, { + method: "PUT", + body: JSON.stringify({ requirementIds: [theirClause] }), + }); + expect(mapped.status).toBe(200); + + const response = await request(acme, `/requirements/${theirClause}/evidence`); + const absent = await request(acme, "/requirements/req_0000000000000000/evidence"); + + expect(response.status).toBe(404); + expect(await json(response)).toEqual(await json(absent)); + }); + + it("breaks a tie between controls by identifier, and pages across it", async () => { + const imported = await request(acme, "/standards", { + method: "POST", + body: JSON.stringify({ + name: "Tied", + edition: "1", + requirements: [{ reference: "1", title: "One" }], + }), + }); + const standardId = (await json<{ data: { id: string } }>(imported)).data.id; + const requirement = ( + await json>(await request(acme, `/standards/${standardId}/requirements`)) + ).data[0]!.id; + const same = { occurredAt: "2026-06-01T00:00:00.000Z" }; + const ids: string[] = []; + for (const name of ["Left", "Right"]) { + const control = await newControl(name); + expect((await mapTo(control, [requirement])).status).toBe(200); + ids.push((await record(acme, control, { title: name, ...same })).id); + } + + const first = await evidenceOf(requirement, "?limit=1"); + const second = await evidenceOf( + requirement, + `?limit=1&cursor=${encodeURIComponent(first.nextCursor!)}`, + ); + + // Newest first, so the larger identifier leads when the dates tie. + expect([...first.data, ...second.data].map((row) => row.id)).toEqual(ids.sort().reverse()); + expect(second.nextCursor).toBeNull(); + }); + + it("answers 404 for an id of the wrong shape, without asking the database", async () => { + const response = await request(acme, `/requirements/${backups}/evidence`); + + expect(response.status).toBe(404); + expect((await json(response)).error.code).toBe("not_found"); + }); +}); diff --git a/apps/server/evidence.ts b/apps/server/evidence.ts new file mode 100644 index 0000000..56176ec --- /dev/null +++ b/apps/server/evidence.ts @@ -0,0 +1,585 @@ +// SPDX-FileCopyrightText: 2026 Quality Runtime contributors +// SPDX-License-Identifier: Apache-2.0 + +/** + * Evidence routes. + * + * Evidence is recorded against a control and read by its own identifier, the + * way a requirement is (ADR 0011). Attesting it is a separate act with a route + * of its own, because it is the point after which the record stops being + * editable — reasoning in `docs/adr/0012-evidence-and-attestation.md`. + */ + +import { idPattern, schema, type TenantTransaction } from "@qualityruntime/db"; +import { and, eq, getTableColumns, inArray, type SQL, sql } from "drizzle-orm"; +import { type Context, Hono } from "hono"; +import { createMiddleware } from "hono/factory"; +import { z } from "zod"; +import { diffFields, fieldsOf } from "./audit.ts"; +import type { OrganizationEnv } from "./organization.ts"; +import { + collectionQuery, + type Cursor, + cursorAt, + newestFirst, + type Ordering, + orderedBy, + page, + rowsAfter, +} from "./pagination.ts"; +import { entityTag, ifMatch, rowVersion } from "./preconditions.ts"; +import { failure } from "./responses.ts"; +import { instant, jsonBody, prose, rejection, words } from "./validation.ts"; + +/** + * A moment that has already been. + * + * Evidence of something that has not happened is not evidence, and once + * attested it cannot be corrected — so a future date is refused where it is + * cheapest, on the way in. A few minutes of tolerance, because a client's clock + * being slightly ahead is ordinary and being wrong about the future is not. + */ +const clockSkew = 5 * 60 * 1000; +const notInTheFuture = () => + instant() + .refine( + (value) => Date.parse(value) <= Date.now() + clockSkew, + "Must not be more than five minutes in the future.", + ) + .meta({ + description: + "When the thing happened, as an ISO 8601 instant with an offset. The UTC year must be " + + "from 0001 through 9999. Must not be more than five minutes in the future, an " + + "allowance for a client clock running ahead.", + }); + +const recordBody = z.object({ + title: words(200), + description: prose(10_000).nullish(), + /** When the thing happened. Required: undated evidence evidences little. */ + occurredAt: notInTheFuture(), +}); + +/** Partial amendment: at least one field is required. The handler permits drafts only. */ +const amendBody = z + .object({ + title: words(200).optional(), + description: prose(10_000).nullable().optional(), + occurredAt: notInTheFuture().optional(), + }) + .refine((body) => Object.keys(body).length > 0, { + message: "Provide at least one field to change.", + }) + .meta({ + anyOf: [{ required: ["title"] }, { required: ["description"] }, { required: ["occurredAt"] }], + description: "At least one of title, description or occurredAt.", + }); + +export { recordBody as evidenceBody, amendBody as evidenceAmendBody }; + +/** Evidence, as a client sees it. The attestation reads as one thing. */ +export const evidenceResponse = z.strictObject({ + id: z.string(), + organizationId: z.string(), + controlId: z.string(), + title: z.string(), + description: z.string().nullable(), + occurredAt: z.iso.datetime(), + attestation: z + .strictObject({ + at: z.iso.datetime(), + by: z.strictObject({ id: z.string(), label: z.string().nullable() }), + }) + .nullable(), + createdAt: z.iso.datetime(), + updatedAt: z.iso.datetime(), +}); + +const version = rowVersion(schema.evidence); + +/** A control's evidence, most recently occurred first. */ +export const evidenceOrder = (controlId: string) => + newestFirst(`control-evidence/${controlId}`, schema.evidence.occurredAt, schema.evidence.id); + +/** + * The evidence of the controls mapped to a requirement, in the same order. + * Scoped to the requirement, like every cursor. + */ +export const requirementEvidenceOrder = (requirementId: string) => + newestFirst( + `requirement-evidence/${requirementId}`, + schema.evidence.occurredAt, + schema.evidence.id, + ); + +/** The one answer for an `If-Match` that no longer names this evidence. */ +const staleEvidence = (c: Context) => + c.json( + failure("precondition_failed", "If-Match does not match the evidence's current ETag.", [ + { path: "", message: "Read it again, and decide against what it now says." }, + ]), + 412, + ); + +/** + * What an empty locked read means: attested, or gone. + * + * `select … for update` is governed by the UPDATE policy, which sees only + * unattested rows — so an attested row is not there to lock. It is also not + * there if it was deleted while this transaction waited for the lock, and the + * two are indistinguishable from the lock alone. Reading again without one + * tells them apart; answering "already attested" for a record that was + * discarded and never signed is a confident wrong answer. + */ +async function whyNotLocked( + tx: TenantTransaction, + evidenceId: string, +): Promise<{ outcome: "attested" } | { outcome: "missing" }> { + const [present] = await tx + .select({ id: schema.evidence.id }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)); + return present ? { outcome: "attested" } : { outcome: "missing" }; +} + +const isEvidenceId = new RegExp(idPattern("evidence")); + +const knownEvidenceId = createMiddleware(async (c, next) => { + if (!isEvidenceId.test(c.req.param("evidenceId") ?? "")) { + return c.json(failure("not_found", "No such evidence."), 404); + } + await next(); +}); + +/** + * What audit history records about evidence. `controlId` never changes, but + * once discarded evidence is gone, its history is the only record of which + * control it belonged to. + */ +const audited = ["controlId", "title", "description", "occurredAt"] as const; + +type Row = typeof schema.evidence.$inferSelect; + +/** One page of evidence narrowed by `where`. */ +async function evidencePage( + tx: TenantTransaction, + ordering: Ordering, + { where, limit, cursor }: { where: SQL; limit: number; cursor: Cursor | undefined }, +) { + const rows = await tx + .select({ ...getTableColumns(schema.evidence), cursorAt: cursorAt(ordering) }) + .from(schema.evidence) + .where(and(where, cursor ? rowsAfter(ordering, cursor) : undefined)) + .orderBy(...orderedBy(ordering)) + .limit(limit + 1); + return rows; +} + +const evidenceShape = (row: Row) => ({ + id: row.id, + organizationId: row.organizationId, + controlId: row.controlId, + title: row.title, + description: row.description, + occurredAt: row.occurredAt, + attestation: + row.attestedAt && row.attestedById + ? { at: row.attestedAt, by: { id: row.attestedById, label: row.attestedByLabel } } + : null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, +}); + +export const evidence = new Hono() + .get("/controls/:controlId/evidence", async (c) => { + const controlId = c.req.param("controlId"); + if (!new RegExp(idPattern("control")).test(controlId)) { + return c.json(failure("not_found", "No such control."), 404); + } + const ordering = evidenceOrder(controlId); + const query = collectionQuery(ordering).safeParse(c.req.query()); + if (!query.success) return c.json(rejection("query", query.error), 400); + const { limit, cursor } = query.data; + + const found = await c.var.withOrganization( + async (tx) => { + const [control] = await tx + .select({ id: schema.control.id }) + .from(schema.control) + .where(eq(schema.control.id, controlId)); + if (!control) return undefined; + + return evidencePage(tx, ordering, { + where: eq(schema.evidence.controlId, controlId), + limit, + cursor, + }); + }, + { repeatableRead: true }, + ); + if (!found) return c.json(failure("not_found", "No such control."), 404); + + const { rows, nextCursor } = page(found, limit, ordering); + return c.json({ data: rows.map(evidenceShape), nextCursor }); + }) + + /** + * The evidence recorded for the controls currently mapped to a requirement — + * drafts and attested, from controls in any state. Not coverage: a mapping + * is an intention and an attestation endorses the evidence, not the mapping + * (ADR 0012). Unmapping a control takes its evidence out of this view and + * leaves the evidence as it was. + */ + .get("/requirements/:requirementId/evidence", async (c) => { + const requirementId = c.req.param("requirementId"); + if (!new RegExp(idPattern("requirement")).test(requirementId)) { + return c.json(failure("not_found", "No such requirement."), 404); + } + const ordering = requirementEvidenceOrder(requirementId); + const query = collectionQuery(ordering).safeParse(c.req.query()); + if (!query.success) return c.json(rejection("query", query.error), 400); + const { limit, cursor } = query.data; + + const found = await c.var.withOrganization( + async (tx) => { + // Visible first, or its absence would read as a requirement with no + // evidence. + const [requirement] = await tx + .select({ id: schema.requirement.id }) + .from(schema.requirement) + .where(eq(schema.requirement.id, requirementId)); + if (!requirement) return undefined; + + // A semi-join rather than a join: each evidence row has one control + // and each mapping is unique per pair, so a join could not duplicate a + // row today — but `in` says "evidence of a mapped control" and cannot + // start to. + return evidencePage(tx, ordering, { + where: inArray( + schema.evidence.controlId, + tx + .select({ controlId: schema.controlRequirement.controlId }) + .from(schema.controlRequirement) + .where(eq(schema.controlRequirement.requirementId, requirementId)), + ), + limit, + cursor, + }); + }, + { repeatableRead: true }, + ); + if (!found) return c.json(failure("not_found", "No such requirement."), 404); + + const { rows, nextCursor } = page(found, limit, ordering); + return c.json({ data: rows.map(evidenceShape), nextCursor }); + }) + + .post("/controls/:controlId/evidence", jsonBody(recordBody), async (c) => { + const controlId = c.req.param("controlId"); + if (!new RegExp(idPattern("control")).test(controlId)) { + return c.json(failure("not_found", "No such control."), 404); + } + const body = c.req.valid("json"); + + const result = await c.var.withOrganization(async (tx) => { + // `for key share` is the lock the foreign key below will take anyway, + // taken early and held for the whole transaction. It conflicts with the + // `for update` a discard holds, so the two cannot interleave: whichever + // arrives second waits and then sees the world the first left. Without + // it, a control discarded in between turned this into a foreign key + // violation and a 500 (ADR 0020). + // + // It does not conflict with itself, so evidence being recorded against + // the same control concurrently is unaffected. + const [control] = await tx + .select({ id: schema.control.id }) + .from(schema.control) + .where(eq(schema.control.id, controlId)) + .for("key share"); + if (!control) return undefined; + + const [row] = await tx + .insert(schema.evidence) + .values({ + organizationId: c.var.member.organizationId, + controlId, + title: body.title, + description: body.description ?? null, + occurredAt: new Date(body.occurredAt), + }) + .returning({ ...getTableColumns(schema.evidence), version }); + + await c.var.audit(tx, { + action: "created", + resourceType: "evidence", + resourceId: row!.id, + after: fieldsOf(row!, audited), + }); + return row!; + }); + if (!result) return c.json(failure("not_found", "No such control."), 404); + + // Its tag, so that what was just recorded can be attested without reading + // it again: the body is what the client has now seen. + c.header("etag", entityTag(result)); + return c.json({ data: evidenceShape(result) }, 201); + }) + + .get("/evidence/:evidenceId", knownEvidenceId, async (c) => { + const evidenceId = c.req.param("evidenceId"); + const [row] = await c.var.withOrganization((tx) => + tx + .select({ ...getTableColumns(schema.evidence), version }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)), + ); + if (!row) return c.json(failure("not_found", "No such evidence."), 404); + + // What an attestation has to quote back, so that what was signed is what + // was read. + c.header("etag", entityTag(row)); + return c.json({ data: evidenceShape(row) }); + }) + + .patch("/evidence/:evidenceId", knownEvidenceId, jsonBody(amendBody), async (c) => { + const evidenceId = c.req.param("evidenceId"); + const body = c.req.valid("json"); + + const result = await c.var.withOrganization(async (tx) => { + // Read without locking. `select … for update` is governed by the UPDATE + // policy as well as the SELECT one, so an attested row is not there to + // lock — and "cannot be locked" would come back as "does not exist". + const [current] = await tx + .select() + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)); + if (!current) return { outcome: "missing" } as const; + if (current.attestedAt) return { outcome: "attested" } as const; + + // Lock before computing the diff: a concurrent amendment could make an + // unlocked preimage stale, corrupting the audit diff or making a real + // change look like a no-op. + const [locked] = await tx + .select({ ...getTableColumns(schema.evidence), version }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)) + .for("update"); + // It may have been attested or discarded while waiting for the lock. + if (!locked) return whyNotLocked(tx, evidenceId); + + // Compared after the lock, so the version cannot move between the test + // and the write — no need to repeat it in the WHERE below. + if (ifMatch(c.req.header("if-match"), entityTag(locked)) === "failed") { + return { outcome: "stale" } as const; + } + + const updates = { + ...(body.title === undefined ? {} : { title: body.title }), + ...(body.description === undefined ? {} : { description: body.description }), + ...(body.occurredAt === undefined ? {} : { occurredAt: new Date(body.occurredAt) }), + }; + // Nothing different means nothing to write. An UPDATE would still move + // the version, staling the tag an attester is about to quote while + // history said nothing happened — the rule controls follow too. + const changed = diffFields( + fieldsOf(locked, audited), + fieldsOf({ ...locked, ...updates }, audited), + ); + if (!changed) return { outcome: "amended", row: locked } as const; + + const [row] = await tx + .update(schema.evidence) + .set(updates) + .where(eq(schema.evidence.id, evidenceId)) + .returning({ ...getTableColumns(schema.evidence), version }); + // The lock makes this impossible: a locked row is an unattested one, and + // nothing can attest it until this transaction ends. + if (!row) throw new Error(`Evidence ${evidenceId} was locked as a draft but not amended.`); + + await c.var.audit(tx, { + action: "updated", + resourceType: "evidence", + resourceId: evidenceId, + before: changed.before, + after: changed.after, + }); + return { outcome: "amended", row } as const; + }); + + if (result.outcome === "missing") { + return c.json(failure("not_found", "No such evidence."), 404); + } + if (result.outcome === "attested") { + return c.json( + failure("already_attested", "Attested evidence cannot be changed.", [ + { path: "", message: "Record new evidence instead." }, + ]), + 409, + ); + } + if (result.outcome === "stale") return staleEvidence(c); + + c.header("etag", entityTag(result.row)); + return c.json({ data: evidenceShape(result.row) }); + }) + + .delete("/evidence/:evidenceId", knownEvidenceId, async (c) => { + const evidenceId = c.req.param("evidenceId"); + + // Only unattested evidence may be discarded (ADR 0012). Removing it lets + // a draft control with no remaining evidence be discarded too (ADR 0017). + const result = await c.var.withOrganization(async (tx) => { + // Read unlocked first, for the same reason the amendment does: `select … + // for update` is governed by the UPDATE policy, so an attested row is + // not there to lock, and "cannot be locked" would come back as "does not + // exist". + const [current] = await tx + .select({ id: schema.evidence.id, attestedAt: schema.evidence.attestedAt }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)); + // This read is what tells 404 from 409: a lock that finds nothing cannot + // say whether the row was absent or attested. + if (!current) return { outcome: "missing" } as const; + if (current.attestedAt) return { outcome: "attested" } as const; + + // Now that it is known to be a draft, lock it — and compare the version + // against the locked row. Comparing an unlocked read would leave the row + // free to be amended between the test and the delete, which is exactly + // what a conditional write is for (ADR 0019). + const [locked] = await tx + .select({ ...getTableColumns(schema.evidence), version }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)) + .for("update"); + // It may have been attested or discarded while waiting for the lock. + if (!locked) return whyNotLocked(tx, evidenceId); + + if (ifMatch(c.req.header("if-match"), entityTag(locked)) === "failed") { + return { outcome: "stale" } as const; + } + + const [removed] = await tx + .delete(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)) + .returning(); + // Impossible under the lock, for the same reason as the amendment. + if (!removed) + throw new Error(`Evidence ${evidenceId} was locked as a draft but not deleted.`); + + await c.var.audit(tx, { + action: "deleted", + resourceType: "evidence", + resourceId: evidenceId, + before: fieldsOf(locked, audited), + }); + + return { outcome: "discarded" } as const; + }); + + if (result.outcome === "missing") { + return c.json(failure("not_found", "No such evidence."), 404); + } + if (result.outcome === "attested") { + return c.json( + failure("already_attested", "Attested evidence cannot be removed.", [ + { path: "", message: "What was attested is kept; record a correction instead." }, + ]), + 409, + ); + } + if (result.outcome === "stale") return staleEvidence(c); + return c.body(null, 204); + }) + + .put("/evidence/:evidenceId/attestation", knownEvidenceId, async (c) => { + const evidenceId = c.req.param("evidenceId"); + // An administrator acting as a member may do that member's work; vouching + // is not work, it is a signature, and signing as somebody else is forgery + // however it is logged (ADR 0012). + // Signing means signing something in particular. Without this a client can + // attest content it never saw, because someone amended the draft between + // the read and the signature. + const ifMatch = c.req.header("if-match"); + if (ifMatch === undefined) { + return c.json( + failure("precondition_required", "Attesting requires the If-Match of the evidence read.", [ + { path: "", message: "Read the evidence and quote its ETag back." }, + ]), + 428, + ); + } + if (c.var.actor.onBehalfOf) { + return c.json( + failure("impersonated", "Evidence cannot be attested while impersonating.", [ + { path: "", message: "Attesting is a personal act and is not delegated." }, + ]), + 403, + ); + } + + const result = await c.var.withOrganization(async (tx) => { + // Unlocked, for the same reason as the amend above. + const [current] = await tx + .select({ id: schema.evidence.id, attestedAt: schema.evidence.attestedAt, version }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)); + if (!current) return { outcome: "missing" } as const; + if (current.attestedAt) return { outcome: "attested" } as const; + if (ifMatch !== entityTag(current)) return { outcome: "stale" } as const; + + // The version goes in the WHERE as well, so the check and the write are + // one statement: an amendment landing in between matches nothing. + const [row] = await tx + .update(schema.evidence) + .set({ + // The database's clock, which audit history uses too, rather than + // whichever server handled the request. + attestedAt: sql`clock_timestamp()`, + attestedById: c.var.actor.id, + attestedByLabel: c.var.actor.label, + }) + .where(and(eq(schema.evidence.id, evidenceId), sql`${version} = ${current.version}`)) + .returning(); + if (!row) { + // Matching nothing is also what attested or discarded meanwhile look + // like, and those have answers of their own. Read again to tell them + // apart, as the amend and discard do after an empty lock. + const [now] = await tx + .select({ attestedAt: schema.evidence.attestedAt }) + .from(schema.evidence) + .where(eq(schema.evidence.id, evidenceId)); + if (!now) return { outcome: "missing" } as const; + if (now.attestedAt) return { outcome: "attested" } as const; + return { outcome: "stale" } as const; + } + + await c.var.audit(tx, { + action: "attested", + resourceType: "evidence", + resourceId: evidenceId, + after: { attestedAt: row.attestedAt, attestedById: row.attestedById }, + }); + return { outcome: "attested_now", row } as const; + }); + + if (result.outcome === "missing") { + return c.json(failure("not_found", "No such evidence."), 404); + } + if (result.outcome === "attested") { + return c.json( + failure("already_attested", "This evidence has already been attested.", [ + { path: "", message: "An attestation is one act and is not repeated." }, + ]), + 409, + ); + } + if (result.outcome === "stale") { + return c.json( + failure("precondition_failed", "If-Match does not match the evidence's current ETag.", [ + { path: "", message: "Read it again, and attest what it now says." }, + ]), + 412, + ); + } + return c.json({ data: evidenceShape(result.row) }); + }); diff --git a/apps/server/openapi.test.ts b/apps/server/openapi.test.ts index 9a3e10f..49ab7c0 100644 --- a/apps/server/openapi.test.ts +++ b/apps/server/openapi.test.ts @@ -266,18 +266,22 @@ describe("the requests it promises to accept", () => { }); it.each([ - ["get", "/api/v1/organizations/{organizationId}/controls/{controlId}"], - ["patch", "/api/v1/organizations/{organizationId}/controls/{controlId}"], - ["get", "/api/v1/organizations/{organizationId}/controls/{controlId}/requirements"], - ["put", "/api/v1/organizations/{organizationId}/controls/{controlId}/requirements"], - ])("says that %s %s answers with an ETag", (method, path) => { + ["get", "/api/v1/organizations/{organizationId}/controls/{controlId}", "200"], + ["patch", "/api/v1/organizations/{organizationId}/controls/{controlId}", "200"], + ["get", "/api/v1/organizations/{organizationId}/evidence/{evidenceId}", "200"], + ["patch", "/api/v1/organizations/{organizationId}/evidence/{evidenceId}", "200"], + ["post", "/api/v1/organizations/{organizationId}/controls/{controlId}/evidence", "201"], + ["get", "/api/v1/organizations/{organizationId}/controls/{controlId}/requirements", "200"], + ["put", "/api/v1/organizations/{organizationId}/controls/{controlId}/requirements", "200"], + ])("says that %s %s answers %s with an ETag", (method, path, status) => { // A document that asks for `If-Match` and never says where the tag comes - // from describes half a contract (ADR 0019). + // from describes half a contract (ADR 0019). Recording evidence answers + // with one too, so that it can be attested without a second read. const operation = ( openApiDocument(sessionCookieName(auth)).paths[path] as Record )[method] as { responses: Record }> }; - expect(operation.responses["200"]?.headers).toHaveProperty("ETag"); + expect(operation.responses[status]?.headers).toHaveProperty("ETag"); }); it("publishes where a cursor comes from", () => { @@ -536,3 +540,101 @@ describe("the responses it promises for standards and mappings", () => { ); }); }); + +describe("the responses it promises for evidence", () => { + const tenant = "/api/v1/organizations/{organizationId}"; + const one = `${tenant}/evidence/{evidenceId}`; + let document: Document; + let controlId: string; + let requirementId: string; + + const at = ( + path: string, + init: Omit & { headers?: Record } = {}, + ) => + app.request(`/api/v1/organizations/${acme.organizationId}${path}`, { + ...init, + headers: { + cookie: acme.cookie, + ...(init.body ? { "content-type": "application/json" } : {}), + ...init.headers, + }, + }); + + /** Records evidence and answers its id and the tag it came back with. */ + const recorded = async (title: string) => { + const response = await at(`/controls/${controlId}/evidence`, { + method: "POST", + body: JSON.stringify({ title, occurredAt: "2026-07-01T09:00:00.000Z" }), + }); + expect(response.status).toBe(201); + return { + id: ((await response.clone().json()) as { data: { id: string } }).data.id, + tag: response.headers.get("etag")!, + response, + }; + }; + + beforeAll(async () => { + document = openApiDocument(sessionCookieName(auth)); + controlId = (await create("Evidenced")).id; + const imported = await at("/standards", { + method: "POST", + body: JSON.stringify({ + name: "Evidenced standard", + edition: "1", + requirements: [{ reference: "1", title: "One" }], + }), + }); + const standardId = (await json<{ data: { id: string } }>(imported)).data.id; + const listed = await at(`/standards/${standardId}/requirements`); + requirementId = (await json<{ data: { id: string }[] }>(listed)).data[0]!.id; + await at(`/controls/${controlId}/requirements`, { + method: "PUT", + body: JSON.stringify({ requirementIds: [requirementId] }), + }); + }); + + it("describes recorded evidence", async () => { + const { response } = await recorded("Recorded"); + + await conformsToDocument(document, "post", `${tenant}/controls/{controlId}/evidence`, response); + }); + + it.each([ + ["one piece of evidence", "get", one], + ["an amendment", "patch", one], + ["an attestation", "put", `${one}/attestation`], + ])("describes %s", async (_case, method, documented) => { + const { id, tag } = await recorded(`For ${method}`); + const init = + method === "patch" + ? { method: "PATCH", body: JSON.stringify({ title: "Amended" }) } + : method === "put" + ? { method: "PUT", headers: { "if-match": tag } } + : {}; + + const response = await at(documented.replace(tenant, "").replace("{evidenceId}", id), init); + + expect(response.status).toBe(200); + await conformsToDocument(document, method, documented, response); + }); + + it.each([ + ["a control's evidence", `${tenant}/controls/{controlId}/evidence`], + ["a requirement's evidence", `${tenant}/requirements/{requirementId}/evidence`], + ])("describes a page of %s, with an item in it", async (_case, documented) => { + await recorded("Listed"); + const response = await at( + documented + .replace(tenant, "") + .replace("{controlId}", controlId) + .replace("{requirementId}", requirementId), + ); + + expect(response.status).toBe(200); + const body = (await response.clone().json()) as { data: unknown[] }; + expect(body.data.length).toBeGreaterThan(0); + await conformsToDocument(document, "get", documented, response); + }); +}); diff --git a/apps/server/openapi.ts b/apps/server/openapi.ts index ef1c178..edb6395 100644 --- a/apps/server/openapi.ts +++ b/apps/server/openapi.ts @@ -24,6 +24,13 @@ import { updateBody, } from "./controls.ts"; import { collectionQuery } from "./pagination.ts"; +import { + evidenceAmendBody, + evidenceBody, + evidenceOrder, + evidenceResponse, + requirementEvidenceOrder, +} from "./evidence.ts"; import { requirementControlsOrder, requirementResponse } from "./requirements.ts"; import { importBody, @@ -106,6 +113,7 @@ const pathParameterTypes: Record = { controlId: "control", standardId: "standard", requirementId: "requirement", + evidenceId: "evidence", }; /** Derived from the path itself, so a parameter cannot be left undescribed. */ @@ -122,7 +130,11 @@ function pathParameters(path: string) { }); } -/** `If-Match` where it is optional: supplied, the write is conditional on it (ADR 0019). */ +/** + * `If-Match` where it is optional: supplied, the write is conditional on it. + * + * Attesting names its own, because there it is required (ADR 0019). + */ const conditional = { name: "If-Match", in: "header", @@ -308,6 +320,96 @@ const operations: Operation[] = [ "404": fails("No such standard."), }, }, + { + method: "get", + path: `${controls}/{controlId}/evidence`, + summary: "List a control's evidence, most recently occurred first.", + // Any control: the published schema is the same whichever it is. + query: collectionQuery(evidenceOrder("{controlId}")), + responses: { + "200": responds("A page of evidence.", collection(evidenceResponse)), + "400": fails("The query is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such control."), + }, + }, + { + method: "post", + path: `${controls}/{controlId}/evidence`, + summary: "Record evidence for a control. It starts unattested.", + request: evidenceBody, + responses: { + "201": versioned("The evidence that was recorded.", single(evidenceResponse)), + "400": fails("The body is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such control."), + "413": fails("The body is too large."), + }, + }, + { + method: "get", + path: `${tenant}/evidence/{evidenceId}`, + summary: "Retrieve one piece of evidence.", + responses: { + "200": versioned("The evidence.", single(evidenceResponse)), + "401": fails("The request is not authenticated."), + "404": fails("No such evidence."), + }, + }, + { + method: "patch", + path: `${tenant}/evidence/{evidenceId}`, + summary: "Change evidence that has not been attested.", + parameters: [conditional], + request: evidenceAmendBody, + responses: { + "200": versioned("The evidence as it now stands.", single(evidenceResponse)), + "400": fails("The body is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such evidence."), + "409": fails("The evidence is attested, and attested evidence does not change."), + "413": fails("The body is too large."), + "412": fails("If-Match does not match the evidence's current ETag."), + }, + }, + { + method: "delete", + path: `${tenant}/evidence/{evidenceId}`, + summary: "Discard unattested evidence.", + parameters: [conditional], + responses: { + "204": { description: "The evidence is gone." }, + "401": fails("The request is not authenticated."), + "404": fails("No such evidence."), + "409": fails("The evidence is attested, and what was attested is kept."), + "412": fails("If-Match does not match the evidence's current ETag."), + }, + }, + { + method: "put", + path: `${tenant}/evidence/{evidenceId}/attestation`, + summary: "Attest evidence, vouching for it. It cannot be changed afterwards.", + parameters: [ + { + name: "If-Match", + in: "header", + required: true, + description: + "The exact strong ETag of the evidence as it was read. Wildcards (`*`), lists and " + + "weak tags are refused: an attestation endorses that specific version.", + schema: { type: "string" }, + }, + ], + responses: { + "200": responds("The evidence, now attested.", single(evidenceResponse)), + "401": fails("The request is not authenticated."), + "403": fails("Attesting is refused while impersonating."), + "404": fails("No such evidence."), + "409": fails("The evidence has already been attested."), + "412": fails("If-Match does not match the evidence's current ETag."), + "428": fails("If-Match is required."), + }, + }, { method: "get", path: `${tenant}/requirements/{requirementId}`, @@ -331,6 +433,21 @@ const operations: Operation[] = [ "404": fails("No such requirement."), }, }, + { + method: "get", + path: `${tenant}/requirements/{requirementId}/evidence`, + summary: + "List the evidence recorded for the controls mapped to a requirement, most recently " + + "occurred first. A mapping is not a claim of coverage.", + // Any requirement: the published schema is the same whichever it is. + query: collectionQuery(requirementEvidenceOrder("{requirementId}")), + responses: { + "200": responds("A page of evidence.", collection(evidenceResponse)), + "400": fails("The query is not valid."), + "401": fails("The request is not authenticated."), + "404": fails("No such requirement."), + }, + }, { method: "get", path: `${tenant}/history`, diff --git a/apps/server/organization.ts b/apps/server/organization.ts index b5ae8b5..0f1b71c 100644 --- a/apps/server/organization.ts +++ b/apps/server/organization.ts @@ -43,6 +43,13 @@ export type OrganizationEnv = { * for the same reason: a handler says what happened, never who did it. */ audit: RecordChange; + /** + * Who the request is attributable to, resolved once. Handlers that record + * attribution of their own — an attestation, say — take it from here + * rather than from the session user, which under impersonation is the + * member being acted as rather than the administrator acting. + */ + actor: Actor; }; }; @@ -138,6 +145,7 @@ export function organizationContext({ } : { type: "user", id: session.user.id, label: session.user.name || null }; + c.set("actor", actor); c.set("audit", (tx, change) => recordChange(tx, actor, organizationId, change)); // The driver is erased here so handlers need not be generic over it; every // transaction method a handler uses is identical across drivers. diff --git a/apps/server/preconditions.ts b/apps/server/preconditions.ts index e5ceccd..94c85de 100644 --- a/apps/server/preconditions.ts +++ b/apps/server/preconditions.ts @@ -6,8 +6,10 @@ * * Two people editing one record is otherwise last-writer-wins, and the loser * never learns. A caller that read a record can name the version it read and be - * refused if it has moved since (ADR 0019). Optional: a caller that does not - * ask gets the write regardless. + * refused if it has moved since (ADR 0019). + * + * Optional everywhere except attesting, which is a signature and has to be of + * something in particular ([ADR 0012](../../docs/adr/0012-evidence-and-attestation.md)). */ import { createHash } from "node:crypto"; diff --git a/apps/server/privileges.test.ts b/apps/server/privileges.test.ts index 389e8aa..8fc4bad 100644 --- a/apps/server/privileges.test.ts +++ b/apps/server/privileges.test.ts @@ -58,39 +58,6 @@ const asJson = (body: unknown) => ({ body: JSON.stringify(body), }); -/** - * Evidence against a control, written as the runtime role inside the tenant. - * No route records evidence yet; the table, its policies and the privileges - * are what is being tested. - */ -const recordEvidence = (controlId: string, { attested = false } = {}) => - withOrganization(db, acme.organizationId, async (tx) => { - const [row] = await tx - .insert(schema.evidence) - .values({ - organizationId: acme.organizationId, - controlId, - title: "Minutes", - occurredAt: new Date("2026-07-01T09:00:00.000Z"), - }) - .returning(); - if (attested) { - const [signed] = await tx - .update(schema.evidence) - .set({ - attestedAt: new Date(), - attestedById: "usr_0000000000000000", - attestedByLabel: "Ada", - }) - .where(eq(schema.evidence.id, row!.id)) - .returning(); - // A fixture that silently failed to attest would let every test built - // on it pass for the wrong reason. - expect(signed?.attestedAt).toBeInstanceOf(Date); - } - return row!.id; - }); - /** Whether a statement was refused, and what PostgreSQL said. */ async function refused(statement: string): Promise { try { @@ -160,19 +127,14 @@ describe("the product runs on the privileges it documents", () => { expect(acme.organizationId).toMatch(/^org_[0-9a-z]{16}$/); }); - it("changes a control and reads its history", async () => { - const activated = await request(acme, `/controls/${control}`, { - method: "PATCH", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ status: "active" }), - }); - expect(activated.status).toBe(200); - - const history = await request(acme, `/history?resource=${control}`); - const { data } = await json<{ data: { action: string }[] }>(history); + it("records evidence against a control", async () => { + const evidence = await request( + acme, + `/controls/${control}/evidence`, + asJson({ title: "Q3 review", occurredAt: "2026-07-01T09:00:00.000Z" }), + ); - expect(history.status).toBe(200); - expect(data.map((event) => event.action).sort()).toEqual(["created", "updated"]); + expect(evidence.status).toBe(201); }); it("imports a standard and maps a control to it", async () => { @@ -200,6 +162,29 @@ describe("the product runs on the privileges it documents", () => { expect(mapped.status).toBe(200); }); + it("attests evidence and then refuses to change it", async () => { + const evidence = await request( + acme, + `/controls/${control}/evidence`, + asJson({ title: "Attested here", occurredAt: "2026-07-01T09:00:00.000Z" }), + ); + const evidenceId = (await json<{ data: { id: string } }>(evidence)).data.id; + const tag = (await request(acme, `/evidence/${evidenceId}`)).headers.get("etag")!; + + const attested = await request(acme, `/evidence/${evidenceId}/attestation`, { + method: "PUT", + headers: { "if-match": tag }, + }); + const amended = await request(acme, `/evidence/${evidenceId}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ title: "Rewritten" }), + }); + + expect(attested.status).toBe(200); + expect(amended.status).toBe(409); + }); + it("needs no privilege on a sequence, because nothing here has one", async () => { // Identifiers are generated by the application (ADR 0002), so a deployment // granting only table privileges is not missing something. @@ -242,7 +227,15 @@ describe("what the runtime role cannot do", () => { it("cannot change attested evidence, though it may change a draft", async () => { // Here the privilege is granted and the policy is what refuses: evidence is - // ordinary until it is attested. + // ordinary until it is attested (ADR 0012). + const record = async () => { + const evidence = await request( + acme, + `/controls/${control}/evidence`, + asJson({ title: "Final", occurredAt: "2026-07-01T09:00:00.000Z" }), + ); + return (await json<{ data: { id: string } }>(evidence)).data.id; + }; const retitle = (evidenceId: string) => withOrganization(db, acme.organizationId, (tx) => tx @@ -252,8 +245,19 @@ describe("what the runtime role cannot do", () => { .returning(), ); - expect(await retitle(await recordEvidence(control))).toHaveLength(1); - expect(await retitle(await recordEvidence(control, { attested: true }))).toEqual([]); + expect(await retitle(await record())).toHaveLength(1); + + const signed = await record(); + const tag = (await request(acme, `/evidence/${signed}`)).headers.get("etag")!; + const attested = await request(acme, `/evidence/${signed}/attestation`, { + method: "PUT", + headers: { "if-match": tag }, + }); + // Without this, a failed attestation would let the refusal below pass for + // the wrong reason. + expect(attested.status).toBe(200); + + expect(await retitle(signed)).toEqual([]); }); it("offers no route that would delete an organization", async () => { @@ -284,7 +288,12 @@ describe("what the runtime role cannot do", () => { // and the delete would match nothing for the wrong reason. const made = await request(acme, "/controls", asJson({ name: "Backup restore" })); const doomed = (await json<{ data: { id: string } }>(made)).data.id; - await recordEvidence(doomed); + const recorded = await request( + acme, + `/controls/${doomed}/evidence`, + asJson({ title: "Restored", occurredAt: "2026-07-01T09:00:00.000Z" }), + ); + expect(recorded.status).toBe(201); // Drizzle wraps the driver's error, so PostgreSQL's reason is the cause. const error = await withOrganization(db, acme.organizationId, (tx) => diff --git a/apps/server/validation.ts b/apps/server/validation.ts index 3c71214..cb69ad9 100644 --- a/apps/server/validation.ts +++ b/apps/server/validation.ts @@ -41,6 +41,35 @@ export const words = (max: number) => .transform((value) => value.trim()) .meta({ description: "Surrounding whitespace is removed once the length is checked." }); +/** + * A moment in time a client supplies, which PostgreSQL will accept. + * + * `z.iso.datetime` settles the format; it says nothing about the instant that + * results. An offset can carry a date out of the four digits this API deals in + * — `9999-12-31T23:59:59-01:00` normalises to year 10000, which `toISOString` + * then writes in the expanded `+010000` form — and year zero parses here while + * PostgreSQL has no such year. The first would travel as a shape no cursor or + * client expects; the second is a 500 for what is a bad request. + * + * PostgreSQL itself reaches far past four digits. The narrower bound is this + * API's, taken because every timestamp it emits is a plain ISO string and + * nothing here has a use for the year 30000. + */ +export const instant = () => + z.iso + .datetime({ offset: true }) + .refine((value) => { + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) return false; + const iso = parsed.toISOString(); + return /^\d{4}-/.test(iso) && !iso.startsWith("0000-"); + }, "Must have a year from 0001 through 9999 once the offset is applied.") + .meta({ + description: + "An ISO 8601 instant with an offset. Once the offset is applied, the year must be " + + "from 0001 through 9999.", + }); + /** Longer text, which may be empty once trimmed. */ export const prose = (max: number) => z @@ -66,8 +95,8 @@ export const rejection = (what: string, error: z.ZodError) => /** * Parses a JSON body against `schema`, answering 400 when it does not fit. * - * The supplied schema decides how to handle unknown properties: control - * bodies strip them, while standard imports reject them. + * The supplied schema decides how to handle unknown properties: control and + * evidence bodies strip them, while standard imports reject them. */ export const jsonBody = (schema: T) => validator("json", (value, c) => { diff --git a/docs/adr/0012-evidence-and-attestation.md b/docs/adr/0012-evidence-and-attestation.md new file mode 100644 index 0000000..313a036 --- /dev/null +++ b/docs/adr/0012-evidence-and-attestation.md @@ -0,0 +1,63 @@ +# 12. Evidence, and what attesting it settles + +Date: 2026-09-18 + +## Status + +Accepted + +## Context + +A control answering to a requirement asserts an intention. Evidence is what makes the assertion checkable: a record that the control was actually operated — a review performed, a restore tested, training completed — with a date and someone prepared to vouch for it. + +Evidence is the first signed record in the runtime, so it is the first that VERSION-01 governs: controlled or finalized records must not silently lose historical state. + +File storage is outside this decision: it requires a runtime capability and deployment adapter, a separate concern from modelling evidence and enforcing attestation. + +## Decision + +**Evidence belongs to a control**, by the composite foreign key every tenant-owned child uses ([ADR 0008](0008-standards-and-requirements.md)), and is read by its own identifier the way a requirement is ([ADR 0011](0011-reading-a-mapping-from-both-ends.md)). + +**`occurredAt` is when the thing happened**, not when the row was written. Evidence of a review done last quarter is evidence about last quarter, whenever somebody got round to recording it, and a control's evidence is listed in that order. It is required — undated evidence evidences very little — and it may not be in the future, with a few minutes' tolerance for a client's clock. Nothing that has not happened is evidence that it did, and once attested there is no correcting it. A date before the control existed is allowed: recording work done earlier is ordinary. + +**Attesting is a separate act with a route of its own**, `PUT /evidence/{id}/attestation`, and it is refused while impersonating. An administrator acting as a member may do that member's work; vouching is not work, it is a signature, and signing as somebody else is forgery however carefully it is logged. The attester is the resolved actor rather than `session.user`, which under impersonation is the person being acted as. + +Attesting requires `If-Match`, quoting the `ETag` the evidence was read with. A signature is a signature on _something_, and without a precondition one person can read a draft, another amend it, and the first sign what they never saw. The tag is the row's `xmin` — the transaction that last wrote it — because `updated_at` is set from JavaScript and carries only milliseconds, so two amendments inside one millisecond would share a tag that still matched. The version goes into the `UPDATE`'s `WHERE` as well, so checking and signing are one statement. + +It records who vouched and when — by identifier and by the name as it stood, because a record of who vouched for something is worth nothing if it disappears with them, the same reasoning audit history uses ([ADR 0005](0005-audit-history.md)). It is one act rather than a repeatable one: attesting twice is 409, not a second attestation with a later timestamp. + +**An attested record cannot be changed, and PostgreSQL is what says so.** A tenant-owned table normally has one policy covering every command. Evidence names them, because what a tenant may do to a row depends on the row — as `control` later came to as well ([ADR 0017](0017-discarding-a-draft-control.md)): + +```sql +CREATE POLICY "evidence_tenant_amend" ON "evidence" FOR UPDATE + USING (organization matches AND "attested_at" IS NULL) + WITH CHECK (organization matches); +``` + +`USING` decides which rows an `UPDATE` can see, and it sees only drafts. Attesting is therefore allowed — the row it starts from has no attestation — and every later change is not: an attested row is invisible to `UPDATE`, whatever the application asks. `DELETE` is the same. This is VERSION-01 as an enforcement rather than a convention, and it is the same shape as the append-only audit log. + +A CHECK keeps an attestation whole: a time with nobody behind it is not an attestation, and a name with no time is not one either. + +**A validity period is not modelled, and that is a decision rather than an omission.** Evidence goes stale — a review done last year is not evidence that a control is operating now — but staleness is a relationship between a control's expectations and an evidence date, not a property of the evidence. Putting `valid_until` on the evidence would have each record assert its own expiry, so two pieces of evidence for the same control could disagree about how often it needs doing. The cadence belongs to the control. It is not there yet because the question that would use it — _what is overdue?_ — does not exist either, and inventing a cadence format before anything reads one is how you get the wrong one. + +## Consequences + +**`SELECT … FOR UPDATE` is governed by the UPDATE policy, not only the SELECT one.** This is worth writing down because it was a surprise: locking a row requires being able to update it, so an attested row cannot be locked, and a handler that read it with `FOR UPDATE` first found nothing and answered 404 where 409 was meant. + +The amendment and deletion handlers read unlocked to decide between 404 and 409, and only then lock. A lock that finds nothing does _not_ simply mean it was attested in between: the row may have been discarded while this transaction waited, and the two are indistinguishable from the lock alone. Answering "already attested" for a record that was thrown away and never signed is a confident wrong answer, so both handlers read again without the lock to tell them apart ([ADR 0020](0020-testing-races.md)). Attesting does not take an explicit read lock: it reads unlocked and constrains its `UPDATE` by the version instead. The update itself locks the row and rechecks the version after a concurrent write. Amending also needs the lock to compute its diff from the current row. An unlocked read can become stale, producing an incorrect audit preimage or making a real change look like a no-op. The lock keeps the comparison and update consistent. + +**An attested record can still be removed by a cascade, and only one such path is left.** Row security does not govern a foreign key's referential action — the same gap recorded for mappings in [ADR 0010](0010-mapping-controls-to-requirements.md). Two routes into it have since been closed: `evidence`'s foreign key to `control` restricts rather than cascades, so removing a control no longer takes its evidence ([ADR 0014](0014-the-runtime-role-owns-nothing.md)), and Better Auth's `POST /api/auth/organization/delete` is disabled. What remains is deleting the `organization` row itself, which cascades through everything and is an operator's act with a credential the server does not hold. + +That is the right behaviour for a tenant leaving, and it is worth saying plainly rather than implying finality is absolute: **evidence is final against the application, not against the removal of the organization it belongs to.** A deployment that must keep attested records beyond the life of a tenant needs them exported or held elsewhere, and nothing here does that. + +There is no way to withdraw an attestation. Unattested evidence can be discarded — the `DELETE` policy has always admitted it, and [ADR 0017](0017-discarding-a-draft-control.md) gave it the route it lacked — but attesting something in error is corrected by recording new evidence, not by removing the old. That is the point of the rule, and it is also untested ground: nothing yet marks one piece of evidence as superseding another. + +Evidence belongs to a control, while evidence for a technical file is gathered per requirement. Without a requirement-level query, a client must read every mapped control's evidence separately, with each request seeing a different snapshot. + +`GET /requirements/{id}/evidence` provides this view — the evidence of the controls _currently_ mapped to the requirement, newest occurred first, drafts and attested alike, from controls in any state, read in one snapshot per page. It is a view, not a record: unmapping a control takes its evidence out of the list and leaves the evidence as it was, and evidence attested before a mapping existed is listed all the same, because the attestation endorses the evidence rather than the mapping. It is not coverage. Evidence under a requirement says a mapped control was operated, not that the requirement is met, and a draft says less than that. + +A page is a snapshot; a walk across pages is not, like every collection here ([ADR 0011](0011-reading-a-mapping-from-both-ends.md)), so this is not yet the export a technical file needs. In either evidence collection, amending a draft's `occurredAt` can move it across an existing cursor, causing the walk to skip it or return it again. `limit` bounds the rows, not the work: the order spans several controls, and no index supplies it directly, so a requirement with many heavily evidenced controls may have all of their evidence read and sorted to answer one page. Worth measuring before assuming it is cheap. + +An attestation endorses the evidence row, not the state of anything around it. Retiring the control afterwards, or unmapping the requirement it answered to, leaves the attestation exactly as it was and says nothing about it — which is right, because the attester vouched for what the evidence says, not for the shape of the system a year later. Reading an old attestation as a claim about present coverage is a mistake, and nothing yet stops a reader making it. + +Nothing checks that the attester is a different person from the recorder, or that they hold any particular role. The domain API does not yet enforce segregation of duties or restrict actions by `member.role`; Better Auth applies its own authorization to organization administration. diff --git a/docs/adr/0019-conditional-writes.md b/docs/adr/0019-conditional-writes.md index 1e1d251..378789d 100644 --- a/docs/adr/0019-conditional-writes.md +++ b/docs/adr/0019-conditional-writes.md @@ -43,7 +43,7 @@ The field is parsed rather than split. An entity tag is opaque and quoted, so a **Preconditions are evaluated last.** A refusal that would have happened anyway — an illegal transition, a control that has been in effect, one carrying evidence, attested evidence — is answered before the tag is looked at. RFC 9110 §13.2.1 asks for that, and there is a second reason: checking the tag first makes a request that was going to be refused disclose whether the caller's tag matched. -**ETags are served where a client would get one**: reading a control, reading evidence, and the `PATCH` response of each, so a client can make a second edit without reading again. The published document declares the header on each of those responses — one that asks for `If-Match` and never says where the tag comes from describes half a contract. +**ETags are served on individual control and evidence reads and their `PATCH` responses**, so a client can make a second edit without reading again. Evidence creation also returns its first tag, allowing the returned record to be attested without another read. The published document declares the header on each of those responses — one that asks for `If-Match` and never says where the tag comes from describes half a contract. ## Consequences @@ -55,8 +55,8 @@ That tag says nothing about _when_. Two equal sets are indistinguishable, which **The listing reads its page and its version from one snapshot.** Under `read committed` those are two statements and can see two different committed sets, which would hand a client a version for membership it was never shown. `withOrganization` takes a `repeatableRead` option for reads whose answers have to agree with each other. Reading one piece of evidence and listing a control's evidence use it too, for the same reason: a row and its separately queried attachments are two statements, and the tag an attestation quotes has to describe the files shown beside it. No write uses it — a write deciding from what is stored _now_ wants the opposite. -For row tags, the version is selected alongside the columns, so one query serves both the body and the tag; `withoutVersion` strips it before the response. Tests check representative responses against the strict published schemas ([ADR 0007](0007-openapi-from-the-schemas.md)); handlers do not validate outgoing responses at runtime. +For row tags, the version is selected alongside the record's columns and omitted from the JSON response body. Tests check representative responses against the strict published schemas ([ADR 0007](0007-openapi-from-the-schemas.md)); handlers do not validate outgoing responses at runtime. -Nothing obliges a client to use this, so nothing guarantees a careless one is safe. That is the cost of optional, taken knowingly: the product now offers the guarantee rather than enforcing it, and a client that wants to be careful can be. +Outside attestation, clients must opt into conditional writes; omitting `If-Match` leaves them unprotected against concurrent changes. That is the cost of optional, taken knowingly: the product offers the guarantee rather than enforcing it. `xmin` is a 32-bit counter and wraps. Two rows can therefore present the same tag, which does not matter — a tag is only ever compared against the row it was read from. What would matter is a row's `xmin` returning to a value a client still holds, which needs the counter to wrap between the read and the write; a stale tag matching wrongly is then possible in theory and not worth engineering against here. diff --git a/docs/data-model.md b/docs/data-model.md index e2e5aa8..a059c5c 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -63,7 +63,7 @@ A standard is something an organization works to: a published one such as ISO 90 | `text` | The requirement as stated, where the deployment may store it | | `position` | Where it falls in the standard's own order | -`reference` is unique within a standard and means nothing outside it. It is how people cite a clause — in a commit message, a pull request, a checklist — so `?reference=` on a standard's requirements resolves one to its requirement by exact match ([ADR 0011](adr/0011-reading-a-mapping-from-both-ends.md)). `position` is **not** unique, so a clause can use an occupied position without renumbering later clauses. Requirements are ordered by `(position, id)`; ties are broken by identifier. `text` is **nullable on purpose**: the wording of a published standard is usually copyrighted, and a licence to read one is not a licence to store it — a requirement tracked by reference and title alone can still be mapped to controls. `position` exists because clause references do not sort: `7.10` precedes `7.9` lexically. +`reference` is unique within a standard and means nothing outside it. It is how people cite a clause — in a commit message, a pull request, a checklist — so `?reference=` on a standard's requirements resolves one to its requirement by exact match ([ADR 0011](adr/0011-reading-a-mapping-from-both-ends.md)). `position` is **not** unique, so a clause can use an occupied position without renumbering later clauses. Requirements are ordered by `(position, id)`; ties are broken by identifier. `text` is **nullable on purpose**: the wording of a published standard is usually copyrighted, and a licence to read one is not a licence to store it — a requirement tracked by reference and title alone can still be mapped to controls and read with their evidence. `position` exists because clause references do not sort: `7.10` precedes `7.9` lexically. A requirement carries its own `organization_id` and references its standard by `(standard_id, organization_id)` together, so a requirement in one organization pointing at a standard in another cannot be written at all (TENANT-01). Every tenant-owned child should be related to its parent the same way; a reference to something instance-wide, such as `user`, takes an ordinary foreign key instead. @@ -89,7 +89,7 @@ The schema admits exactly these three values and no more. The API answers for th The `DELETE` policy admits only a `draft`, and PostgreSQL is what makes "draft" mean "never took effect" rather than the API. `activated_at` records when a control first became active; a trigger sets it and refuses any other write to it, and a CHECK allows a draft exactly when it is null. So nothing that was in effect can be a draft again — not through the API, and not through raw SQL, including an `UPDATE` and a `DELETE` in one transaction. The trigger has to be a trigger: a policy cannot compare a row to what it used to be, and anything able to clear the stamp could turn a control that was in effect back into a deletable draft. -A control still carrying evidence is refused too, because `evidence` restricts rather than cascades: attested evidence has to go on naming what it was evidence of. Discarding a control takes its requirement mappings with it, by cascade, and a cascade is not audited. +A control still carrying evidence is refused too, because `evidence` restricts rather than cascades. Discard the evidence first. Unattested evidence can be discarded; attested evidence cannot, and then the draft stays — a draft cannot be retired, and attested evidence goes on naming what it was evidence of. Discarding a control takes its requirement mappings with it, by cascade, and a cascade is not audited. The deletion itself is audited, and that history outlives the row: `resource_id` is a plain column, not a reference. It stays readable afterwards at `GET /history?resource={controlId}` — a history reachable only through a live record would disappear exactly when it is most wanted ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). @@ -105,6 +105,16 @@ A control row is mutable: editing one overwrites it. What it was is recorded in The set is replaced whole rather than added to one link at a time, and a mapping change is recorded against the control, not the link. Deleting a control, a requirement, or a requirement's standard removes the links to it — silently, because a cascade is not a change the application made. +### Evidence + +A record that a control was actually operated: a review performed, a restore tested, a training completed. It belongs to a control, carries `occurred_at` — when the thing happened, not when the row was written — and is listed in that order. It may not be in the future, beyond a five-minute allowance for a client's clock; a date before the control existed is fine, because recording earlier work is ordinary. It is also read from a requirement: the evidence of the controls currently mapped to it, which is a view through the mapping rather than a claim that the requirement is met. [ADR 0012](adr/0012-evidence-and-attestation.md) records the design. + +**A policy is a test, not a lock.** The rules below are enforced by row-level security, which decides what a row may be — it cannot decide when two transactions may act. Under `read committed` a predicate reads a snapshot taken before a concurrent transaction committed, so a handler that reads state and then writes on the strength of it also has to hold a row lock the other writer contends for. Where that is load-bearing, [ADR 0020](adr/0020-testing-races.md) names the place and the lock. + +**Evidence is the first finalised record here.** Until someone attests it, it is an ordinary draft. Attesting records who vouched and when, and after that PostgreSQL will not let the application change or delete the row at all: the `UPDATE` and `DELETE` policies see only unattested rows (VERSION-01). Correcting attested evidence means recording new evidence, not editing the old. Attesting is refused while impersonating — it is a signature, not work that can be done on someone's behalf — and is the one mutation that _requires_ `If-Match`, so that what was signed is what was read. Deleting the organization still removes everything in it, cascades being outside row security — evidence is final against the application, not against a tenant being removed. + +A validity period is deliberately absent. Evidence does go stale, but staleness is a relationship between a control's expectations and an evidence date — the cadence belongs to the control, and nothing reads one yet. + ### Audit event Domain API mutations record changes in the same transaction as the change itself ([ADR 0005](adr/0005-audit-history.md)). A change PostgreSQL makes on its own — a foreign key's cascade removing rows — writes nothing, which is a known gap rather than a decision. It names the actor, the action, the record, and the fields that moved. @@ -113,7 +123,7 @@ Domain API mutations record changes in the same transaction as the change itself | ------------------------------ | -------------------------------------------------------------------------------- | | `actor_type`, `actor_id` | Who acted: `user` or `system`, and their identifier | | `actor_label` | How the actor was named at the time | -| `action` | A verb — `created`, `updated`, `deleted` | +| `action` | A verb — `created`, `updated`, `attested`, `deleted` | | `resource_type`, `resource_id` | Which record it happened to | | `before`, `after` | The fields that changed; `before` is null for a creation, `after` for a deletion | | `created_at` | When the change happened, not when the row was written | @@ -122,7 +132,7 @@ An administrator impersonating a member is the actor, because they are accountab Neither `actor_id` nor `resource_id` is a foreign key. Both the actor and the record can be deleted, and history that vanishes with them is not history (AUDIT-01) — `actor_label` exists for the same reason, since an identifier alone means nothing to a reader once the row is gone. -`before` and `after` carry a record's own fields — for a field edit, only those that differ. Payloads can also describe related records: a standard import records its requirement count, and a mapping update records the full requirement-id sets before and after the change. Record identity is already a column, and bookkeeping timestamps (`created_at`, `updated_at`) describe the write rather than the change. A domain timestamp — when something happened, rather than when it was written — is a field like any other. An update that changes nothing writes no event at all. +`before` and `after` carry a record's own fields — for a field edit, only those that differ. Payloads can also describe related records: a standard import records its requirement count, and a mapping update records the full requirement-id sets before and after the change. Record identity is already a column, and bookkeeping timestamps (`created_at`, `updated_at`) describe the write rather than the change. Domain timestamps such as `occurred_at` and `attested_at` remain part of the audit payload. An update that changes nothing writes no event at all. History is readable at `GET /api/v1/organizations/{organizationId}/history`, newest first and paged like every other collection ([ADR 0006](adr/0006-cursor-paged-collections.md)). `?resource={id}` narrows it to one record, named by its identifier alone since the identifier says what kind it is. There is no per-record route and no 404: history outlives what it describes, so there is nothing to look a resource up in, and what a caller may see is decided by the policies ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). @@ -130,9 +140,9 @@ History is readable at `GET /api/v1/organizations/{organizationId}/history`, new ### Changing only what you read -Amending or discarding a control accepts `If-Match`, and answers `412` when the version it names has moved ([ADR 0019](adr/0019-conditional-writes.md)). A record's version is PostgreSQL's `xmin` — the transaction that last wrote the row — served as an `ETag` on reading a control and on a successful amendment, so a client can make a second edit without reading again. +Amending or deleting a control or evidence record accepts `If-Match`, and answers `412` when the version it names has moved ([ADR 0019](adr/0019-conditional-writes.md)). A record's version is PostgreSQL's `xmin` — the transaction that last wrote the row — served as an `ETag` on individual control and evidence reads and successful amendments, so a client can make a second edit without reading again. Recording evidence answers with its first tag too, so what was just recorded can be attested without reading it back. -The header is optional: omitting it leaves writes last-writer-wins. +The header is optional for those operations: omitting it leaves writes last-writer-wins. Attesting evidence requires the exact `ETag` from the evidence read. `PUT /controls/{controlId}/requirements` replaces a set of rows rather than amending a record, so there is no single row version to quote. Its version is the set's contents instead, served as an `ETag` when the requirements are listed — the same on every page of them — and honoured on the replacement. A control therefore carries two versions, its own and its mappings', and they are not interchangeable. A client that reads the set page by page and writes it back needs the same tag on every page, and reads again if one differs: a mapping added behind its cursor changes the tag on later pages without appearing in them. diff --git a/docs/development.md b/docs/development.md index ea64580..fbdd006 100644 --- a/docs/development.md +++ b/docs/development.md @@ -111,7 +111,7 @@ bun run dev # http://localhost:3000, restarting on change It runs from the repository root so Bun loads the root `.env`, and it refuses to start when `DATABASE_URL`, `BETTER_AUTH_URL`, or `BETTER_AUTH_SECRET` is missing rather than failing on the first request that needs one. -`apps/server` mounts [Better Auth](https://better-auth.com) at `/api/auth/*`, and this product's own API at `/api/v1`. Tenant-owned resources — controls, standards, requirements, and the history of what happened to them — sit under `/api/v1/organizations/:organizationId` behind `organizationContext`, which resolves the caller's membership and binds `withOrganization` to that organization ([ADR 0004](adr/0004-organization-in-the-request-path.md)); a route mounted outside that prefix has no `withOrganization` on its context and fails rather than serving unscoped rows. `apps/server/organization.test.ts` and `controls.test.ts` drive the stack over HTTP as a non-superuser role, so the policies apply there too; request bodies and query strings are validated with [Zod](https://zod.dev) through `validation.ts`, which owns what a rejection looks like, and collections are paged by cursor through `pagination.ts`, each naming the ordering it is read in ([ADR 0006](adr/0006-cursor-paged-collections.md), [ADR 0009](adr/0009-importing-a-standard.md)). `responses.ts` defines shared response envelopes and builds errors; resource modules define their response schemas, and handlers build successful responses. `openapi.ts` combines those schemas with operation metadata into the document served at `/api/v1/openapi.json` ([ADR 0007](adr/0007-openapi-from-the-schemas.md)). Adding a route means adding its operation there too — `openapi.test.ts` derives what the app serves and fails until the two agree. A mutating handler also records what changed through `c.var.audit`, on the same transaction as the change ([ADR 0005](adr/0005-audit-history.md)); `audit.test.ts` covers that, including that the history cannot be rewritten. `authOptions` in `apps/server/auth.ts` is the schema contract — it decides which tables exist, and `auth.test.ts` derives its expectations from that same object. Better Auth refuses to start when the Drizzle schema object disagrees with it; that check reads the schema in code, not the live database, so applying migrations is still on you. +`apps/server` mounts [Better Auth](https://better-auth.com) at `/api/auth/*`, and this product's own API at `/api/v1`. Tenant-owned resources — controls, standards, requirements, evidence, and the history of what happened to them — sit under `/api/v1/organizations/:organizationId` behind `organizationContext`, which resolves the caller's membership and binds `withOrganization` to that organization ([ADR 0004](adr/0004-organization-in-the-request-path.md)); a route mounted outside that prefix has no `withOrganization` on its context and fails rather than serving unscoped rows. `apps/server/organization.test.ts` and `controls.test.ts` drive the stack over HTTP as a non-superuser role, so the policies apply there too; request bodies and query strings are validated with [Zod](https://zod.dev) through `validation.ts`, which owns what a rejection looks like, and collections are paged by cursor through `pagination.ts`, each naming the ordering it is read in ([ADR 0006](adr/0006-cursor-paged-collections.md), [ADR 0009](adr/0009-importing-a-standard.md)). `responses.ts` defines shared response envelopes and builds errors; resource modules define their response schemas, and handlers build successful responses. `openapi.ts` combines those schemas with operation metadata into the document served at `/api/v1/openapi.json` ([ADR 0007](adr/0007-openapi-from-the-schemas.md)). Adding a route means adding its operation there too — `openapi.test.ts` derives what the app serves and fails until the two agree. A mutating handler also records what changed through `c.var.audit`, on the same transaction as the change ([ADR 0005](adr/0005-audit-history.md)); `audit.test.ts` covers that, including that the history cannot be rewritten. `authOptions` in `apps/server/auth.ts` is the schema contract — it decides which tables exist, and `auth.test.ts` derives its expectations from that same object. Better Auth refuses to start when the Drizzle schema object disagrees with it; that check reads the schema in code, not the live database, so applying migrations is still on you. ## Testing races diff --git a/docs/product.md b/docs/product.md index 1ce1d81..eecd4ea 100644 --- a/docs/product.md +++ b/docs/product.md @@ -64,6 +64,8 @@ What AI may not do is become an implicit source of truth. Anything it produces i Nothing yet _requires_ a person for that transition — the lifecycle makes it deliberate and the audit record makes it attributable, but no rule says an agent may not put a control into effect. Whether some acts should require a human is a real question, and `member.role` exists but does not gate domain API actions today. +Attestation is the sharp edge of this: it is a signature, and signatures are not delegated. An administrator impersonating a member cannot make one — the product refuses it outright rather than recording it carefully. + What it cannot currently tell is a program holding a person's credentials from that person. There are no machine credentials distinct from a human session, so anything with the cookie is that human as far as the system knows. For a record whose whole value is that somebody vouched, that is a gap worth naming. ## The open-source boundary @@ -74,7 +76,9 @@ A managed service is planned, and what belongs to it is the operation rather tha ## Status -The schema for the whole loop is in place, and PostgreSQL enforces its tenancy and finality: standards, requirements, controls, mappings, evidence, attestation and files. The API serves the first part of it: standards can be imported, and controls created, changed, moved through their lifecycle, mapped to the requirements they answer and — while they never took effect — discarded, and every change is audited and readable as history. Evidence and files are not yet reachable through the API. +The loop exists end to end: standards can be imported, controls recorded and mapped to the requirements they answer, evidence recorded and attested, and domain API mutations are audited. Files cannot yet be attached to evidence. Better Auth operations and cascades PostgreSQL performs do not write domain audit events; `docs/data-model.md` describes the audit model. + +A draft control with no evidence can be discarded, and so can unattested evidence. A control that took effect is retired rather than removed, and attested evidence stays. That history is readable on its own, and outlives the records it describes. There is no user interface, no deployment artifact, and none of the entities beyond that loop. diff --git a/docs/security.md b/docs/security.md index aebb56b..b9bbd95 100644 --- a/docs/security.md +++ b/docs/security.md @@ -32,4 +32,4 @@ Better Auth's tables are outside this: it resolves a user's memberships before a **Any member can read all of it.** `GET /history` serves the organization's whole audit history — actor labels, impersonation attribution, and the `before`/`after` of every change, including records since deleted ([ADR 0018](adr/0018-one-history-rather-than-one-per-record.md)). Membership is the authorization boundary for the domain API; its handlers do not gate actions on `member.role`. Better Auth applies its own authorization to organization administration. That is a deliberate widening and the first place a reader-level role would be needed. Row security does not govern `TRUNCATE` or a table owner's privileges, so protecting the history from the runtime role itself is a matter of grants — see [deployment](deployment.md). -Three other tables name their commands rather than covering them all at once, and in each case the `DELETE` policy — or its absence — is where the rule lives. `evidence` admits only unattested rows, so what was signed cannot be removed; `file` has no `DELETE` policy at all, and a trigger refuses an attachment to attested evidence. `control` admits only a draft, and a draft is held to be one that never took effect: a trigger owns `activated_at` and a CHECK ties a draft to its being null, so nothing that was in effect can become a draft again ([ADR 0017](adr/0017-discarding-a-draft-control.md)). Where a route reaches one of these, it answers with something a caller can act on; the policy is what makes the rule true. +Three other tables name their commands rather than covering them all at once, and in each case the `DELETE` policy — or its absence — is where the rule lives. `evidence` admits only unattested rows, so what was signed cannot be removed ([ADR 0012](adr/0012-evidence-and-attestation.md)); `file` has no `DELETE` policy at all, and a trigger refuses an attachment to attested evidence. `control` admits only a draft, and a draft is held to be one that never took effect: a trigger owns `activated_at` and a CHECK ties a draft to its being null, so nothing that was in effect can become a draft again ([ADR 0017](adr/0017-discarding-a-draft-control.md)). Where a route reaches one of these, it answers with something a caller can act on; the policy is what makes the rule true.