Skip to content

Plan 4b: the platform audit log (reads, chain verification, AuditorWrite) - #88

Merged
ojies merged 18 commits into
mainfrom
agent/plan-4b
Nov 3, 2026
Merged

ojies merged 18 commits into
mainfrom
agent/plan-4b

Conversation

@ojies

@ojies ojies commented Nov 3, 2026

Copy link
Copy Markdown
Collaborator

What 4b adds

The audit log for platform admins and auditors, on the platform audience as /platform/audit (Plan 4, part 4b; frontend contract §14.9).

  • Reader: GET /platform/audit (filters, exact-id q, session-bound HMAC cursor), /{id}, /hospitals, /people, POST /platform/audit/undisplayable. A catalogue maps chain rows to mhai-web's types and kinds; a row it can't map is sent as the bare entry, never as stored.
  • Reads are recorded: every page, detail, people, hospitals and chain-check read is an audit.viewed or audit.searched entry, deduped per session for 5 minutes in the same transaction.
  • Chain verification: POST /platform/audit/verify, GET .../verify/runs, /runs/{id}, /status, and an auditor's POST .../runs/{id}/outcome. A check walks the whole chain in keyset pages inside one read-only snapshot, with a 10-minute budget; it is intact only when the whole chain was walked. A daily check runs at 06:00 UTC and sets the audit_chain_broken gauge.
  • AuditorWrite: a route rule that lets auditors make exactly the audit writes the contract gives them, and nothing else.
  • Security contact: SECURITY_CONTACT_NAME and SECURITY_CONTACT_EMAIL are required outside development. deploy/setup-host.sh asks for them, checks them as core does (a Go test keeps the two in step) and refuses one without the other.
  • Smoke: TestSmokeAuditLog (a synthetic auditor over api.v1.platform, two-step enrolled: start a check, poll it to intact, read the status, find clinician B's review_submitted under type=review). The smoke stack gets a bff_platform password and a synthetic contact.
  • Fixes found on the way: the NATS parity test now tolerates the log listing its own reads; a staticcheck ST1018 in a test.

Migration 00014

audit_verify_runs (with a transition trigger and proof-field CHECKs), mhai_platform's append to audit_log narrowed to the eight audit actions, indexes on audit_log (actor_id, id) and (request_id), and four SECURITY DEFINER read functions whose bodies Tasks 11 to 13 (4c) fill. Down restores 00003's append and drops both indexes (TestMigration14DownRestoresPlatformAppend).

OpenAPI 1.6.0

Merged on top of main's 1.4.0 (mfa_enrolled) and this branch's 1.5.0. .github/scripts/openapi-breaking.sh against origin/main: no breaking changes.

Rulings (ledger R21 to R31)

  • R21: the 17 Plan 2/3 actions with no mhai-web kind stay withheld rows (the bare undisplayable entry).
  • R22: review_submitted sends no facts.state (mhai-web has no approved/rejected value).
  • R23: six new kinds, not seven (audit_undisplayable_reported is already in mhai-web).
  • R24: per-job job.retried/job.cancelled rows are system rows and stay withheld; jobs_* kinds are for batch rows only.
  • R25: read dedupe uses Postgres idempotency_keys (scope audit.read, 5 minutes) in the same transaction, not a KV bucket.
  • R26: the audit.searched dedupe key includes a hash of q, so each distinct search is recorded.
  • R27: analyst-only actors are undisplayable and left out of the person picker.
  • R28: audit_log(actor_id, id) index in 00014; hex HMAC cursor so it never has a pseudonym's shape.
  • R29: open_failure is a VerifyRun or null, set exactly while the failure is open.
  • R30: every page of the runs list is recorded (the dedupe collapses them) and the runs cursor is HMAC-bound to the session.
  • R31: one fix wave before this PR: session-bound runs cursor, both-or-neither contact, undisplayable reports deduped, outcome note re-checked on output, spec wording (the note IS returned in VerifyRun on the platform routes, never elsewhere), the 00014 Down test; M2, M4, M7, M8 went to TODO.md.
  • Also in §16: detail, people and hospitals reads are recorded as audit.searched.

Deferred (TODO.md, spec §16)

  • mhai-web must ship six audit kinds (answer_agreed, review_withdrawn, review_refused, learning_recorded, audit_check_started, audit_check_finished), searched_kind person/hospital/run, an analyst actor role, and approved/rejected on review_submitted; until then those entries show as "can't be displayed".
  • Not built: /{id}/patients, named access, exports, the bell and email on a broken chain, signed checkpoints.
  • Final-review residuals: list filters still see attributes of bare rows; a user name that can't be shown makes the integrity page 500; contact name length is counted differently in core and mhai-web; the gauge is stale on the copy walking the scheduled check; a cool-down on starting checks; the outcome reply sits in the idempotency store.
  • Flaky TestTwoCoreCopiesShareGrants (internal/grant), seen once in CI.
  • 4c (health, jobs, guardrails) is the next phase.

Deploy

The demo needs SECURITY_CONTACT_NAME and SECURITY_CONTACT_EMAIL in /srv/mhai-backend/.env for the integrity status; until they are set, GET /platform/audit/verify/status answers 503 (see deploy/README.md).

Test evidence (remote, steven-reid)

  • make smoke: pass, all five smoke tests including TestSmokeAuditLog.
  • Go: gofmt, go vet, staticcheck 2025.1, and go test -race plain, -tags integration, -tags "integration test": all green.
  • Python pytest -m "not models and not boundary": green (nothing Python changed since).

🤖 Generated with Claude Code

ojies added 18 commits November 3, 2026 15:11
Types, kinds (with their type, target kinds and note flag), facts keys and
filter shapes as mhai-web's audit screens accept them, copied from
audit-contract.ts, kinds.ts and read.ts. The six kinds Plan 4 sends that
mhai-web has yet to ship are listed under pending_in_web.
Migration 00014: audit_verify_runs (one running run, the proof fields an
intact run needs, a 1000-character outcome note on broken runs only),
mhai_platform's audit append narrowed to spec 5.5's eight actions with no
hospital, and the four platform SECURITY DEFINER functions with their final
signatures and a refusal body until Tasks 11-13.

audit/catalogue.go maps each action to the contract's type, kind, target,
outcome, flag and facts; anything unmapped, withheld, off its schema or
pseudonym-shaped is sent as the bare entry. The six audit.* actions get
closed schemas.
… job rows

- The intact proof CHECK is NULL-safe (entries_checked NULL passed it).
- audit_verify_runs: the platform inserts only running runs, and a trigger
  fixes a finished run but for its outcome and version.
- job.retried and job.cancelled map to the platform's jobs_* kinds only for
  the batch row (job_count, no job, no entity); per-job rows are system
  rows and are withheld (R24).
- C17 pins the exact relations, sequences and SECURITY DEFINER functions
  mhai_platform can reach, and that it is a member of no role.
- The catalogue fuzz requires a schema-valid payload to map unless its row
  is withheld or gated, and every row to map; the runtime pseudonym guard
  has its own test.
- The platform append test finds its row by its own id.
GET /platform/audit, /platform/audit/{id}, /platform/audit/hospitals and
/platform/audit/people for platform admins and auditors (platform
audience, two-step always), in authz.Tx as mhai_platform. Entries leave
only through the catalogue (Map, or the bare entry); the actor, hospital
and request id are added from the database and the whole entry is dropped
to the bare form if anything in it has the pseudonym's shape (C22).

The type, outcome and flagged filters run in SQL over conditions that
Classify evaluates in Go; a test holds Classify against Map. The cursor
is base64url(last id, sha256(canonical filters)[:16]) and is refused
under other filters (C33). total counts to 10000 with total_capped. q is
an exact event or request id, ignores the other filters and is refused
when pseudonym-shaped.

The first page is recorded once per session, list and filters for 5
minutes: the dedupe key is an idempotency_keys row inserted in the same
transaction as audit.viewed or audit.searched, so both commit or neither.
A cursor read records nothing. after_break follows the latest broken
verify run while its failure is open. The deferred routes are not
registered and answer 404. openapi 1.4.0, additive.
The cursor was base64url(last id, filter hash) with no key, and a cursor
page skipped the read record, so a forged cursor (last = MaxInt64 and the
filter hash) read the newest page with no audit.viewed row. Now:

- The cursor is hex(last id || HMAC-SHA256 tag[:16]) under a key derived
  with HKDF from MFA_ENCRYPTION_KEY ("mhai audit cursor key v1"), over the
  session id, the canonical filters and the id. A forged, altered, other
  session's or other filters' cursor is 422 validation_failed. Without a
  usable key the list is 503 not_ready. Hex can't form a pseudonym.
- Every page calls RecordRead; the 5-minute dedupe (session, list, filter
  hash, q included) collapses a page load into one row, and each distinct
  search is its own row.
- 00014 adds audit_log (actor_id, id) for the person filter (and drops it
  on Down); a test checks the plan uses it.
- TODO: mhai-web to add an analyst actor role.
…ople paging

- GET /platform/audit/{id} records the read as the exact-id search for
  that id (audit.searched, searched_kind event, entity audit_event), in
  the read's transaction and deduped per session and id for 5 minutes.
  If the row can't be written the reply is 500 with no entry.
- The person picker and the hospitals list are recorded as audit.searched
  with searched_kind person or hospital, never the text; the person
  search is deduped on a hash of q. mhai-web has no such searched_kind,
  so the catalogue gates audit.searched to event and request
  (searchedInWeb) and the new rows are sent bare (TODO for mhai-web). The
  reader's SQL gates are now a table checked against each When.
- 00014 adds audit_log (request_id), dropped on Down; a test checks the
  plan uses it.
- People search pages on (display_name, id) until 20 showable people or
  the end of the matches.
- openapi: q's pattern is what the handler takes; the recorded reads are
  described.
…nd stays broken; every tick refreshes the gauge, closes stale runs, and is due by the database clock
… recorded, undisplayable dedupe, note re-check, half-set contact, Down test, spec wording
@ojies
ojies merged commit f5031af into main Nov 3, 2026
8 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