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.
A record
Section titled “A record”{ "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}sponsoris the human the task runs for. Filter on it to see everything any agent did for a person.checkpointis the checkpoint that seals the record. It isnulluntil the sealing pass reaches the record.- A denial carries a machine-readable
reason.
Events
Section titled “Events”| 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.
Summary records
Section titled “Summary records”Two kinds of summary keep the ledger from growing with traffic. Nothing is dropped:
- Renewals. A task’s first renewal gets its own
token.refreshedrecord. Later renewals are counted, and onetoken.refreshedrecord with acountis 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 acount.
A denial that names an agent, person, task or token is always its own record.
The seal
Section titled “The seal”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_sizesays 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.
Verifying the ledger
Section titled “Verifying the ledger”SubactId.Server audit-verifyAudit 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:
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.
Proving one record
Section titled “Proving one record”GET /audit/records/10428/proofAuthorization: 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.
Reading records
Section titled “Reading records”GET /audit?sponsor=f47ac10b-…&from=2026-09-01&to=2026-09-30Authorization: Bearer <admin api key>- Filters, all optional and combined with AND:
sponsor,agent_id,task_id,from(inclusive),to(exclusive) anddecision(allowordeny). - A time is an ISO 8601 timestamp or a bare date meaning midnight UTC. A bare date in
tocovers that whole day, so the example is all of September. - Records come back oldest first,
limitper page (default 100, at most 1000). Passnext_cursorback ascursorfor the next page. - Every page carries
archived_before: the earliest time the online ledger still holds, ornullif nothing was archived. A range older than that returns an empty page.
Retention
Section titled “Retention”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:
- Verifies the checkpoints that seal the month.
- Exports the records and checkpoints to a file.
- Reads the export back and rebuilds every root.
- Writes an
audit.archivedrecord naming the export and its SHA-256. - 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.
Sending copies to a sink
Section titled “Sending copies to a sink”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.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.