Skip to content

feat(evidence): kates evidence export writes a period's record for an auditor - #565

Merged
bmscomp merged 2 commits into
mainfrom
feat/evidence-export
Oct 31, 2026
Merged

bmscomp merged 2 commits into
mainfrom
feat/evidence-export

Conversation

@bmscomp

@bmscomp bmscomp commented Oct 30, 2026 •

Copy link
Copy Markdown
Owner

Why

An auditor, as under the EU's DORA, asks what was tested in a period, when, by whom and how it went, and whether the record was changed since. #560, #561 and #562 made Kates keep that record: stored compliance checks, a hash-chained audit log, and report hashes in the chain. This PR hands it over, as Phase 3c of the enterprise plan, built in the CLI from endpoints that already exist.

Building it turned up a silent data-loss bug: GET /api/audit read at most its newest 500 rows and paged them in memory, so an export of a busy period would have missed rows without a word.

What

kates evidence export --from <time> [--to <time>] --out <dir> [--sign] writes a new directory (an existing one is refused, so a pack never mixes with older files):

File Holds
export.json The period, when the pack was made, the Kates API's URL
runs.json Test runs created in the period
disruptions.json The full report of each disruption in the period
compliance-checks.json Each stored check with its report and reportSha256
audit.json The period's audit rows, each with its rowHash
audit-verify.json GET /api/audit/verify: whether the chain holds, and its head
SHA256SUMS Each file's SHA-256: shasum -a 256 -c SHA256SUMS shows none changed
  • As written. Items are kept as the API wrote them, as raw JSON, so no field the CLI doesn't model is dropped.
  • Signing. --sign runs cosign sign-blob keyless on SHA256SUMS, in the foreground so the sign-in URL shows, and writes SHA256SUMS.sigstore.json.
  • Exit code. It exits 1 when the audit chain is broken, with the pack written all the same.
  • Period. The period is [from, to); --to defaults to now.
  • Runs and disruptions have no period filter. The CLI pages them newest first, keeps the period, stops at the first older item, and keeps each id once if a new row shifts the pages.
  • Older Kates APIs. A 404 on the new endpoints says to upgrade. An audit list with rows outside the period, which is what an older Kates API answers, is refused rather than exported.

GET /api/audit now:

  • pages in the database, ordered by createdAt then id;
  • answers the real total;
  • takes until (exclusive; 400 when it isn't an instant).

since keeps its old lenient behaviour.

MCP: the caveat audit-actor-recorded and kates_activity's total no longer say "at most 500". The anchored ref to the cap goes with it.

Book: the CLI reference gains "Evidence for Auditors".

Deliberately not done

  • No server-side export endpoint or zip. The CLI assembles the pack from existing endpoints.
  • No DORA, SOC 2 or ISO control mapping. It needs a compliance person's review, so it waits for a customer who asks.
  • No PDF or Markdown summary. The JSON is the evidence.

Risks

A run deleted during the export, as by the daily retention sweep, shifts the newest-first pages and can hide another run. The code marks it as a shortcut:; a period filter on /api/tests would remove it.

Tests

Locally:

  • ./mvnw verify: 1788 unit tests and 98 ITs on real Postgres and Kafka, 0 failures, coverage gate passed. New:
    • AuditVerifyTest: 520 rows read across 3 pages, past the old cap, and until excludes.
    • AuditResourceTest: until passed on; a bad until is a 400.
  • go test ./..., including 9 new TestEvidence*. Two came from review (7f611ff), and each fails on the code before it: all 401 audit rows are read across pages with no total in the answer, and a failed export leaves no directory to block the retry. The others:
    • the period cut, and fields the CLI doesn't model are kept;
    • SHA256SUMS matches every file;
    • 450 runs across pages with a shifted page come back once each;
    • a broken chain exits 1 with the pack written;
    • an old API's out-of-period audit rows are refused;
    • a 404 names the upgrade;
    • an existing directory is refused;
    • period parsing.
  • go vet, golangci-lint --new-from-rev=origin/main (0 issues), check-cli-style.sh, check-cli-compat.sh, check-book-style.sh, spotless:check, TestMCPCaveatRefAnchors.

Not run: --sign against real Sigstore, which needs a browser sign-in; and an export against a live Kates.

… auditor

An auditor, as under the EU's DORA, asks what was tested in a period, when,
by whom and how it went. This is Phase 3c of plans/enterprise.md, built in
the CLI from endpoints that already exist, plus one server fix.

- kates evidence export --from --to --out [--sign] writes a new directory:
  export.json (period, time, Kates URL), runs.json, disruptions.json (each
  full report), compliance-checks.json (each with its report), audit.json,
  audit-verify.json, and SHA256SUMS, so "shasum -a 256 -c SHA256SUMS"
  shows no file changed. Items are kept as the API wrote them. --sign runs
  cosign sign-blob keyless on SHA256SUMS. It exits 1 on a broken audit
  chain, with the pack written all the same.
- Runs and disruptions have no period filter: the CLI pages them newest
  first, keeps [from, to), stops at the first older one, and keeps each id
  once if a new row shifts the pages.
- GET /api/audit read at most its newest 500 rows and paged them in
  memory, so a busy period's export would have missed rows without a word.
  It now pages in the database, ordered by createdAt then id, answers the
  real total, and takes an exclusive until (400 when not an instant). The
  CLI refuses an audit list with rows outside the period, which is what an
  older Kates API answers.
- The MCP caveat audit-actor-recorded and kates_activity's total no
  longer say "at most 500"; its anchored ref goes with the cap.
- The book's CLI reference gains "Evidence for Auditors".

Verified locally: mvnw verify (1788 unit, 98 IT on Postgres and Kafka),
spotless:check, go test ./... (TestMCPCaveatRefAnchors included), go vet,
golangci-lint --new-from-rev=origin/main, check-cli-style, check-cli-compat,
check-book-style.

@graphify-labs graphify-labs Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Graphify reviewed this change.

Worth a look — the grounded gate found no coupling regressions or blocking issues, but 2 advisory finding(s) below merit a look before merge.

Not checked: tests were not run; no formal proof of the changed code.


Graphify review — findings

Adds kates evidence export, which writes a new directory with the runs, full disruption reports, stored compliance checks, audit rows and audit-chain verification for a [--from, --to) period, plus a SHA256SUMS manifest and optional keyless cosign signing via --sign. It creates the output directory up front and refuses one that already exists, keeps every item as the raw bytes the API returned through the new Client.GetRaw, pages with newestFirstIn and allPages, and rejects audit rows outside the period. When the audit chain is broken it still writes the pack and exits 1, and evidenceHint maps a 404 to an upgrade message for older APIs. It also registers evidence in commandProfiles with no profile and corrects the audit page comment so Total counts every matching row.

Worth a look

  • allPages stops after the first page when the response has no total — cli/cmd/evidence.go:222 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review
  • A failed export leaves the output directory behind, so the retry is refused — cli/cmd/evidence.go:64 · Escalate · medium
    • NOT verified (no proof, no reproducing execution) — needs human review

Review partial — this diff was larger than one review pass covers, so later files were not reviewed; some findings may be missing.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 731 functions depend on the 380 functions this change touches.

Health — this change adds coupling hotspots:

  • new: collectEvidence() — 1 callers, 6 callees
  • new: evidenceExportCmd — 0 callers, 11 callees
  • new: TestEvidenceExportPagesPastOnePageAndKeepsEachRunOnce() — 0 callers, 7 callees

Verification — 731 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 477 function(s) in the blast radius were not formally verified this run

Docs that may be stale (advisory)

…and 10 more.

· 3 grounded finding(s) anchored inline below.

Comment thread cli/cmd/evidence.go Resolved
Comment thread cli/cmd/evidence.go Resolved
Comment thread cli/cmd/evidence_test.go Resolved
Review on #565:
- allPages stopped once it had "total" items, so an answer without a
  total stopped after its first page: an export that drops rows without a
  word, the bug the PR fixes in /api/audit. It now reads to the first
  short page, and listPage no longer reads a total.
- The directory was made before anything was read, so a failed export
  left it behind and the retry was refused. It is now checked first and
  made once everything is read, and a failed write removes it.

Each has a test that fails on the code before this.
@bmscomp
bmscomp merged commit 1cee282 into main Oct 31, 2026
61 checks passed
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.

1 participant