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.recordedwebhooks 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 —GETshowscredential_configured: true. Omitcredential_secreton later updates to keep the stored keys. Objects are written under yourprefix. - 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;tamperedwithfirst_invalid_seqandtamper_kind—modified_event(a stored event no longer matches its record hash),missing_event(a chained event is gone outside retention), orchain_discontinuity(a chain row is missing or does not extend its predecessor);pendingwithpending_count— the checked range reaches the head but committed events are not sealed yet. A stopped or lagging tailer never turns intoverified.
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.
Retention and legal hold
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.