Compliance
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.
IQ Routing is not currently claiming SOC2 Type I or Type II compliance and does not represent that it has completed a SOC2 audit. The tooling on this page produces evidence for your own auditor; a SOC2 report, if one exists, is provided separately under NDA.
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
Status: Live today. The audit-chain primitive and its verifier endpoint are in force in production.
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). - 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
Status: Live today, on the Enterprise plan. 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 ZIP
carries audit-log.json with the chain columns intact so the auditor
re-runs the chain verifier offline. 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 normalisedresultcolumn.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 cap
protects the gateway from a runaway request that would otherwise stream a
long-lived org's full audit history through one worker.
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
manifest.json carries the pack metadata so the auditor can spot a
truncated download immediately:
{
"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. The exporting actor is recorded on the audit row rather than in the manifest.
The row counts in the manifest match the row counts in each bundled file. A mismatch is the canonical "truncated download" signal and the auditor should re-export.
The README
README.md walks the auditor through the bundle in plain prose.
It names each file, the column meanings, and the offline verifier
invocation. 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. It also catches reorder, fork, cycle, and mid-chain
deletion when the export runs from genesis. 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
Status: Built, not enabled in production. 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 (no audit events for the org that day) skip the write entirely; the verifier handles the missing root cleanly as a non-divergence, and it 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. There is nothing for you to configure here; what matters to a reviewer is what that configuration rules out. 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 that could have changed since. 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, none of the retention properties described in this subsection are in force, and a reviewer should give them 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, and rolls the walk up into one period-level result:
- Live recompute: the Merkle root computed from the current audit rows for that day.
- Durable-store root: the root the gateway stored for that org-day.
- Object-store root: the write-once object for that org-day.
A day where the witnesses agree is only counted, not itemized: it
adds to days_verified and nothing else. A day where a witness
disagrees lands in two places: a compact entry in divergent_days
(day, expected_root, actual_root, reason) and the full
per-witness dump in inconsistencies. A day with no stored root yet
(the seal job has not sealed a root for that org-day) is its own
divergence reason rather than a tamper signal; the response's
integrity_status field separates that case (pending) from a
witness that actively disagrees with the live recompute
(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. The object-store root is meant to be the trusted anchor, because object lock is what makes it immutable; the stored root and the live recompute are the two witnesses that should agree with it. With no object-storage target configured in production, that anchor is absent there and the compare runs on two witnesses rather than three.
The Settings → Audit pane exposes the verifier as a one-click button parallel to the chain verifier; the dashboard renders the divergence list inline so the operator can pin the broken day without leaving the page.
GDPR DSAR pack
Status: Live today. The GDPR DSAR composer and its export endpoint are in force in production.
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: Article 15's right of access, Article 17's right to erasure, and Article 30's 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, ordered by(created_at, id). Carries the audit chain columns intact so the offline verifier still works.subject-identity.json: the subject's own identity record (id, email, name, creation date) plus their org membership role, per Article 15(3). It is left un-redacted, because this is the subject's own access copy.subject-held-data.json: the held processing content the subject's requests fall under, meaning stored prompt and response bodies plus agent-session usage, scoped to the requested org. Also un-redacted, and not bounded by the one-year audit window, because Article 15(3) is about the data held rather than a one-year slice. A row limit bounds the file so a very long processing history cannot stream unbounded.data-processing-register.json: an Article 30 manifest naming every distinct purpose, data category, retention window, and lawful basis declared for the subject within the period.deletion-log.csv: every erasure event per Article 17. The gateway emits asubject_data_deleted_accountevent on account erasure, and those rows land in this file.data-transfer-log.csv: every cross-border-transfer event per Chapter V records. The CSV carries the source region, the destination region, an explicitcross_borderflag, and the transfer mechanism, so an auditor can spot a cross-border transfer at row granularity.retention-policy-snapshot.json: a JSON snapshot of the org's current retention policy by data class.manifest.json: pack metadata plus row counts per file plus the subject_id and the period bounds. The exporting actor lives on the audit row rather than in the manifest.README.md: an auditor-facing walk-through naming each file plus the offline chain-verifier invocation.
The retention-policy snapshot reports what your org's configuration actually is at export time, and it is a snapshot rather than a statement of policy. The authoritative description of what the gateway retains, in particular the Zero Data Retention boundary and what stays recorded even with it on, lives on the security page and this page defers to it rather than restating it in slightly different words. If the two ever read differently, the security page is the one to hand your reviewer.
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: a request with `period_end
- period_start > 365 days
returns400` with the inline message "GDPR DSAR pack period exceeds 1-year cap". The cap protects the gateway from a runaway request that would otherwise stream a subject's full audit history through one worker.
The endpoint is synchronous. The composer returns the full ZIP
bytes, the endpoint writes the gdpr_dsar_pack_exported audit row
before constructing the StreamingResponse, and the response yields
the bytes as a single chunk. There is no background-job queue; an
operator hitting the endpoint sees either the ZIP download or the
4xx error in one round trip.
The audit row
Every successful export writes one audit row tagged
action = 'gdpr_dsar_pack_exported'. The row carries the requesting
admin's user id, the period bounds, the subject_id, plus the row
counts read from the manifest so the auditor can re-correlate the
download to the pack contents.
HIPAA BAA pack
Status: Not a HIPAA business associate today. IQ Routing 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 (emails, phone numbers, Social Security numbers, card numbers, and more) 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 (Business Associate Agreement) composer lands as a sibling instance of the SOC2 plus GDPR primitives. The pack covers §164.312(b) audit controls plus the Breach Notification Rule under 45 CFR §164.400-414 plus the workforce-training requirement, packaged for a covered-entity customer's compliance team.
The composer assembles seven files into one ZIP:
phi-access-log.json: every PHI-access audit row in the period per HIPAA §164.312(b).baa-metadata-snapshot.json: the org's BAA metadata snapshot (plan tier, data-residency region, and the derived BAA terms).breach-notification-log.csv: every breach-notification event in the period.audit-control-report.json: an aggregate report counting total events, distinct actors, plus a per-action breakdown across PHI access, modify, delete, and export.workforce-training-log.csv: every workforce-training event in the period.manifest.json: pack metadata plus row counts plus period plus the exporting org id.README.md: an auditor-facing guide explaining the seven files plus the HIPAA-scoped privacy posture.
The endpoint
POST /admin/compliance/hipaa-baa-pack?org_id=<uuid>
Authorization: Bearer <session token>
Content-Type: application/json
{
"period_start": "2024-01-01",
"period_end": "2026-01-01"
}
The endpoint mirrors the GDPR endpoint's access plus rate-limit
shape. No subject_id parameter because the HIPAA pack is org-scoped
(the §164.312(b) audit-control requirement targets the covered
entity's full PHI-touch history, not a single individual's record).
The 6-year cap matches the HIPAA §164.316(b)(2)(i) retention floor:
policies, procedures, and documentation must be retained for six
years from creation or the last date in effect, whichever is later.
The composer pins the ceiling at 2192 days; a longer window
returns 400 with "HIPAA BAA pack period exceeds 6-year cap".
The endpoint is synchronous on the same shape as the GDPR endpoint:
the composer returns the full ZIP bytes, the
hipaa_baa_pack_exported audit row writes before the
StreamingResponse, and the response yields the bytes as a single
chunk. No background-job queue; an operator sees either the ZIP or
the 4xx error in one round trip.
The audit row
Every successful export writes one audit row tagged
action = 'hipaa_baa_pack_exported'. The row carries the requesting
admin's user id, the period bounds, plus the row counts read from
the manifest. The audit row is the canonical signal a downstream
SOC2 evidence pack (which carries the audit rows verbatim) surfaces
to confirm a HIPAA export landed.
Cross-org Merkle
Status: Built, not enabled in production. The cross-org reduction sits on top of the per-org Merkle backup and inherits its status: it ships in the gateway and has composed no root in production.
The per-org Merkle backup is designed to pin one daily root per org to the immutable object store. A cross-org reduction 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 cross-org composer is scheduled for 04:00 UTC, one hour after the 03:00 UTC per-org job, so every per-org leaf for the prior day is already committed before the cross-org reduction runs. The composer reads every per-org root for the day, ordered by org id, and reduces the per-org roots into one SHA-256 Merkle root.
The sort-by-org-id contract
Ordering the leaves by org id is load-bearing because any reorder produces a different cross-org root, which would break the verifier's deterministic-recompute invariant. The verifier reads the per-org leaves under the same order so the two paths produce the same leaf order even when new orgs land mid-day. An empty day (no per-org roots) skips both writes so a quiet day does not leave a junk cross-org record.
The three-witness verify
The verifier endpoint runs a three-witness compare for one UTC day:
- Live recompute: the cross-org root computed fresh from the current per-org roots for the day.
- Durable-store witness: the cross-org root the gateway stored for the day.
- Object-store witness: the write-once copy the scheduled job writes for the day.
A clean day returns all_match=true with the three witnesses
agreeing and an empty divergent_witnesses list. Any divergence
surfaces all three values plus a per-divergence string so the
operator can pin which witness disagreed. The object-store read is
defensive so a transient storage failure surfaces as an empty third
witness rather than crashing the verify call; the durable-store witness
still runs and the compare still reports.
The access gate
The cross-org verifier is reserved for IQ Routing platform operators, not
for tenant roles. Your own owners and admins run the per-org verifier;
the cross-org reduction reads every org's per-org leaves, so it is not a
tenant-facing surface. The gate fires at the endpoint layer before any
cross-org work happens, and a non-operator caller is masked with a 404
rather than a 403 so the route is indistinguishable from one that does
not exist. The dashboard shows the cross-org button only to an owner
session, which is a UI narrowing on top of the operator gate rather than
the gate itself.
The verifier endpoint shape
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 POST /admin/compliance/audit-merkle-verify
with the period range in the JSON body, cross-org verification is
GET /admin/compliance/audit-merkle-cross-org-verify with the period
range as query parameters. The 1-year cap fires from the same shared
period-cap check, so the GET shape inherits the same 400 response
surface as the per-org POST shape.
The dashboard proxy mirrors the GET shape: the browser issues a GET with the org_id plus period_start plus period_end on the query string, the proxy attaches the bearer token server-side, and the upstream gateway responds with the three-witness divergent-day report as JSON.