The audit ledger model
Every state-changing action in Devotel Orbit — creating an API key, changing a role, updating a flow, exporting a contact list — is recorded in an audit ledger that is built to answer one question an auditor actually asks: is this the complete, unaltered history? A plain append-only table cannot answer it, because a deleted or edited row is indistinguishable from one that was never written. Orbit’s ledger answers it with a hash chain over every row, a daily Merkle root that anchors the chain to storage outside the database, and per-row canonicalization versioning so verification replays exactly what was written. This page explains the model: what the chain proves and what it deliberately does not, why the three planes that move audit data exist, and how to read a verification verdict. For the operational workflow (queueing an export, polling it, running the verifier), see Audit log export and verification; for the dashboard surface and quick CSV/JSON exports, see the Audit Log guide. SIEM streaming lives on the tenant webhook fabric — subscribe an endpoint toaudit.log.created in
Settings → Webhooks.
The three planes
Audit data moves through three deliberately decoupled planes. Only the first one can touch the chain; the other two are readers. Write plane — one append path. Every API route, scheduler, and background worker records audit events through a single append function. All 600+ call sites funnel into one chokepoint that resolves the event, opens a transaction, takes a cluster-wide PostgreSQL advisory lock scoped to your organization, reads the chain tail, computes the new hash, and inserts the row — all inside one commit. A single chokepoint is what makes the chain meaningful: there is no second write path that could append a row without linking it. Concurrency within one API replica is additionally queued in-process, and across replicas the advisory lock serializes — so the chain advances one append at a time per organization even under bulk fan-out. Streaming plane — push after commit. Once the write plane’s transaction has committed, the handler fires anaudit.log.created event onto your
tenant’s webhook fabric if you have an endpoint subscribed to it and platform
streaming is enabled for your workspace. The event carries the row’s hash
(current_hash) so your SIEM can later cross-check the streamed record
against an export’s verification verdict. Delivery is fire-and-forget: the
stream is never awaited, so a slow or failing SIEM can stall neither the API
response nor the hash chain. An optional filter
(security, or specific category/action prefixes) narrows the stream to the
security-relevant slice for SOC teams that do not want the full firehose.
Export and verify plane — queued bundles, replayable proofs. When a
reviewer needs a bounded slice — a quarter, an incident window — an export job
bundles your ledger rows into a downloadable file, and the chain-verify
endpoint replays the hash chain over the same day range and reports
chain_valid:true|false with the daily Merkle roots for those days. The
export is queued rather than synchronous so a range covering hundreds of
thousands of events never blocks your request.
The hash chain
Every ledger row stores two digests:prev_hash, the current_hash of the
previous row in your organization’s chain, and current_hash, the SHA-256 of
prev_hash concatenated with the row’s canonicalized content. The first row
an organization ever writes anchors from a fixed 64-zero genesis sentinel
rather than a NULL, so verification is a pure function with no special-case
branch on the head row.
A worked three-event chain:
Because each row embeds its predecessor’s digest, a broken link is provable:
if a row is inserted, removed, reordered, or edited, the
prev_hash pointers
stop matching, or a recomputed current_hash stops matching the stored one.
Verification walks the rows in original append order (created_at, then id)
and flags three defect classes:
missing_current_hash— the row was written outside the chained path;prev_hash_mismatch— the link to the predecessor is broken (insert / remove / reorder);current_hash_mismatch— the row’s content changed after it was hashed.
Canonicalization versions
The hash is computed over a canonical string — one fixed serialization of the event fields — so that two different services hashing the same event produce bit-identical input. Which serialization is correct has changed once:- v1 (legacy) sorted only top-level keys. Nested objects were emitted in
insertion order, so two code paths building the same nested
detailsobject could silently produce different digests. - v2 (current) sorts keys recursively at every depth; array element order is preserved, because ordering is semantically meaningful in audit payloads.
canonicalization_version,
1 or 2). That persistence is the point: the verifier re-canonicalizes
every historical row with the algorithm that hashed it at write time, so the
chain keeps verifying across the algorithm upgrade with no rewrite of stored
rows. Rows written before the version column existed are read as v1.
A v2 canonicalization example — this nested details payload:
prev_hash concatenated with that exact string.
Daily Merkle roots
A scheduler rolls each UTC day’s chain rows into one Merkle root — a balanced binary tree over that day’scurrent_hash leaf values — and persists
it on the daily-roots table with the row count and the first and last event
ids. The root blob is then uploaded to immutable (WORM) object storage and the
signed anchor URL is recorded on the row.
One root per UTC day does three things for an auditor:
- Compresses the day. Instead of re-hashing thousands of rows to confirm a day, a reviewer re-derives one 64-hex digest from the day’s leaves and compares it to the anchored root.
- Anchors externally. Because the root digest is stored outside the database, a post-hoc mutation visible in the database cannot be laundered into a “new” legitimate root — recomputation refuses to overwrite an already computed day.
- Bounds the export contract. Exports require day-aligned
from/todates because a partial day would anchor against an incomplete root. The verify endpoint returns the stored roots alongside the chain verdict so the reviewer can also confirm each day’srow_countagainst the exported row count.
Tenant resolution, hashed vs non-hashed
Two resolver behaviours explain what you see on the dashboard and why edits to some columns do not corrupt the chain:- Organization → tenant resolution. Call sites historically recorded only the organization id; the write plane back-fills the platform tenant id from a cached org→tenant lookup so per-tenant operator queries (and per-tenant SIEM streaming) actually see the row. Well-known sentinel scopes such as the public-DSAR path are remapped to the platform system organization before the insert, so no audit row ever carries a non-organization placeholder.
- What is hashed. Exactly eleven fields form the hash input: id, organization, tenant, user, action, resource, resource id, details, IP address, user agent, and the timestamp. The daily-root anchor URL and other bookkeeping columns are deliberately outside the hashed shape, so the scheduler can attach the signed anchor URL after the fact without altering a row’s proof. Everything that answers “who did what, on which resource, when” is inside the hash.
Failure behavior
The ledger is best-effort by design, and the failure semantics are the same across every surface:- Writes fail open. Non-blocking call sites log safe identifiers — organization, user, action, resource, and a short digest fingerprint of the payload (never the payload itself, which can carry PII) — and emit a metric so a sustained failure rate pages without ever exposing event contents.
- Reads fail closed. Verification validates the shape of every row it replays; a schema or SELECT drift that reads as a missing column throws a loud error rather than silently flipping a healthy chain to “invalid”.
- Streaming never blocks. SIEM dispatch happens after the append commits and is never awaited; faults are logged at warning level and surfaced nowhere in the request path.
- Genuine faults page. Transient database-availability blips (connection pooler timeouts, statement timeouts) are demoted to metrics-only, but a real chain or constraint fault still raises an alert — a broken audit chain never goes unnoticed.
Further reading
- Audit log export, chain verification, and refresh workflow — queue a bounded export, poll it to a signed URL, and replay the chain over your date range.
- Audit Log guide — the Settings → Audit log dashboard surface, filters, and quick CSV/JSON exports.
- SIEM sinks — continuous push delivery to Splunk, Datadog, or a drain you own, as an alternative to
audit.log.createdwebhooks. - Tenant-isolation model — the org/tenant scoping rules the ledger’s resolver builds on.