Skip to main content
Audit chain

Audit chain integrity

Every state-changing admin action on your fremforge org, SSO config changes, push-protection overrides, branch-protection updates, billing transitions, SCIM provisioning, is recorded in an audit log. fremforge goes one step further than a plain log: each entry is cryptographically chained to the previous one, and the chain head is WORM-anchored (Write-Once-Read-Many) to T Cloud Public OBS Object Lock storage every 2 minutes.

The result: you can detect any tampering of the audit log, and you can verify it yourself without trusting fremverk’s word for it.

This page documents what the chain guarantees, what it doesn’t, and how to exercise the verification end-to-end. The technical contract is in DPA Annex A.7.

The guarantee

For every audit-log entry recorded for your org, fremforge stores:

  • The event itself (actor, action, timestamp, fields).

  • A prev_hash, the hash of the previous entry for your tenant.

  • A hash, SHA-256 over the canonical JSON of the entry.

    The canonicalisation is exact, and the ordering below is alphabetical by key, not the order the fields are listed elsewhere on this page — a verifier that hashes them in any other order gets a different digest and concludes, wrongly, that the chain is broken:

    sha256( {"action":…,"actor":…,"created_at_iso":…,"fields_json":…,"prev_hash":…,"tenant_id":…} )

    No whitespace. prev_hash is the literal null at the chain epoch. created_at_iso is the timestamp as YYYY-MM-DDTHH:mm:ss.sssZ. fields_json is the payload as a JSON string, embedded as a string rather than as a nested object — so it round-trips byte-for-byte instead of being re-serialised. That last detail is the one that catches people: parse and re-serialise the payload and your digest will not match ours.

Because each entry’s hash includes the previous hash, modifying any entry breaks the chain forward. GET /orgs/{org}/audit/chain returns the raw rows so you can catch that yourself; GET /orgs/{org}/audit/integrity reports the walk we did.

In addition, every 2 minutes a separate anchor job writes (tenant_id, latest_hash, count, timestamp) to T Cloud Public OBS with Object Lock retention of 1095 days (3 years), in COMPLIANCE mode. The anchor object is physically un-deletable within retention, the storage layer rejects deletes from anyone, including fremverk. An attacker who somehow modified the entire DB chain would still have to reckon with the OBS-side anchors that disagree.

Gap disclosure. The 2-minute cadence is the target; we don’t claim a contractual ≤2min RPO on chain anchoring. If T Cloud Public OBS is unreachable (regional maintenance, network event), the anchor job retries on the next tick and records the gap in the chain metadata. Worst-case observed window before the chain-break alarm fires is 12 minutes (cadence + ANCHOR_STALE threshold). Gaps are documented in the next status-page incident report and called out in the DPA §A.7 audit-logging clause.

What this catches

ThreatCaught?
A single audit row’s content modified (actor, action, fields)✓, hash_mismatch on that row
A single row deleted from the middle of the chain✓, prev_hash_mismatch on the row after
The whole chain rewritten by an attacker with full DB write✓ if the rewrite happened more than ~10 min ago (12-minute worst-case), the WORM anchor disagrees
The whole chain rewritten + the WORM bucket also tamperedStorage-layer Object Lock physically rejects the OBS write; would require T Cloud Public-side compromise
An admin action that doesn’t emit an audit event at allThis is a coverage gap, not a chain break, see “What this doesn’t catch”

What this doesn’t catch

  • Coverage gaps. If a code path mutates state without emitting an audit event, the chain is silent. We cover the state-changing routes in Annex A.7; we run a quarterly audit on the route surface to confirm coverage holds.
  • Pre-Annex-A.7 history. Audit events emitted before chain anchoring was enabled on your tenant carry a pre-chain-epoch sentinel and are explicitly NOT part of the verifiable chain.
  • Read events. The chain only records mutations. A leak of read access (someone seeing data they shouldn’t) won’t appear as a chain entry, the audit-log slice does record the read where the route emits one, but read-only route coverage is a separate guarantee, not a chain guarantee.

Verifying the chain yourself

This is a local recomputation, not a status read. fremforge audit-verify downloads the raw rows from GET /orgs/{org}/audit/chain — each with its hash and prev_hash — and recomputes every digest and every link on your machine. It then fetches our own verdict and compares. The exit code is your result; a disagreement between the two is reported and fails the command.

The distinction matters more than it might look. Everything above this section describes a guarantee against someone who reaches the database but not the API: the WORM anchor is in Object Lock COMPLIANCE mode, so a tampered database cannot be made to agree with it. None of it constrains the API itself, and until August 2026 the cross-check between the chain walk and the anchor was performed by the same party that reported the answer. Local verification is what makes the guarantee checkable by you rather than asserted by us.

The anchor is signed, and you verify the signature — not us. Each anchor is written with a DSSE envelope signed by the same Ed25519 key as our SLSA build provenance, whose public half is published at www.frem.sh/.well-known/slsa-trust-root.json.

fremforge audit-verify fetches that trust root directly from www.frem.sh, not from the API, and there is no fallback to a key the API supplied — a signature checked against a key the audited party handed you proves nothing. It then checks the envelope’s subject digest equals sha256(anchor_head), so an envelope that is cryptographically valid for a different anchor is rejected rather than accepted.

Two honest caveats:

  • Anchors written before 2026-08-04 are unsigned and report NOT CHECKED. There is no retro-signing: an anchor’s value is that it was written at a point in time under Object Lock, and re-writing history to add a signature would defeat that.
  • Signing is deliberately non-fatal on our side. If the signer fails, the anchor is still written — an unsigned anchor is far better than no anchor, because the anchor is the tamper-evidence backstop. We alarm on that (AUDIT_ANCHOR_UNSIGNED, Major) so the degradation is loud rather than silent, and audit-verify reports NOT CHECKED rather than pretending.

Use the fremforge CLI:

# Install (one-liner — see /cli/ for manual download and SHA-256 verification)
curl -sSfL https://cli.frem.sh/cli/install.sh | sh

# Configure auth — generate a PAT at https://frem.sh/user/settings/applications
# with the audit:read scope.
export FREMFORGE_TOKEN='<your PAT>'

# Verify the chain for your org
fremforge audit-verify acme --human

Output looks like:

✓ local verification: ok
  (hashes recomputed on this machine — the server was not asked)

  walk_mode:              tail
  rows_walked:            1284
  verified_count:         1280
  pre_chain_epoch_count:  4
  chain_broken_count:     0
  tenant_erased_count:    0
  payload_redacted_count: 0
  last_verified_hash:     a2b3c4...

✓ WORM anchor (OBS Object Lock):
    anchor_head:            f8e9d0...
    chain_tip_at_anchor:    a2b3c4...
    count_at_anchor:        1280
    timestamp:              2026-05-05T18:00:00.000Z
    age_seconds:            120
    agrees_with_local_walk: true
    signature:              VERIFIED (keyid fremforge-slsa-builder-v1)
    against the trust root fetched from www.frem.sh, not from this API —
    so the anchored tip is the tip that was anchored.

  server verdict:         ok
  server agrees with the local walk.

Without --human, the CLI emits raw JSON for scripting — including both verdicts and any divergence between them:

# Exit 0 only if YOUR walk was clean and ours agreed
fremforge audit-verify acme || echo "verification failed: $?"

Exit codes, all derived from the local walk:

  • 0, the chain verified and our verdict agreed
  • 2, partial: either there are chain-broken sentinel rows (we wrote the audit row but the chain transaction failed, so the row is preserved for visibility and the chain is missing a link there), or the row cap truncated the walk so part of the chain was not checked. Note we report ok on a truncated walk and the CLI does not — attesting to rows nobody walked is exactly the kind of overstatement this command exists to avoid.
  • 3, broken: a row’s recomputed hash doesn’t match its stored hash, OR a row’s prev_hash doesn’t match the running chain. The output includes the first_break row id and reason.
  • 4, anchor mismatch: the WORM-anchored tip is not present in your chain, meaning the anchored row was deleted or altered. Note this is a containment check: an active tenant whose newest rows are not yet anchored is normal and passes.
  • 5, we disagreed: your walk was clean but our verdict was not. Usually a row appended between the two calls. Re-run to confirm; if it persists, we are seeing something you are not.

The direction of a disagreement is not symmetric, and the CLI says which one it found. Our verdict being worse than yours is usually a timing artefact. Our verdict being better than yours — we say ok where your own arithmetic says broken — is the case this whole mechanism exists to surface, and it is reported as an incident rather than a warning.

What we do internally

The chain head is anchored every 2 minutes to WORM storage, and the anchor chain is independently re-verified on a continuous schedule — each tenant’s anchor chain is walked forward from genesis and checked for continuity. Any mismatch, or any tenant whose anchor goes stale (older than ~10 minutes; 12-minute worst-case), pages on-call immediately as a leading indicator that anchor writes have stopped. Customer-facing impact is captured by DPA §11A, gaps are documented in our status page post-incident.

Pseudonymisation under GDPR

When you exercise Right-to-Erasure, audit events for your tenant are pseudonymised, not deleted: the actor field becomes [erased] and fields_json is reset to {}. The chain link (the hash) is retained so the chain remains verifiable; the PII is removed.

This means a fremforge audit-verify run against an erased tenant will show tenant_erased_count > 0, and those rows are reported separately rather than as breaks — a pseudonymised row deliberately no longer matches its own hash, while its link to the following row is still checked. The five counters (verified_count, pre_chain_epoch_count, chain_broken_count, tenant_erased_count, payload_redacted_count) do not sum to rows_walked: a single row can be both erased and payload-redacted, and is counted under both. The chain is intact; the contents are scrubbed. After the 3-year WORM-anchor retention window expires, the rows themselves are permanently deleted.

Independent verification

Auditors can request a walk-through of the chain end-to-end under NDA — email compliance@frem.sh for the technical assessment package.