Security & ops
Compliance
Audit chain, evidence packs, GDPR DSAR, and HIPAA-oriented tooling.
Last reviewed: August 20, 2026
This page walks the gateway’s SOC2 posture: the audit-chain primitive that tamper-evidences every state-changing event, the SOC2 evidence pack composer that bundles a period for an auditor in one ZIP, and the Merkle backup primitive, which is designed to pin a daily root to write-once storage so a database admin who rewrites the on-database chain still cannot rewrite the historical root.
The audit-chain primitive and the SOC2 evidence-pack composer are live. The Merkle backup primitive is built but not enabled in production; the section below states what that means for a reviewer.
The audience is a SOC2 auditor working through the Trust Services Criteria for the gateway. Each section names the surface, the operator endpoint, and the verification step you can run yourself from the Settings → Audit pane.
Audit-chain primitive
Every state-changing mutation writes a row into the org audit log. Each new row stamps two extra fields: a chain hash (the hex digest of HMAC-SHA256(secret, prev_chain_hash || canonical_row_json)) and a previous-hash pointer (the chain hash of the prior row for the same org).
The secret is per-org and derived from the master key vault. It never crosses the wire; the verifier endpoint computes hashes server-side and returns only the verdict.
Pre-chain rows (everything written before the chain primitive landed) carry null on both chain fields. The chain is forward-only from the cutover row: the verifier reports the pre-chain epoch as out of scope and verifies every row after it end to end.
The chain shape mirrors a git commit-hash chain: any row that gets edited or deleted breaks the next row’s hash, and the verifier surfaces the break with the exact row id where the chain diverges.
The verifier endpoint at GET /admin/compliance/audit-chain-verify?org_id=<uuid> walks the chain forward for one org, recomputes each row’s hash, and returns a boolean verdict plus the first divergent row id (null if the chain holds end to end) plus the counts of rows verified and pre-chain rows skipped. The Settings → Audit pane exposes the verifier as a one-click button so an operator can run it before each evidence-pack export.
SOC2 evidence pack
The SOC2 evidence-pack composer bundles one period’s audit history into a single ZIP for an auditor. Access to the SOC2 and HIPAA packs is limited to Enterprise organisations; the GDPR DSAR export below is open to every plan. The full ZIP carries:
audit-log.json: every audit row in the period with the chain columns intact so the auditor can re-run the chain verifier offline.key-vault-access.csv: the four reveal-action audit terms (api_key_revealed,api_key_reveal_blocked,api_key_reveal_ip_blocked,api_key_reveal_hardware_token_blocked) joined to a normalised result column.webhook-deliveries.csv: every webhook delivery with its delivery status, attempt number, latency, and a dead-letter flag, with the destination URL SHA-256 hashed rather than shown.anomaly-events.csv: every anomaly the detector surfaced.role-assignments.csv: every role grant and revocation.chain-verify.py: a self-contained pure-stdlib script that re-runs the chain verifier againstaudit-log.jsonso the auditor does not need network access to the gateway.manifest.json: pack metadata plus row counts per file.README.md: an auditor-facing walk-through naming each file plus the offline verifier invocation.
The bundled files run through the gateway’s offline redactor before they are written, so emails, phone numbers, national identifiers, and card numbers in audit metadata, anomaly reasons, and key-vault user-agent strings arrive as [REDACTED:TYPE] markers. Source IPs are deliberately left intact because an access-trail review needs the literal value. The chain hash on each row was committed against the un-redacted canonical form, so an offline recompute reads clean only when the operator supplies the un-redacted view.
The endpoint
POST /admin/compliance/evidence-pack?org_id=<uuid>
Authorization: Bearer <session token>
Content-Type: application/json
{
"period_start": "2026-04-01",
"period_end": "2026-04-30"
}The endpoint enforces the org-access check so an attacker with admin-of-org-A credentials cannot probe org-B’s pack, plus the admin-or-owner role gate, plus the same 5-per-hour per-(user, org) export rate limit the other pack endpoints use. The 1-year period cap is enforced server-side: a request with period_end - period_start > 365 days returns a 400 with the inline message “Evidence pack period exceeds 1-year cap”. A reversed period returns 400 with the message period_start must be <= period_end.
The endpoint writes a soc2_evidence_pack_exported audit row before streaming the ZIP body so the export itself is auditable. The row carries the period bounds and the actor id but not the ZIP contents.
The manifest
{
"org_id": "org_5d9f...",
"period_start": "2026-04-01",
"period_end": "2026-04-30",
"generation_timestamp": "2026-05-01T12:30:00+00:00",
"gateway_version": "0.13.0",
"row_counts": {
"audit-log.json": 1247,
"key-vault-access.csv": 8,
"webhook-deliveries.csv": 92,
"anomaly-events.csv": 3,
"role-assignments.csv": 6
}
}The manifest carries exactly those keys; a caller-supplied key that is not on the whitelist is dropped rather than written through. A mismatch between the manifest’s row counts and the bundled files is the canonical “truncated download” signal and the auditor should re-export.
The README and offline verifier
README.md walks the auditor through the bundle in plain prose. The verifier runs as python chain-verify.py audit-log.json. It prompts for the per-org chain secret on stdin, which the operator supplies out of band; the secret is derived from the master key vault and never ships inside the bundle. The script prints ok on a clean walk and exits zero, or prints inconsistency at row <id> and exits one on the first divergence. A windowed export proves the linkage and content of the rows it contains but cannot prove the bundle is complete — the Merkle root below is designed to be the separate anchor for that, and it is not enabled in production, so a windowed export carries no completeness anchor today.
Merkle backup
The daily per-org Merkle backup job and its three-witness verifier endpoint ship in the gateway. The backup job is not enabled in production and no root has been sealed there, so the verifier has nothing to compare against. The audit chain above, not the Merkle root, is the tamper-evidence control in force today. What follows describes the primitive as built, so a reviewer can assess the design. Do not record it as an operating control.
The audit chain is tamper-evident inside the gateway’s own durable storage, but an operator holding raw write access to that storage could still rewrite both the canonical row and its chain hash. The Merkle backup primitive is designed to close that gap, and until it is enabled the gap is open.
As designed, a daily job computes one Merkle root per org over the day’s chain-hash values, ordered by (created_at, id). The root goes to two independent places: the gateway’s own durable storage, and a write-once object in immutable object storage. The design requires the object store to run object lock with versioning, so an attacker who gains storage credentials cannot overwrite a prior root; the most they can do is write a new versioned object alongside the original. No object-storage target is configured for production today, so the job does not seal roots there and neither store holds one.
The scheduled job
The job is scheduled for 03:00 UTC against the prior calendar day. It iterates every org with audit activity in the prior day, computes the root for each, and writes it to both places, with the object-storage write pinned to a committed durable-storage row so a partial write cannot leave the two disagreeing. Empty days skip the write entirely; the verifier reports a non-empty day with no stored root as pending rather than as tampering. Because the job is not enabled in production, the verifier reports every day as pending for every org there.
Object lock
The design calls for a backup target with object lock in compliance mode, not governance mode, and versioning on. Compliance mode means no principal, including the account that owns the storage, can shorten a retention period or delete a locked version before it expires. Governance mode would allow exactly that under a privileged override, which is why the design does not use it.
Each root is written with its own retain-until date rather than relying on a bucket-wide default, so per-object retention is provable rather than inferred from a bucket setting. The write also records the storage version id alongside the root in durable storage, so the verifier reads back the exact immutable bytes rather than whatever currently sits at that key. The design default is one year of retention per org. No such target is provisioned for production. Until one is, a reviewer should give these retention properties no weight.
The verifier endpoint
POST /admin/compliance/audit-merkle-verify?org_id=<uuid>
Authorization: Bearer <session token>
Content-Type: application/json
{
"period_start": "2026-04-01",
"period_end": "2026-04-30"
}The endpoint walks every day in the period, reads the three witnesses for each day — the live recompute, the durable-store root, and the object-store root — and rolls the walk up into one period-level result. A day where the witnesses agree is only counted, not itemized. A day where a witness disagrees lands in divergent_days and the full per-witness dump in inconsistencies. A day with no stored root yet is its own divergence reason (pending), separate from a witness that actively disagrees (inconsistent) and from a clean walk (verified).
{
"org_id": "org_5d9f...",
"period_start": "2026-04-01",
"period_end": "2026-04-30",
"days_verified": 30,
"verified": false,
"integrity_status": "inconsistent",
"divergent_days": [
{
"day": "2026-04-16",
"expected_root": "9c1f...",
"actual_root": "ee20...",
"reason": "db root differs from recomputed, s3 root differs from recomputed"
}
],
"inconsistencies": [
{
"org_id": "org_5d9f...",
"day": "2026-04-16",
"db_root": "9c1f...",
"s3_root": "9c1f...",
"recomputed_root": "ee20...",
"all_match": false,
"divergences": ["db root differs from recomputed", "s3 root differs from recomputed"],
"has_sealed_root": true
}
]
}The three-witness compare model is the load-bearing piece. A witness that disagrees with the live recompute, on a day that already has a sealed root, is the canonical “the stored history has been tampered with” signal. With no object-storage target configured in production, that anchor is absent there and the compare runs on two witnesses rather than three.
GDPR DSAR pack
The GDPR DSAR (Data Subject Access Request) composer mirrors the SOC2 evidence-pack primitive but scopes the export to one subject (a single user) inside one org for one period. The pack covers Articles 15, 17, and 30 of the GDPR: right of access, right to erasure, and records of processing activities.
The composer assembles nine files into one ZIP:
subject-access-log.json: every audit row tagged with the subject id for the period, with chain columns intact.subject-identity.json: the subject’s own identity record plus org membership role, per Article 15(3). Left un-redacted, because this is the subject’s own access copy.subject-held-data.json: stored prompt/response bodies plus agent-session usage, scoped to the requested org. Un-redacted, and not bounded by the one-year audit window. A row limit bounds the file.data-processing-register.json: an Article 30 manifest naming every purpose, data category, retention window, and lawful basis for the subject within the period.deletion-log.csv: every erasure event per Article 17.data-transfer-log.csv: every cross-border-transfer event per Chapter V, with source region, destination region, an explicitcross_borderflag, and the transfer mechanism.retention-policy-snapshot.json: a snapshot of the org’s current retention policy by data class.manifest.json: pack metadata plus row counts plus the subject_id and period bounds.README.md: an auditor-facing walk-through.
The retention-policy snapshot reports what your org’s configuration actually is at export time; it is a snapshot rather than a statement of policy. The authoritative description of what the gateway retains lives on the Security page and this page defers to it rather than restating it.
The endpoint
POST /admin/compliance/gdpr-dsar-pack?org_id=<uuid>
Authorization: Bearer <session token>
Content-Type: application/json
{
"subject_id": "user_5d9f...",
"period_start": "2026-04-01",
"period_end": "2026-04-30"
}The endpoint enforces org-access plus the admin-or-owner role gate, then rate-limits (the same 5-per-hour cap that gates the SOC2 endpoint), then walks the subject-belongs-to-org check before the composer fires. A subject_id whose user or audit-event trail does not appear under the requested org returns 404 so a cross-org subject probe stays indistinguishable from a typo. The 1-year cap is enforced server-side the same way as the SOC2 endpoint’s. The endpoint is synchronous: the composer returns the full ZIP bytes in one round trip, no background-job queue.
HIPAA BAA pack
Fairmeter does not currently offer or sign a Business Associate Agreement and is not a HIPAA business associate. The tooling described here is HIPAA-oriented audit-control and BAA-evidence building blocks, not a signed BAA or a HIPAA-readiness attestation. A per-org PII redaction setting can strip common identifiers out of prompts before they reach the model, but it is opt-in, off by default, and not a HIPAA Safe Harbor de-identification control. Do not route protected health information (PHI) through the gateway.
The HIPAA BAA composer lands as a sibling instance of the SOC2 plus GDPR primitives, covering §164.312(b) audit controls plus the Breach Notification Rule under 45 CFR §164.400-414 plus the workforce-training requirement. The composer assembles seven files: phi-access-log.json, baa-metadata-snapshot.json, breach-notification-log.csv, audit-control-report.json, workforce-training-log.csv, manifest.json, and README.md.
The endpoint mirrors the GDPR endpoint’s access and rate-limit shape, with no subject_id parameter because the pack is org-scoped. The 6-year cap matches the HIPAA §164.316(b)(2)(i) retention floor (2192 days); a longer window returns 400 with “HIPAA BAA pack period exceeds 6-year cap”.
Cross-org Merkle
The cross-org reduction sits on top of the per-org Merkle backup and inherits its status. It extends the primitive so an attacker who rewrites a single org’s per-org root still cannot rewrite the cross-org composition of every org’s roots for the same day.
The composer is scheduled for 04:00 UTC, one hour after the 03:00 UTC per-org job, reads every per-org root for the day ordered by org id, and reduces the per-org roots into one SHA-256 Merkle root. Ordering the leaves by org id is load-bearing because any reorder produces a different cross-org root. An empty day skips both writes.
The verifier runs a three-witness compare for one UTC day: the live recompute, the durable-store witness, and the object-store witness. A clean day returns all_match=true. The cross-org verifier is reserved for Fairmeter platform operators, not tenant roles; a non-operator caller is masked with a 404.
GET /admin/compliance/audit-merkle-cross-org-verify ?period_start=2026-04-01 &period_end=2026-04-30 Authorization: Bearer <session token>
The cross-org verifier diverges from the per-org verifier by HTTP method: per-org Merkle verification is a POST with the period range in the JSON body, cross-org verification is a GET with the period range as query parameters.