Skip to main content

Audit Chain — Tamper-Evident by Construction

Every security-relevant action — view, write, consent grant, break-glass invocation, admin action, LLM call, retrieval — is recorded as an audit_events row. The chain is append-only, SHA-256-hashed, and JCS-canonicalized so that a single altered byte in any past event breaks verification.

Append sequence

Schema

{
seq: number; // monotonic, gap-free
prevHash: string | null; // eventHash of seq-1, or null for genesis
eventHash: string; // SHA-256(JCS(every field except eventHash))
timestamp: ISO-8601;
actor: {
user_sub_hash: string; // salted hash — never raw IdP subject
role: string;
};
action: string; // e.g., "patient.encounter.created"
source: "api" | "ml" | "ehr_epic" | "research-engine" | ...;
patient_id: string | null;
authSource: "standing" | "admin_bypass" | "break_glass" | "intake_bootstrap" | "patient_self";
authSourceRef: string | null; // break-glass id, admin justification id, etc.
payload: {
... // action-specific; PHI fields redacted or count-only
};
requestId: string;
traceId: string;
}

Rules — non-negotiable

  1. AuditService.append() is the only writer. Any direct db.audit_events.insertOne() fails the CI grep check.
  2. Events are never mutated or deleted. A bug in a written event is corrected by a compensating event ({action: "audit.compensation", payload: {corrects_seq: N, reason: "..."}}) — not by rewriting history.
  3. Payload never contains raw PHI. Specifically: no prompt text, no model output, no MRN / SSN / DOB / phone / insurance / clinical narrative. PHI-adjacent content lives in llm_call_logs (envelope-encrypted) and joins back by traceId.
  4. Entity-identifier lists use count-only semantics (FR-SPEC-05-015). E.g., the memory.retrieval.hybrid event carries entities_referenced_count: 42 — never the full list of 42 ids.
  5. Verification runs hourly in Phase 2 on a scheduled job. Mismatch = Sev 0 = incident-response runbook fires.
  6. Retention — audit events live forever (no TTL). Related llm_call_logs content retention is 30 days with anonymization purge (FR-023e).

Cross-source correlation

Audit events from 6 services (api, ml, memory-store, research-engine, orchestrator, model-optimization) share the same audit_events collection + chain. Correlation is done via traceId (W3C trace-context propagated end-to-end) and requestId (generated at the API edge).

A forensic reviewer can pull all audit events for a traceId and see the full chain of activity across services — in order, tamper-evident, consent-aware.

Specific event catalogs

SourceAction prefixCommon actions
apps/apipatient.* · encounter.* · note.* · consent.* · care_team.* · referral.* · admin.*created · updated · viewed · granted · revoked · invoked
apps/api authauth.*signin · signout · refresh · mfa_verified · break_glass_invoked
apps/mlllm.*invoked · sanitized · deidentified · provider_fallback · retention_purged
memory-storememory.*entity.created · claim.created · claim.transitioned · retrieval.hybrid · govern.* · consent.grant/deny/break_glass_granted
research-engineresearch.*researcher.claim_emitted · critic.verdict · critic.sla_exceeded · critic.quarantine · correlator.* · replicator.* · librarian.*
orchestratororchestrator.*candidate.detected · candidate.scoring_started/scored/scoring_failed · candidate.approved/dismissed
model-optimizationoptimization.*trace.captured · decision_graph.node_added · outcome.recorded

Verification tools

# Manual verification of a specific range
curl -X POST https://api.onehealth.s4ai.com/api/audit/verify \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d '{"from_seq": 1000, "to_seq": 2000}'

# Response shape:
# {
# "ok": true,
# "verified": 1001,
# "integrity": "intact"
# }
# OR on mismatch:
# {
# "ok": false,
# "mismatch_at_seq": 1547,
# "expected_prevHash": "abc...",
# "actual_prevHash": "xyz..."
# }

In Phase 2, this runs hourly via a scheduled job (GovernanceAuditJob) + the mismatch path triggers the hipaa-incident-response skill's runbook.

Why JCS (JSON Canonicalization Scheme)?

RFC 8785 — JSON Canonicalization Scheme — produces a byte-exact canonical form regardless of key order, whitespace, or number formatting. Required because:

  • Two correct serializers of the same event could produce different bytes → different hashes → spurious mismatch
  • JCS is idempotent + deterministic → same event in, same bytes out, every time

Implementation: apps/api/src/modules/audit/jcs.ts. Test probe in audit.service.spec.ts verifies round-trip + re-canonicalization idempotence.

Deeper