Skip to content

feat: record evidence and attest it - #8

Merged
quality-runtime[bot] merged 1 commit into
mainfrom
feat/evidence-and-files
Sep 19, 2026
Merged

quality-runtime[bot] merged 1 commit into
mainfrom
feat/evidence-and-files

Conversation

@quality-runtime

@quality-runtime quality-runtime Bot commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Adds the evidence half of requirement → control → evidence: a record that a control was actually operated, and a person's signature on it. Files and durable storage follow in the next PR; this one is deliberately without attachments.

Note

This replaces the earlier combined branch. The three guideline commits merged as #7, and the file/storage work is split out, so this PR is now evidence and attestation only.

The routes

Method Path What it does
POST /controls/{id}/evidence Records it. Answers 201 with an ETag, so it can be attested without a second read
GET /controls/{id}/evidence That control's evidence, most recently occurred first
GET /requirements/{id}/evidence The evidence of the controls currently mapped to that requirement
GET /evidence/{id} One record, with its ETag
PATCH /evidence/{id} Amends a draft. Optional If-Match
DELETE /evidence/{id} Discards a draft
PUT /evidence/{id}/attestation Signs it. If-Match required

occurredAt is when the thing happened, not when the row was written, and may not be more than five minutes ahead — an allowance for a client clock running fast.

Attesting is a signature, not an edit

POST /api/v1/organizations/{org}/controls/{id}/evidence   → 201, ETag: "812"
PUT  /api/v1/organizations/{org}/evidence/{id}/attestation
If-Match: "812"                                           → 200 attested
  • Exactly one strong tag. Unlike every other conditional write here, *, lists and weak tags are refused: a signature is of one version in particular.
  • Never delegated. An administrator impersonating a member is refused with 403, rather than the act being recorded carefully.
  • Once. A second attempt is 409.
  • Final. Afterwards the UPDATE and DELETE policies admit only unattested rows, so PostgreSQL — not the handler — refuses every later change.

Races, and why the code looks the way it does

SELECT … FOR UPDATE is governed by the UPDATE policy, so an attested row cannot be locked, and "cannot be locked" would otherwise surface as "does not exist". Amend and discard therefore read unlocked to tell 404 from 409, then lock, and read again when the lock finds nothing. Attestation locks nothing at all: it puts the version in its UPDATE … WHERE, and when that matches no row it rereads to answer 404, 409 or 412 — not 412 for all three.

What the real-PostgreSQL suite now forces (apps/server/concurrency.test.ts)
  • evidence amended while a conditional discard, amendment or attestation waits;
  • evidence attested — or discarded — while an attestation waits;
  • evidence attested while an amendment or discard waits, and evidence discarded while one waits — 409 and 404, never the one mistaken for the other;
  • recording evidence against a control being discarded, in both orders, answering 404/409 rather than a foreign-key 500;
  • two amendments in turn, each audit event naming what it really replaced.

Details worth a reviewer's eye

  • Nothing to write means nothing written. An amendment that sets the values a record already has writes no row and no event, so the version a signer is about to quote doesn't move.
  • The database's clock stamps attestedAt (clock_timestamp()), the same clock audit history uses.
  • History outlives the record. Evidence events carry controlId, so discarded evidence can still say which control it belonged to, and /history?resource=evd_… reads it.
  • Requirement evidence is a view, not a record. Unmapping a control drops its evidence from that list and changes nothing else. It says a mapped control was operated, never that the requirement is met.

Decisions

ADR 0012 records the design: attestation as the first finalised record enforced by policy rather than convention, why If-Match is required only here, and why a validity period is not modelled. It builds on ADR 0019 (versions and If-Match) and ADR 0020 (how these races are forced). The schema, policies and triggers landed earlier in #3; this adds the API and the docs.

Verification

On the committed tree, checked out clean:

bun run check                                     # format, lint, types
bun run test                                      # 527 passing, TEST_DATABASE_URL set
uvx --from 'reuse[charset-normalizer]' reuse lint

Each new guarantee was checked by removing it and watching its test fail: the attestation reread, the no-op amendment, the exact-tag rule, the empty If-Match, the five-minute allowance, and the ETag on recording.

Not here

  • Files, storage and verify:files — the next PR, which adds a files field to these responses. Links from ADR 0020 to ADR 0013 stay broken until then.
  • Withdrawing an attestation, a validity period, and recorder ≠ attester — deliberate absences, written down in ADR 0012.
  • Top-level cursors are not yet bound to the organization — known, and tracked from feat: import standards and map controls to requirements #5.

@koistya
koistya added this pull request to stack #9 September 19, 2026 18:04
Base automatically changed from docs/guidelines to main September 19, 2026 18:05
@koistya
koistya force-pushed the feat/evidence-and-files branch from 4385f11 to 48b1bfe Compare September 19, 2026 18:06
@quality-runtime
quality-runtime Bot force-pushed the feat/evidence-and-files branch from 48b1bfe to 7f0e780 Compare September 19, 2026 22:24
@quality-runtime quality-runtime Bot changed the title feat: record evidence, attest it, and attach files feat: record evidence and attest it Sep 19, 2026
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.
@quality-runtime
quality-runtime Bot force-pushed the feat/evidence-and-files branch from 7f0e780 to fc8dc66 Compare September 19, 2026 23:27
@quality-runtime
quality-runtime Bot merged commit 7b67a0d into main Sep 19, 2026
6 checks passed
@quality-runtime
quality-runtime Bot deleted the feat/evidence-and-files branch September 19, 2026 23:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants