Skip to content
IQ Routing

Compliance

Last reviewed: July 3, 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 that pins 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, SOC2 evidence-pack composer, and Merkle backup primitives are live.

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 /admin/audit/verify-chain walks the chain forward for one org, recomputes each row's hash, and returns the first divergent row id (or null if the chain holds end to end). 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. The SOC2 evidence-pack composer bundles one period's audit history into a single ZIP for an auditor. The ZIP carries audit_log.csv with the chain columns intact so the auditor re-runs the chain verifier offline. The full ZIP carries:

  • audit_log.csv: 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 the HTTP status, retry count, and dead-letter flag.
  • anomaly_events.csv: every anomaly the detector surfaced.
  • role_assignments.csv: every role grant and revocation.
  • chain_verification.py: a self-contained pure-stdlib script that re-runs the chain verifier against audit_log.csv so the auditor does not need network access to the gateway.
  • manifest.json: pack metadata plus row counts per CSV file.
  • README.md: an auditor-facing walk-through naming each file plus the offline verifier invocation.

The endpoint

POST /admin/compliance/evidence-pack
Authorization: Bearer <session token>
Content-Type: application/json

{
  "period_start": "2026-04-01",
  "period_end":   "2026-04-30"
}

The endpoint enforces assert_org_access so an attacker with admin-of-org-A credentials cannot probe org-B's pack. 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 365-day cap; split into multiple packs". 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:

{
  "gateway_version": "13.7.0",
  "org_id":          "org_5d9f...",
  "period_start":    "2026-04-01",
  "period_end":      "2026-04-30",
  "exported_at":     "2026-05-01T12:30:00Z",
  "exported_by":     "user_a1b2...",
  "row_counts": {
    "audit_log.csv":         1247,
    "key_vault_access.csv":     8,
    "webhook_deliveries.csv":  92,
    "anomaly_events.csv":       3,
    "role_assignments.csv":     6
  }
}

The row counts in the manifest match the row counts in each CSV. 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_verification.py audit_log.csv

It exits zero on a clean chain and one on the first divergence, printing the divergent row id to stderr.

Merkle backup

Status: Live today. The daily per-org Merkle backup and its three-witness verifier endpoint are in force in production.

The audit chain inside the database is tamper-evident, but a database admin with raw write access can still rewrite both the canonical row and its chain hash. The Merkle backup primitive defends against that.

Once a day, a cron job computes one Merkle root per org over the day's chain-hash values, ordered by (created_at, id). The root is written to two stores: a Merkle-root table in Postgres and a write-once object in immutable object storage. The object store has object lock enabled 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.

The cron

The daily cron runs at 02:00 UTC for the prior calendar day and is gated so a non-production deploy can skip it. The cron iterates every org with audit activity in the prior day, computes the root for each, and writes it to both stores in one transaction. 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.

Object lock

The backup target must be an object-storage bucket with object lock enabled (compliance mode, not governance mode) and versioning enabled. The writer refuses to start if the bucket is not configured. The bucket policy should also deny delete-version operations to every principal so even a root account cannot remove a prior version; a correct policy permits only put and get for the cron's role and denies every delete and lock-tampering action.

The verifier endpoint

POST /admin/compliance/audit-merkle-verify
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 returns the divergence list:

  • Live recompute: the Merkle root computed from the current audit rows for that day.
  • Database root: the stored Merkle-root row for that org-day.
  • Object-store root: the write-once object for that org-day.

A clean day returns all_match: true with the three roots equal. A divergent day returns all_match: false with the three witnesses plus a divergences list naming which witnesses disagree.

{
  "results": [
    {
      "org_id": "org_5d9f...",
      "day": "2026-04-15",
      "db_root": "5b9a...",
      "s3_root": "5b9a...",
      "recomputed_root": "5b9a...",
      "all_match": true,
      "divergences": []
    },
    {
      "org_id": "org_5d9f...",
      "day": "2026-04-16",
      "db_root": "9c1f...",
      "s3_root": "9c1f...",
      "recomputed_root": "ee20...",
      "all_match": false,
      "divergences": ["recomputed_root != db_root"]
    }
  ]
}

The three-witness compare model is the load-bearing piece. A single divergence is the canonical "the database has been tampered with" signal. The object-store root is the trusted anchor because object lock makes it immutable; the database root and the live recompute are the two witnesses that should agree with it.

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 seven 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.
  • 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 a subject_data_deleted_account event 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 destination region plus the source region 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, period bounds, and exporting actor.
  • README.md: an auditor-facing walk-through naming each file plus the offline chain-verifier invocation.

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 daysreturns400` with the inline message "GDPR DSAR pack period exceeds 365-day cap; split into multiple packs". 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. PII redaction at the gateway boundary is on the roadmap and not yet live, so the gateway does not strip identifiers from prompts. 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; split into multiple packs".

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

The per-org Merkle backup pins 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 runs at 04:00 UTC, one hour after the per-org cron, 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 the database write and the object-store write so a quiet day does not leave a junk cross-org row.

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.
  • DB witness: the stored cross-org root for the day.
  • Object-store witness: the write-once copy the cron wrote 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 DB witness still runs and the compare still reports.

The owner-only access gate

The cross-org verifier is owner-only. The admin role can run the per-org verifier but only an org owner can run the cross-org verifier because the cross-org reduction touches every org's per-org leaves. The dashboard renders the cross-org button only for an owner session; an admin session sees the single-org button and not the cross-org button. The owner-only gate fires at the endpoint layer too, so an admin who scripts the endpoint directly still hits a 403 before the composer runs.

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.