Skip to content

Pre-release. v0.1 is not out yet, so there is nothing to install and no public source to clone — the quickstart builds from a checkout.

The audit ledger

Every authorization decision writes an audit record, including every denial. The record is written in the same transaction as the decision.

{
"seq": 10428,
"checkpoint": 271,
"ts": "2026-09-09T14:03:41.882Z",
"event": "token.issued",
"task_id": "task_01HQZX9K4M",
"agent_id": "jira-triage",
"sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"audience": "https://jira.internal",
"scope": "jira:read jira:comment",
"jti": "tok_01HQZX9K5P",
"delegation_depth": 1,
"decision": "allow",
"reason": null
}
  • sponsor is the human the task runs for. Filter on it to see everything any agent did for a person.
  • checkpoint is the checkpoint that seals the record. It is null until the sealing pass reaches the record.
  • A denial carries a machine-readable reason.
Event Written when
token.issued An exchange creates a task and its first token. There is no separate task.created.
token.refreshed A task is renewed
token.denied A token request is refused
token.revoked An agent revokes one of its tokens by jti
task.revoked A task is revoked
task.expired A task reaches its end. Expiry is never recorded as a revocation.
agent.registered, agent.updated, agent.deleted A registration changes
admin.denied An admin API call fails authentication
sponsor.blocked, sponsor.unblocked An operator blocks or unblocks a human
sponsor.signal A signal from the identity provider (back-channel logout, SCIM, Shared Signals) is acted on
signal.denied, scim.denied, ssf.denied An inbound signal or its credential is refused
audit.archived A month of the ledger is archived

tool.called is reserved and not produced in v0.1. A tool server records its own calls in its own log; see the log line. The exact shape of each record is section 7 of the spec.

Two kinds of summary keep the ledger from growing with traffic. Nothing is dropped:

  • Renewals. A task’s first renewal gets its own token.refreshed record. Later renewals are counted, and one token.refreshed record with a count is written when the task ends.
  • Denials that name nobody (no agent, person, task or token). Each reason is written once, then counted for the rest of SubactId:Audit:Aggregation:Window (one minute by default) and written as one record with a count.

A denial that names an agent, person, task or token is always its own record.

The ledger table is append-only: a database trigger refuses UPDATE, DELETE and TRUNCATE. A record carries no hash of its own. Instead, a background pass on each instance runs every SubactId:Audit:Checkpoint:Interval (one minute by default). It takes every record not yet sealed, builds a Merkle tree over them in sequence order, signs the root with the control plane’s signing key, and links the new checkpoint to the previous one:

leaf = sha256(0x00 || canonical_json(record))
node = sha256(0x01 || left || right)

The tree is RFC 6962, so a proof verifies in Certificate Transparency tooling. The canonical JSON and its exact string escaping are defined in the spec; anything that rebuilds a leaf must follow them.

{
"checkpoint_id": 271,
"first_seq": 10380,
"last_seq": 10429,
"tree_size": 49,
"root_hash": "2b8d...0e4c",
"prev_checkpoint_hash": "9c1f...a20b",
"closed_at": "2026-09-09T14:04:00.000Z",
"kid": "key-2026-09",
"signature": "3b2a...77d1"
}
  • Checkpoints cover the ledger end to end with no gaps. The checkpoints table refuses changes the same way the ledger does.
  • If a record is changed, removed or inserted, its window no longer builds the signed root. If a checkpoint is removed or replaced, the link from the next one breaks.
  • Sequence numbers may have holes: a rolled-back append uses a number without writing a record. tree_size says how many records the range really holds.
  • A checkpoint verifies only while the key that signed it is published. When you rotate the signing key, keep the old key configured.

The unsealed window. A record written since the last checkpoint is protected by the trigger but is not yet inside a signed root. The window is the checkpoint interval. Each instance logs its interval at startup.

Terminal window
SubactId.Server audit-verify
Output
Audit ledger intact: 272 checkpoint(s) verified sealing 10512 record(s) up to seq 10512, last checkpoint 272:9c1f6a30….

audit-verify checks every checkpoint in order: its signature against the published keys, its link to the previous checkpoint, and its root against the tree rebuilt from its records. It reports the first fault it finds.

Exit code Meaning
0 The seal is intact
3 The seal is not intact
1 The ledger could not be read
2 A malformed argument

A walk alone cannot detect a cut tail. Someone with write access could remove the last checkpoint and the records it sealed, and what remains would still verify. Each run prints its last checkpoint as checkpoint_id:root. Store that outside the database and pass it to the next run:

Terminal window
SubactId.Server audit-verify 272:9c1f6a30…

The command then also confirms that checkpoint still exists and signs the same root. GET /audit/checkpoints returns the checkpoints so you can keep a copy elsewhere.

GET /audit/records/10428/proof
Authorization: Bearer <admin api key>
{
"seq": 10428,
"checkpoint": { "checkpoint_id": 271, "...": "..." },
"leaf_index": 48,
"audit_path": ["a1b2...", "c3d4..."]
}

To verify, recompute the leaf from the published record, fold the audit path over it, check the checkpoint’s signature against the JWKS, and compare the result with root_hash. Nothing else is needed, so someone without access to the ledger can check a record.

  • 404: the record does not exist, or has not been sealed yet.
  • 410: the record’s month has been archived. Its proof is in the export.
GET /audit?sponsor=f47ac10b-…&from=2026-09-01&to=2026-09-30
Authorization: Bearer <admin api key>
  • Filters, all optional and combined with AND: sponsor, agent_id, task_id, from (inclusive), to (exclusive) and decision (allow or deny).
  • A time is an ISO 8601 timestamp or a bare date meaning midnight UTC. A bare date in to covers that whole day, so the example is all of September.
  • Records come back oldest first, limit per page (default 100, at most 1000). Pass next_cursor back as cursor for the next page.
  • Every page carries archived_before: the earliest time the online ledger still holds, or null if nothing was archived. A range older than that returns an empty page.

On Postgres the ledger is partitioned by month. Nothing leaves it unless you run SubactId.Server audit-archive. For each month it archives, the command:

  1. Verifies the checkpoints that seal the month.
  2. Exports the records and checkpoints to a file.
  3. Reads the export back and rebuilds every root.
  4. Writes an audit.archived record naming the export and its SHA-256.
  5. Drops the partition.

The checkpoints stay in the database, so the seal is unbroken. SubactId.Server audit-verify --archive <export> checks an archived month. Operating it covers planning.

Set SubactId:Audit:Sink:Url to also post every record to an external sink, in batches and in sequence order. The ledger remains the record. Delivery is queued and never slows a token request.

Delivery is at least once, and batches from several instances can arrive out of order. A sink should use seq as the identity and order by it.

Subact ID Pre-release. v0.1 is not out yet.

© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.

LegalTermsPrivacy