Skip to main content

Audit streaming, hash chain & retention

Every security- and configuration-relevant action is recorded as an immutable audit event, queryable through GET /orgs/{org_id}/audit-events and exported daily. Audit v2 adds, for organizations whose plan includes it:

  • streaming — signed audit.recorded webhooks and compressed JSONL batch objects in a GCS or S3 sink, for SIEM ingestion;
  • tamper evidence — every event is sealed into a per-organization hash chain you can verify through the API and anchor against daily exports;
  • retention controls — a configurable keep-minimum of at least 396 days and an organization-level legal hold;
  • read audit — opt-in, sampled records of who read which entity's state.

Configuration lives at GET/PUT /orgs/{org_id}/audit-settings and GET/PUT /projects/{project_id}/audit-settings and requires the org admin role. Settings your plan does not include are refused with failed_precondition and a plan_limit detail naming the plan and the limit.

Receive audit.recorded webhooks​

Enable stream.webhook_enabled and subscribe a webhook endpoint of the project to the audit.recorded event type. Webhooks carry project-scoped events only (the envelope always names a project) and never state.read events; stream.action_prefixes narrows them further with literal prefixes such as contract. or slot.activate (an empty list streams every eligible action). Org-scoped events and read-audit events reach the sink and the query API.

{
"id": "whe_01j2x8…",
"type": "audit.recorded",
"created_at": "2026-07-21T10:05:12Z",
"project_id": "proj_01j2x8…",
"data": {
"audit_id": "aud_01j2x8…",
"org_id": "org_01j2x8…",
"project_id": "proj_01j2x8…",
"environment": "live",
"action": "contract.approve",
"actor": { "kind": "user", "id": "user_01j2x8…", "display": "reviewer@example.com" },
"resource": { "type": "schema_contract", "id": "3" },
"outcome": "success",
"details": { "version": 3 },
"ip": "203.0.113.7",
"user_agent": "console/1.0",
"request_id": "req_01j2x8…",
"occurred_at": "2026-07-21T10:05:01Z",
"chain_seq": 27,
"chain_hash": "4f0c…"
}
}

data is the full audit event (audit_event.json) plus its sealed position in the organization's chain (api/events/audit_webhooks.json). Verify the Tessera-Signature header exactly like every other Tessera webhook: HMAC-SHA256 over "{t}." + raw_body with the endpoint secret, within a five-minute timestamp tolerance. Delivery is at least once and unordered: deduplicate and order by audit_id (and chain_seq within an organization). A redelivered event carries the same envelope id and bytes.

Webhooks are published within one tailer poll (at most 30 seconds) of the event being committed; the first delivery attempt follows within seconds.

Read sink objects​

A sink receives every event — org-scoped events and state.read included — as zstd-compressed JSONL, one bare audit_event.json object per line, within five minutes of commit:

projects/{project_id}/audit/stream/dt=YYYY-MM-DD/batch-{first_audit_id}.jsonl.zst
orgs/{org_id}/audit/stream/dt=YYYY-MM-DD/batch-{first_audit_id}.jsonl.zst
  • S3: {"kind": "s3", "bucket": "…", "prefix": "siem/", "region": "us-east-1", "credential_secret": {"access_key_id": "…", "secret_access_key": "…"}}. The keys are envelope-encrypted with Cloud KMS, used only by the stream tailer and never returned — GET shows credential_configured: true. Omit credential_secret on later updates to keep the stored keys. Objects are written under your prefix.
  • GCS: {"kind": "gcs"} writes the paths above in the platform exports bucket with Tessera's own workload identity. Naming another bucket is refused: a shared platform identity writing tenant-named buckets could be aimed at a bucket you do not own.

Batch boundaries are fixed before an object is written, so a retried write produces the identical object at the same name.

Verify the hash chain​

Each event's record_hash is the SHA-256 fingerprint of its canonical JSON (sorted keys, compact separators — the platform's fingerprint algorithm), and

chain_hash(n) = SHA-256( bytes(chain_hash(n-1)) || bytes(record_hash(n)) )
chain_hash(0) = fingerprint({"format": "tessera.audit-chain.genesis.v1", "org_id": "<org_id>"})

over the raw 32-byte digests. Sequence numbers follow the order events were sealed, which can differ from occurred_at.

POST /v1/orgs/{org_id}/audit-events:verify
Content-Type: application/json

{"from_seq": 1}

The verifier recomputes at most 1000 links per call; continue from next_from_seq. It reports:

  • verified — every checked link and record is intact;
  • tampered with first_invalid_seq and tamper_kind — modified_event (a stored event no longer matches its record hash), missing_event (a chained event is gone outside retention), or chain_discontinuity (a chain row is missing or does not extend its predecessor);
  • pending with pending_count — the checked range reaches the head but committed events are not sealed yet. A stopped or lagging tailer never turns into verified.

Events whose month partition was dropped by retention count in expired_count: their links still verify, and their content remains checkable against the daily exports. Only events older than the 396-day retention floor can be expired; a missing younger event is always missing_event. A verified result with a non-null next_from_seq covers only the checked page: keep calling until next_from_seq is null.

Anchor the chain outside the database​

The daily export writes one anchor per organization:

orgs/{org_id}/audit/dt=YYYY-MM-DD/chain-head.json
{"chain_hash": "…", "chain_seq": 812, "day": "2026-07-21", "org_id": "org_…"}

Anchors are written once and never overwritten. To prove the stored chain was not rewritten, call :verify with {"to_seq": <chain_seq>} and compare verified_through_hash with the anchor's chain_hash.

audit_retention_days is a keep-minimum: 396 days by default, up to your plan's audit_retention_max_days. Audit data is stored in monthly partitions shared by all tenants, so a month is removed only once it is older than the longest retention of every organization holding events in it, its daily exports are verified, and no holding organization has an active legal hold. Retention never deletes individual events and never removes data early.

Retention can only be raised. A PUT /orgs/{org_id}/audit-settings whose audit_retention_days is below the stored value is refused with 400 failed_precondition and a precondition_failure detail:

{
"type": "precondition_failure",
"reason": "audit_retention_cannot_be_lowered",
"current_minimum_days": 1095,
"requested_days": 396
}

Sending the stored value again is accepted, even when your plan's retention ceiling has since been lowered below it (so you can still change other settings or release a legal hold). A value above your plan's audit_retention_max_days is still refused with a plan_limit detail, and a refused write changes nothing, including the legal hold in the same request. Concurrent writes cannot lower the value: the stored retention is only ever updated conditionally.

A legal hold (legal_hold: {"active": true, "reason": "…"}) blocks removal of every month holding the organization's events until it is released. Placing and releasing a hold are recorded as audit.legal_hold.place and audit.legal_hold.release in the same transaction as the settings change.

Read audit​

PUT /projects/{project_id}/audit-settings with {"read_audit_enabled": true, "sample_rate": 0.1} records a state.read event for a uniform sample of slot-bound entity-state reads (single-entity state, scores:batch and segment reads):

{
"action": "state.read",
"resource": { "type": "entity_state", "id": "slot_01j2x8…" },
"details": {
"endpoint": "scores_batch",
"entity_keys": ["player/p-1041", "player/p-2207"],
"entity_count": 2,
"sample_rate": 0.1,
"sampling_id": "rsa_3f9c0e…",
"purpose": null
}
}

Each read gets a fresh sampling_id minted by Tessera, and is sampled when the first 8 bytes of SHA-256 of that id, read as a fraction of 2^64, fall below sample_rate. Your X-Request-Id stays the event's request_id for correlation but never influences sampling, so no caller can choose to go unrecorded; the recorded sampling_id lets you recompute each decision. entity_keys lists up to 32 keys; purpose is the API key's purpose when the key is restricted to exactly one and the read is purpose-checked, and null otherwise (an unrestricted key, a key allowed several purposes, a signed-in user or application, or the slot-level segment list, segment and segment KPI reads, which are not purpose-checked). Recording never delays or fails a read, settings changes apply within 60 seconds, and disabling read audit restores byte-identical responses. Usage metering of reads is unchanged.