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 v0.1 API surface
Status: Draft, pre-v0.1.0. This contract may change until v0.1.0 is tagged.
Working spec. Everything here is standards-based: RFC 8693 (token exchange), RFC 7662 (introspection), RFC 7009 (revocation), RFC 8707 (resource indicators).
Core invariant:
A task token can never carry more authority than the human who invoked it, and every action traces back to that human.
1. Discovery
Section titled “1. Discovery”The control plane is its own OIDC issuer.
GET /.well-known/openid-configuration{ "issuer": "https://subactid.internal.example.com", "token_endpoint": "https://subactid.internal.example.com/oauth2/token", "introspection_endpoint": "https://subactid.internal.example.com/oauth2/introspect", "revocation_endpoint": "https://subactid.internal.example.com/oauth2/revoke", "jwks_uri": "https://subactid.internal.example.com/.well-known/jwks.json", "grant_types_supported": [ "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token" ], "token_endpoint_auth_methods_supported": ["private_key_jwt"]}Agents authenticate with private_key_jwt. client_secret_post is not
supported.
mTLS (tls_client_auth) is not in v0.1 and is not advertised.
2. Agent registration
Section titled “2. Agent registration”Admin API. A GitOps setup drives it from YAML in a repository
(docs/gitops.md).
POST /admin/agents{ "agent_id": "jira-triage", "display_name": "Jira triage agent", "sponsor_required": true, "allowed_scopes": ["jira:read", "jira:comment", "confluence:read"], "allowed_audiences": ["https://jira.internal", "https://confluence.internal"], "max_task_ttl": "PT30M", "max_token_ttl": "PT5M", "max_delegation_depth": 2, "high_risk_audiences": ["https://db.internal"], "jwks_uri": "https://jira-triage.agents.internal/.well-known/jwks.json"}Field notes:
-
sponsor_requiredmeans no token is ever issued without a human subject token. In v0.1 it must betrue. A registration withfalseis rejected, because every exchange requires a subject token. -
allowed_scopesandallowed_audiencesare the operator’s ceiling. The issued scope is the intersection ofallowed_scopes, the human’s own scopes and the request (section 3). An audience outsideallowed_audiencesisinvalid_target. Both must name at least one entry. A registration leaving either empty is rejected per field.high_risk_audiencesmay be empty, and is by default. -
max_task_ttlis the lifetime of the whole task.max_token_ttlis the lifetime of each token. No token outlives the task it belongs to, so a six-hour job never holds a six-hour credential. Both are optional:- A registration that names neither gets
SubactId:Tokens:DefaultTaskTtlandSubactId:Tokens:DefaultTokenTtl. - A registration that names only a task lifetime shorter than the default token lifetime has its token lifetime cut to the task, not refused.
- A value the registration names is not narrowed by these settings. They are defaults, not a second ceiling.
Both are checked against the server-wide bounds
SubactId:Agents:MinTaskTtl,MaxTaskTtl,MinTokenTtlandMaxTokenTtl. Use these bounds to hold every agent to something shorter. - A registration that names neither gets
-
max_delegation_depthis the longestactchain a token issued to this agent may carry. Any value from 1 to 5 is accepted. In v0.1 the control plane issuesdepth: 1only (section 4), so the value does not change what is issued. -
high_risk_audiencesnames the audiences whose tokens must be checked by introspection on every call, not validated locally until they expire. This is slower, and revocation is instant. A token minted for one of them carriesintrospect_required: true(section 4), so the tool server learns the requirement from the token and needs no configuration of its own. -
jwks_urinames where the agent publishes the public keys it authenticates with, over https, on a host of its own. A registration may instead carry the keys inline asjwks, an RFC 7517 key set with public members only. The control plane then serves them atGET /agents/{agent_id}/jwks.jsonand fetches nothing. That route serves inline keys only, so ajwks_uripointing at it names keys the control plane does not hold. Exactly one of the two is set. A registration with neither is refused. A key carrying any private member is refused per field, and so is a key this control plane could not verify an assertion with.
3. Token exchange
Section titled “3. Token exchange”The main endpoint. Standard RFC 8693.
POST /oauth2/tokenContent-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&subject_token=<user access token from Keycloak>&subject_token_type=urn:ietf:params:oauth:token-type:access_token&actor_token=<agent private_key_jwt assertion>&actor_token_type=urn:ietf:params:oauth:token-type:jwt&requested_token_type=urn:ietf:params:oauth:token-type:access_token&resource=https://jira.internal&scope=jira:read jira:commentAuthorization decision
Section titled “Authorization decision”Effective scope is the intersection of three sets:
effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopesAn empty intersection is invalid_scope, not an empty token. The audience must
appear in agent.allowed_audiences, or the answer is invalid_target.
The subject token is validated against the upstream IdP’s JWKS: signature,
iss, exp and aud.
The human is then checked against the same list a refresh uses (section 5). A valid token may have been issued before the human was blocked or disabled.
- A blocked or disabled human is
access_denied. - Under the polling mode, an IdP that cannot answer is
temporarily_unavailable, never a token. - Under the polling mode, the exchange asks the IdP for the current status and does not cache the answer. The new task’s first refresh must catch a human disabled since the exchange, so it cannot reuse an answer from before the task existed.
Each task also records the human under the claim named by the control plane’s
sponsor key setting, sub unless configured otherwise. Later signals about a
person are matched by this identifier. This supports providers whose sub is
not the identifier their other interfaces use. A subject token carrying no
usable value for this claim is invalid_grant, because a task that nothing
could later stop is not issued. sub is unaffected and remains the human in
every token and every audit record.
Response
Section titled “Response”{ "access_token": "eyJhbGciOi...", "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "token_type": "Bearer", "expires_in": 300, "scope": "jira:read jira:comment", "refresh_token": "task_grant_8f2c...", "task_id": "task_01HQZX9K4M", "task_expires_at": "2026-09-09T14:32:00Z"}refresh_token here is a task grant. It is bound to task_id, cannot widen
scope, and dies when the task expires or is revoked.
4. Task token claims
Section titled “4. Task token claims”{ "iss": "https://subactid.internal.example.com", "sub": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "aud": "https://jira.internal", "exp": 1757426520, "iat": 1757426220, "jti": "tok_01HQZX9K5P", "scope": "jira:read jira:comment", "client_id": "agent:jira-triage", "act": { "sub": "agent:jira-triage", "instance": "pod-7f9c4b", "depth": 1 }, "task": { "id": "task_01HQZX9K4M", "exp": 1757428020, "sponsor": "f47ac10b-58cc-4372-a567-0e02b2c3d479" }}sub remains the human. This is delegation, not impersonation. The agent
appears in act, never in sub. A tool server that sees no act claim knows a
human called it directly.
act.instance says which copy of the agent is acting. It is copied from an
instance claim in the agent’s client assertion. It is signed by the agent and
attributed to it, but never checked against anything. It is written only when
the agent asserted a non-blank value, and is at most 128 characters. A longer
one is invalid_client.
introspect_required is written, as true, only when the audience is listed in
the agent’s high_risk_audiences. A tool server that sees it must introspect on
every call instead of trusting the local check until the token expires. It is
absent otherwise.
Sub-agent delegation — not in v0.1
Section titled “Sub-agent delegation — not in v0.1”The control plane issues depth: 1 only. An exchange validates its
subject_token against the upstream identity provider, so a task token
presented as one is invalid_grant. max_delegation_depth is checked on every
exchange, against a depth that is always 1.
The rest of this section describes the shape of a sub-agent exchange. A tool server must already be able to read it, because a chain can reach it from anywhere. The SDKs (not released yet) will enforce their own limit on it. Do not build anything that expects the control plane to produce one yet.
A sub-agent exchange takes the parent’s task token as subject_token. The new
actor becomes the outermost act, and the previous actor nests inside it, per
RFC 8693 §4.1:
"act": { "sub": "agent:db-reader", "instance": "pod-3a1f88", "depth": 2, "act": { "sub": "agent:jira-triage", "instance": "pod-7f9c4b", "depth": 1 }}Two hard rules, both enforced by the server and never asserted by the client:
scope can only narrow on each hop, and depth may not exceed
max_delegation_depth.
5. Refresh for long-running tasks
Section titled “5. Refresh for long-running tasks”POST /oauth2/token
grant_type=refresh_token&refresh_token=task_grant_8f2c...&resource=https://jira.internal&scope=jira:read&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<agent private_key_jwt assertion>The grant is bound to the agent it was issued to, so a refresh authenticates the agent the same way an exchange does (RFC 7523 section 2.2). A grant presented by any other agent is treated as if it does not exist.
The server checks, in order:
- The grant exists.
- The task is not revoked.
task.exphas not passed.task.expis far enough off to be worth a token.- The sponsor may still be acted for.
- The requested scope ⊆ the previously granted scope.
- The policy check passes again against the agent’s current registration.
It then issues a fresh token with the same task_id and a new jti.
A task with less than five seconds left is access_denied with the reason
task_ending, so no token is issued that expires before its holder can use it.
This is not expiry: the task has not ended. A client that sees it should stop,
not retry.
The sponsor check
Section titled “The sponsor check”The control plane holds its own list of humans it will not act for. A human on
the list is refused at every exchange and every refresh, whatever put them
there. A disabled or deleted user is access_denied. The list is fed in one of
two modes, and they promise different things.
poll (the default) also asks the IdP’s admin API. It speaks Keycloak’s
admin API. It asks by sub, whatever the sponsor key claim is set to: the key
is how the control plane’s own list holds a person, and sub is how the IdP
does.
- On a refresh, the answer is reused for no longer than the lifetime of the token being issued. On an exchange, it is asked for as of now and not kept, so a task never inherits a status fetched before it existed.
- Every task of a user disabled or deleted at the IdP therefore fails its next
refresh within one
expires_in, whether or not anything told the control plane. - An IdP that cannot answer is
temporarily_unavailable, never treated as active. - After five questions in a row that the IdP itself fails (no connection, a
timeout, a server error), the IdP is not asked for five seconds. Every check in
that time is
temporarily_unavailableat once. Then one question is let through to see whether the IdP is back. - An unusable answer about one person is still a refusal for that person, and does not count towards the five failures. The pause only ever refuses sooner.
This is the stronger promise, and it is what v0.1 ships with.
signals does not ask the IdP. A human is acted for until something tells
the control plane otherwise. What it has been told is enforced within one
expires_in, as above. What it has not been told cannot be enforced: without a
signal, a task runs to its own task.exp, which max_task_ttl bounds. The
refusal is fail-closed (a list it cannot read is not a yes), but the list is
only as complete as what reaches it. Use this mode for a provider without a
Keycloak-shaped admin API.
The two are cumulative: under poll, a human is refused if the local list holds
them or the IdP does not confirm them. Neither can turn the other’s refusal
into a yes.
Clients should renew proactively at ~60% of expires_in rather than on 401.
The SDKs (not released yet) will do this automatically.
6. Revocation
Section titled “6. Revocation”POST /oauth2/revoke # RFC 7009, revokes one token or a task grantDELETE /admin/tasks/{id} # kills the task and every token under itDELETE /admin/agents/{id}/tasks # kills every live task for an agentDELETE /admin/sponsors/{sponsor_key}/tasks # kills every live task acting for one humanPOST /oauth2/revoke takes the RFC 7009 token and optional
token_type_hint, plus the agent’s client_assertion as in section 5.
- A task grant revokes its task and everything delegated from it.
- A task token is revoked by
jti. - Only the agent a token was issued to can revoke it. Anything else, including a
token that does not exist, gets the same empty
200, so the endpoint cannot be used to probe tokens. - Revoking twice is one revocation.
The sponsor kill switch takes the key each task is stored under. That is the
claim named by the control plane’s sponsor key setting, not necessarily the
sub (section 3). It ends what is running and nothing more: the person may
start a new task at once. A human with nothing live gets revoked_tasks: 0,
not a 404. To also refuse the person, block them:
GET /admin/sponsors/{sponsor_key} # the block standing on that human, or 404PUT /admin/sponsors/{sponsor_key}/block # refuse them, and end what is runningDELETE /admin/sponsors/{sponsor_key}/block # lift a block an operator placedA block row exists only while the person is refused.
- Blocking is idempotent. It revokes the person’s live tasks in the same transaction.
- Every block records which source placed it, and only that source may lift it.
An operator cannot clear what a provisioning feed reported, and a feed cannot
clear what an operator decided.
DELETEby the wrong source is409, not a silent success. - Lifting a block brings nothing back. Revocation is permanent everywhere in this section. A lifted block only lets the person start again.
Revocation is eventual for locally validated tokens, bounded by
max_token_ttl. For audiences listed in high_risk_audiences, tool servers
call introspection per request and revocation is immediate. A token minted for
such an audience carries introspect_required: true (section 4), so a tool
server that honours it needs no configuration of its own. Operators choose this
tradeoff per audience, and should do so knowingly.
POST /oauth2/introspect # RFC 7662{ "active": false, "revoked_at": "2026-09-09T14:05:11Z", "revocation_reason": "operator_kill_switch"}Introspection is answered from storage, so a revocation shows the moment it is written. A token is active only if all of these hold:
- it verifies;
- it has not expired;
- it has not been revoked by
jti; - its task is still active and unexpired;
- its agent is still enabled.
An active token returns its claims, sub, act and scope among them, plus
task_id. Anything else is active: false, with:
revoked_atandrevocation_reasononly when the token or its task was revoked;revocation_reason: "agent_disabled"when its agent was disabled;- nothing else.
So nothing can be learned from a token that is not a live token of this control
plane. An expired token or task is plain active: false, because expiry is not
a revocation. The token_type_hint is ignored.
In v0.1, holding the token is the only credential the endpoint asks for. A task token is unforgeable and readable by its bearer, so introspection reveals nothing the bearer does not already have except the revocation state.
Back-channel logout
Section titled “Back-channel logout”POST /backchannel-logoutContent-Type: application/x-www-form-urlencoded
logout_token=<logout token from the IdP>OpenID Connect Back-Channel Logout 1.0. A session ending at the identity provider ends the tasks that session started. A logout naming only the person ends every live task of theirs. It revokes and does not block: the same person may sign in again and start a new task. To block somebody, use the admin operation above.
The token is validated as section 2.4 of Back-Channel Logout requires, and no more loosely:
- a signature by a key the upstream publishes;
issequal to the upstream issuer;audcontaining the configured client;- a recent
iat; - an
exphonoured when present; - an
eventsclaim carryinghttp://schemas.openid.net/event/backchannel-logout; - at least one of
subandsid; - no
nonce; - a
jtithat has not been seen before.
The last three stop an ID token being posted here as a logout, and stop the same logout being replayed.
The configured client is the one the identity provider registered the logout URI on. That is the human-facing client, not the audience a subject token carries. They are different values; do not conflate them.
Every answer carries Cache-Control: no-store.
200when the logout was acted on, including when the person or session had nothing running.400with an OAuth error body when the token does not validate. The body does not say which check failed.503withtemporarily_unavailablewhen the IdP’s keys cannot be fetched. That is the control plane’s failure, not the token’s, so a sender that retries should retry this one.
A jti is remembered from the moment its token is accepted, in the same
transaction as the revocation it asks for. A revocation that fails therefore
does not leave the token spent. The jti is remembered for as long as the token
could have been accepted, and no longer. A token presented again after it was
accepted gets 200, as it did the first time, and nothing is recorded. Section
2.6 of Back-Channel Logout lists the jti check as optional. Here it keeps one
logout from being applied twice. It is not a reason to call the token bad, so a
provider that re-sends a logout whose acknowledgement it did not see is not told
its logout failed.
The endpoint exists only when a client is configured. Subact ID does not advertise
backchannel_logout_supported in its own discovery document: that field means
the provider sends logout tokens, and Subact ID receives them. The operator
registers the URI at the identity provider.
SCIM 2.0 provisioning
Section titled “SCIM 2.0 provisioning”POST /scim/v2/UsersGET /scim/v2/Users/{id}GET /scim/v2/Users?filter=userName eq "ada@example.com"PUT /scim/v2/Users/{id}PATCH /scim/v2/Users/{id}DELETE /scim/v2/Users/{id}GET /scim/v2/ServiceProviderConfigRFC 7643 and RFC 7644, with enough of the Users resource for a provisioning
client to run. Not supported: Groups, bulk, sorting and entity tags. The only
filter is attribute eq "value" on userName or externalId.
ServiceProviderConfig states all of this, so a client can read it there.
Unlike a logout, a provisioning write blocks: a deactivation means the person should not be acted for until somebody says otherwise. The effect of every operation depends on the state the write asks for:
| Write | Effect |
|---|---|
active: false, on create, PUT or PATCH |
block the person, source scim, kind disabled, and revoke every live task of theirs |
DELETE |
block, source scim, kind deleted, and revoke every live task |
active: true |
lift a scim block, and only a scim block |
A block another source placed is never replaced, lifted, or taken over. An operator’s block outlives a provisioning feed that has not caught up, and this receiver cannot take over a block and then lift it with a later reactivation. Revocation is permanent: a reactivation lets the person start a new task and brings none of the old ones back.
The attribute that names the person is configured: externalId by default, or
userName. It must carry the same value as the sponsor key claim of that
person’s subject token (section 3), or a deactivation matches nothing. A create
or replace that cannot supply it is 400 with invalidValue, so provisioning
fails at setup rather than silently failing to block later.
A user record holds the identifiers and the state, and nothing else. Names,
emails, departments and managers that a client sends are accepted and dropped,
so a GET returns what was kept, not what was sent.
Authentication is a bearer credential issued to the provisioning client,
checked before the request body is read. It is not the admin key and grants
none of its authority. Two values are accepted at once, so a client that rotates
a secret in two steps, as Okta and Entra ID do, keeps provisioning in between. A
refused request writes a scim.denied record naming nobody. The routes do not
exist until a credential is configured, so an unconfigured deployment answers
404, not 401.
Errors use SCIM’s own shape (RFC 7644 section 3.12), with a fixed sentence per failure and nothing the client sent quoted back:
| Status | When |
|---|---|
404 |
the resource is not here |
409 with uniqueness |
a second record under one userName |
400 with invalidValue or invalidSyntax |
a body that cannot be applied |
415 |
a body that is not application/scim+json |
507 |
the receiver already holds as many users as it is configured to |
412 |
the user kept changing under the write |
A patch that sets an attribute this receiver keeps to a value it cannot read,
such as active set to something that is not a boolean, is invalidValue,
never silently ignored. A 200 would say a deactivation was applied when it
was not.
A write is decided from the user as read, and applied only while the user is
still in that state. Two writes to one person that both read them as active, one
deactivating and one not, cannot end with the second putting the person back.
The second finds the user changed, reads them again and decides again. After
three such rounds it answers 412 rather than write over what it did not read.
Shared Signals and CAEP
Section titled “Shared Signals and CAEP”POST /eventsContent-Type: application/secevent+jwt
<security event token>RFC 8935 push delivery of an RFC 8417 Security Event Token. The route exists only when a transmitter is configured. Four event types are acted on:
| Event | Effect |
|---|---|
CAEP session-revoked |
revoke every live task of that person; no block |
RISC account-disabled |
block, source ssf, kind disabled, and revoke |
RISC account-purged |
block, source ssf, kind deleted, and revoke |
RISC account-enabled |
lift an ssf block, and only an ssf block |
A token that validates but carries only events this receiver does not act on is accepted and changes nothing. A transmitter was entitled to send it, and a refusal would make it retry forever or disable the stream. As with every signal, a block another source placed is never replaced, lifted or taken over.
Two guards apply, and neither is sufficient alone:
- The transmitter presents a push credential, checked before the body is read.
- The token is verified against the keys the configured transmitter publishes. That is a different issuer, with different keys, from the identity provider whose subject tokens are exchanged here.
iss must be that transmitter, aud must be the configured stream audience,
and iat must be no older than a day. The limit is a day, not minutes, because
a transmitter queues what it could not deliver and sends it when the receiver is
back.
A jti seen from that transmitter before is acted on once. The second delivery
gets 202, as the first did, and nothing is recorded. RFC 8935 has no other way
to say “already done”, and a refusal counts as a failed delivery at the
transmitter.
The subject is an RFC 9493 Subject Identifier, either as a top-level sub_id or
inside the event. Two formats are read: iss_sub, whose iss must be the
upstream identity provider, and opaque. Either way, the value is the
identifier tasks are keyed by (section 3). These are refused, not guessed at:
- a format this receiver does not read;
- a token that names one person at the top level and another inside the event.
Answers:
202with an empty body when the event was acted on.400with RFC 8935’serrand adescriptionnaming the check that failed, when the token does not hold. Unlike the back-channel logout receiver, this one names the check, because nothing reaches this validator without first presenting the push credential.401withauthentication_failedand nothing more, for a missing or wrong credential.503when the keys could not be fetched.
Subact ID advertises no receiver metadata. Shared Signals defines metadata only for transmitters. The operator creates the stream at the transmitter.
7. Audit record
Section titled “7. Audit record”Append-only, and sealed by signed checkpoints. A record carries no hash of its own. It is written in the transaction that made the decision. A background pass periodically takes every record not yet sealed, builds a Merkle tree over them in sequence order, signs the root and links that checkpoint to the one before it. Tamper-evidence means “this record is provably inside a signed checkpoint”, so it covers a record once the checkpoint window has passed.
{ "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}checkpoint is the checkpoint whose signed root this record is inside. It is
null until the sealing pass reaches the record, and it is written as an
explicit null, not left out, so a reader can tell a record that can be proved
from one that can only be read.
A record’s leaf is sha256(0x00 || canonical_json(record)), and an interior
node is sha256(0x01 || left || right). A tree of n leaves splits at the
largest power of two strictly below n. This is RFC 6962, so a proof verifies in
any Certificate Transparency tooling.
The canonical JSON is:
- the record’s semantic fields, with keys in lexicographic order;
- no whitespace;
- nulls written explicitly, except
countanddetail(below); - the timestamp as UTC ISO 8601 with exactly three fractional digits;
- the decision in lowercase;
- without
seqandcheckpoint, because neither is a fact about the event.
Strings inside it are escaped one fixed way. A leaf is a hash of bytes, so two encoders that escape the same value differently compute two different leaves. The output is ASCII:
\is written\\.- Backspace, tab, line feed, form feed and carriage return are written
\b,\t,\n,\fand\r. - Every other control character, every character outside printable ASCII, and
each of
",&,',+,<,>and`is written as\uXXXX, with four uppercase hexadecimal digits of its UTF-16 code unit. A character above U+FFFF is two escapes, its surrogate pair. /is written as it is.
Anything that rebuilds a leaf from a published record, such as a sink, an auditor or an SDK, escapes this way and not the way its own JSON library defaults to. Otherwise its leaf is not the one that was sealed.
count is absent from the canonical JSON rather than written as "count":null,
for the reason section 7.2 gives.
detail says what a record is about where no other field holds it. In v0.1
exactly one event carries it: audit.archived (section 7.5). It is absent
everywhere else, on the same terms as count: absent from the canonical JSON
rather than written as "detail":null, so a record without it hashes and
verifies unchanged. A reader treats an absent detail as nothing said, and
never as a value.
7.0 The checkpoint
Section titled “7.0 The checkpoint”{ "checkpoint_id": 271, "first_seq": 10380, "last_seq": 10512, "tree_size": 131, "root_hash": "4e77...c913", "prev_checkpoint_hash": "9c1f...a20b", "closed_at": "2026-09-09T14:04:00.000Z", "kid": "key-2026-09", "signature": "3b2a...77d1"}A checkpoint covers [first_seq, last_seq], which begins exactly where the
previous one ended. The checkpoints cover the ledger end to end, with no gap and
no overlap. The range names sequence numbers, not rows. A number that a
rolled-back append consumed falls inside a range and is sealed as a gap.
tree_size is how many records the range actually held, so a record inserted
into such a gap afterwards is detected as a fault.
signature is an ES256 signature over the canonical JSON of the fields above
except signature itself. It uses the same form and the same string escaping as
a record’s canonical JSON. It is made with the control plane’s existing signing
key set and verifies against the JWKS of §1. prev_checkpoint_hash is the
SHA-256 of the previous checkpoint’s signed bytes, and null on the first
checkpoint. Hashes and the signature are lowercase hex.
A checkpoint verifies only while the key it names is published. The seal is
signed with whichever key is active, so every key that has ever been active has
signed checkpoints. Removing a key from the key set makes every checkpoint it
signed unverifiable from then on: audit-verify reports the first of them as a
bad signature and examines nothing after it. A routine rotation therefore stops
signing with a key but keeps it configured. Only a disclosed key is removed, at
that cost. The rollout is in docs/keys.md.
Each instance runs a sealing pass every SubactId:Audit:Checkpoint:Interval, one
minute by default. The claim to seal is exclusive, so any number of instances
share the work and each range is sealed once. audit_checkpoints is guarded
exactly as the ledger is: it refuses UPDATE, DELETE and TRUNCATE by
trigger and by revoked privileges.
The unsealed window. A record written since the last checkpoint is in the ledger, guarded by the append-only trigger and the revoked privileges, but it is not yet inside a signed root. That window is the checkpoint interval, one minute by default. Each instance states the window it is running with at startup.
7.1 Reading the seal
Section titled “7.1 Reading the seal”GET /audit/checkpoints?after=&limit= returns the checkpoints as they are
stored, oldest first, limit (default 100, at most 1000) per page. next_after
continues to the next page and is null on the last page. Keep a copy somewhere
this database cannot reach: that is what catches a cut tail.
{ "checkpoints": [ { "checkpoint_id": 271, "...": "..." } ], "next_after": 271 }GET /audit/records/{seq}/proof returns the checkpoint that seals a record, the
record’s position among its leaves and the audit path:
{ "seq": 10428, "checkpoint": { "checkpoint_id": 271, "...": "..." }, "leaf_index": 48, "audit_path": ["a1b2...", "c3d4..."]}To verify, recompute the leaf from the published record, fold the path over it,
and compare the result with root_hash, after checking the checkpoint’s
signature against the JWKS. Nothing beyond what these two endpoints return is
needed.
- A record that does not exist, or that the pass has not reached yet, answers
404. The audit query tells the two apart: a record it returns with a nullcheckpointis waiting, and one it never returns was never written. - A sequence number inside a sealed range at which no record was committed (one
a rolled-back append consumed) is a record that does not exist, and answers
404too. - An
audit_pathis empty when the checkpoint sealed one record. That is a whole proof, not a missing one. - If the records a checkpoint covers no longer rebuild the root it signed, there
is no honest proof to give. The endpoint answers
500rather than a path that folds to nothing.audit-verifysays what changed. - A record whose checkpoint has been archived (§7.5) answers
410. Its root still stands and its checkpoint is still listed, but its leaves are in an export and not in this database. The body names the checkpoint and says so.SubactId.Server audit-verify --archive <export>checks it.
Both endpoints require the admin API key, like the audit query.
7.2 Summary records
Section titled “7.2 Summary records”A caller that presents no credential can be denied as fast as it can ask, and
every denial is a record in a table that refuses DELETE. So a denial that
names nobody (no agent_id, no sponsor, no task_id, no jti) is recorded
once per reason per window, and the rest of that window is counted:
- The first occurrence of a reason is written as it happens, like any other record, so the ledger answers within one request of the event.
- Every further occurrence in the window is counted, not written.
- At the window’s close, one summary record is written carrying
count, the number of further occurrences. A reason that happened once produces no summary.
A denial that can be attributed is never summarised, at any volume. Each stays one record per event.
A task’s successful renewals are summarised the same way, with the task’s own life as the window:
- The first
token.refreshedfor a task is written as it happens, so the ledger shows the task alive within one request. - Every further successful renewal is counted on the task’s grant, in the statement that records the grant’s use. The count is exact, survives a restart, and is one count across every instance.
- When the task ends (
task.expiredfrom the sweeper, ortask.revokedhowever it was revoked), onetoken.refreshedsummary carryingcount, the renewals beyond the first, is written immediately before the terminal record. A task renewed once produces no summary.
The summary names the task, agent, sponsor, audience, scope and depth, as the
terminal record does, with decision allow and jti null. It stands for
several tokens, so the identifiers of renewed tokens beyond the first are not in
the ledger. A refused renewal is never summarised: every one is its own
token.denied.
count is present only on a summary record and is absent everywhere else.
A reader treats an absent count as one. It is also absent from the canonical
JSON, not written as "count":null. This is the one exception to writing nulls
explicitly, so a ledger written by an earlier version seals and verifies
unchanged.
The denial window is SubactId:Audit:Aggregation:Window, one minute by default.
- Denial counts are held in memory until the window closes. They are approximate at the edges, and a crash loses at most one window of them. What was written through is unaffected.
- Each instance counts its own, so n instances write up to n summaries per reason per window.
- Renewal counts are in the database, exact, and one per task.
SubactId:Audit:Aggregation:Enabled turns both kinds of summary off. It is a
deployment setting, not a switch to flip while tasks are alive. If it is turned
off and on again, the renewals written individually in between are still in the
grant’s count, and the summary counts them again.
An append reads nothing and takes no exclusive lock. Its only lock is a shared
fence, taken by the insert’s own statement and held until it commits, so
appends never wait on each other. The sealing pass takes that fence exclusively
for the length of one query, once per interval, to read a high-water mark below
which nothing can still appear. A sequence number is taken at INSERT and only
becomes visible at COMMIT. The fence stops a transaction that commits late
from landing a record underneath a root that is already signed. The table
refuses UPDATE, DELETE and TRUNCATE by trigger, and so does
audit_checkpoints.
SubactId.Server audit-verify [checkpoint_id:root,...] walks every checkpoint in id
order and checks each one three ways:
- its signature against the published key set;
- its link to the checkpoint before it;
- the root it signed against the tree rebuilt from the records its range covers.
It reports the checkpoint and the kind of the first fault: a signature that does not verify, a checkpoint that does not follow on from the one before it, or records that no longer build the signed root. An edited, removed or inserted record shows up as the last kind. Sequence numbers may have holes, since a rolled-back append consumes one without writing a record, and the range covers those holes.
The walk alone cannot tell a cut tail from a short ledger. Each run therefore
prints its last checkpoint as checkpoint_id:root. Keep that value somewhere the
database cannot reach, and pass it to a later run: the command then also
confirms that checkpoint is still there, signing the same root.
| 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 |
Events: token.issued, token.denied, token.refreshed, token.revoked,
task.revoked, task.expired, agent.registered, agent.updated,
agent.deleted, sponsor.blocked, sponsor.unblocked, sponsor.signal,
signal.denied, scim.denied, ssf.denied, admin.denied and
audit.archived.
token.issued is also the record of a task’s creation. An exchange creates the
task and issues its first token in one transaction, so the first token.issued
for a task_id is that task’s creation. It carries the scope, audience and
delegation depth the task was created with, and its jti is the first token’s.
There is no task.created. To find when a task started, take the earliest
record for the task_id: the audit query’s task_id filter returns records
oldest first.
sponsor.signal records a signal accepted from outside and the reason it
carried. One task.revoked is written beside it per task ended. It names the
human the signal’s sub named. For a logout that named only a session, it names
the sponsor of the tasks it ended. A session logout that ended nothing names
nobody, because nothing about that session is known here but its name.
signal.denied records a signal that did not validate, and names nobody:
nothing in an unverified token is worth writing down. A flood of them collapses
to one record and a count, like any other unattributed denial.
A request refused by admission control is recorded under the event of the
surface it was refused on: signal.denied, scim.denied or ssf.denied for
the receivers, admin.denied for the admin API, and token.denied for the
rest. Its reason is rate_limited, or overloaded when the instance was at
capacity (section 8).
A signal from a Shared Signals transmitter names the person the same way and
carries ssf_sessions_revoked, ssf_account_disabled, ssf_account_purged or
ssf_account_enabled. ssf.denied records a push that carried no usable
credential, and names nobody.
A signal from a provisioning client names the person by their sponsor key and
carries scim_deactivated, scim_deleted or scim_reactivated as its reason.
A write that restates what already holds records nothing, because a directory
sync re-sends the state of everybody it knows about. scim.denied records a
request to that receiver that carried no usable credential, and names nobody.
sponsor.blocked and sponsor.unblocked name the human in sponsor, by the
key they were blocked under. Where the sponsor key claim is not sub, that is
not the value the sponsor filter of the audit query matches on other records.
The task.revoked records written alongside a block carry the subject, as every
other record does.
tool.called is reserved and not produced in v0.1. There is no endpoint to
report a call to, and no SDK sends one. A tool server records its own calls in
its own log.
task.expired is written once per task by the expiry sweeper when it marks the
task terminal and revokes its grants. Expiry is not a revocation and is never
written as one. task.revoked is written once per task in a revoked tree, with
parent_revoked as the reason on descendants. token.revoked is written when a
single token is revoked by jti through POST /oauth2/revoke.
7.3 Delivery to a sink
Section titled “7.3 Delivery to a sink”The ledger is the record. A sink gets a copy.
| Setting | Meaning |
|---|---|
SubactId:Audit:Sink:Url |
Where records are delivered. Without it, nothing is queued. |
SubactId:Audit:Sink:BearerToken |
Sent as a bearer token when set. The URL must then be https. |
SubactId:Audit:DrainBatchSize |
Records per request. Default 100. |
SubactId:Audit:DrainInterval |
Time between drains. Default 5 seconds. |
- Every append also queues the record in
audit_outbox, in the same transaction. - A drain on each instance posts queued records to the URL as
POSTwith body{"records": [...]}, each record in the shape above, in sequence order. - Any answer other than 2xx, or no answer within 10 seconds, is a failed delivery. Each record in the batch is marked with the error and tried again after a wait that doubles with that record’s failures, from one second to a cap of five minutes.
- The sink is not on the path of a token request. The endpoint only queues, so a slow, failing or absent sink changes nothing about what the endpoint answers.
- Entries are claimed with a skip-locked read for the length of the drain’s transaction, so any number of instances drain the one outbox and no record is delivered twice by design. A record is delivered at least once: a crash between the sink accepting and the delete committing repeats the delivery.
- A delivered entry is deleted in that same transaction, so
audit_outboxholds only what has still to be delivered, never a second copy of the ledger. - Requests from several instances, or a retried batch behind a newer one, may
arrive out of order. A sink treats
seqas the identity and orders by it.
7.4 Query
Section titled “7.4 Query”For example, every action any agent took on behalf of one person in September:
GET /audit?sponsor=f47ac10b-...&from=2026-09-01&to=2026-09-30Authorization: Bearer <SubactId:Admin:ApiKey>Filters, all optional, 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 above is all of September. Records
come back oldest first, limit (default 100, at most 1000) per page:
{ "records": [ { "seq": 10428, "ts": "2026-09-09T14:03:41.882Z", "event": "token.issued", "...": "..." } ], "next_cursor": "NjM5...", "archived_before": "2026-07-01T00:00:00.000Z"}Each record is the audit record above. next_cursor is opaque: pass it back as
cursor for the next page. It is null on the last page. A bad filter answers
400 with per-field errors.
archived_before is the earliest instant the online ledger still holds, and
null when nothing has been archived out of it. It is on every page. A caller
asking about a range older than this reads an empty page as “not here any
more”, not “nothing happened”. An archived range is an ordinary empty page,
never an error.
The endpoint requires the admin API key. A missing or wrong key answers 401
and writes admin.denied.
A page of one person’s month is one index range in page order, never a scan of the ledger:
- The
sponsorandagent_idfilters are served from indexes in(ts, seq)order under the sponsor and under the agent. On Postgres these index a four-byte fingerprint of each value, and the string itself is re-checked on the row, so the answer is exact. - A
decision=denyfilter is served from a partial index over the same order holding only denials. - A query with none of these filters is served from
(ts, seq).
7.5 Retention
Section titled “7.5 Retention”The ledger refuses DELETE and TRUNCATE, so no row can be removed on its own.
The table is partitioned by month, and a whole month can leave, because
detaching a partition is DDL rather than a delete. That is the only way anything
leaves.
Nothing leaves on its own. SubactId:Audit:Retention is unset unless an
operator sets it. Even then, it only supplies a cutoff to one explicit command:
SubactId.Server audit-archive --before <YYYY-MM> --to <directory>--before is the first month to keep. It may be left out when
SubactId:Audit:Retention is set, in which case the cutoff is the month holding
now - retention. It is refused if it is after the current month. For each
partition older than the cutoff, oldest first:
- Every record in it must be inside a checkpoint. Every checkpoint from the last archived one through the one sealing this month’s highest sequence number must verify: signature, and link to the checkpoint before it. A month that fails is not archived; investigate it.
- The records those checkpoints sealed are exported to
<directory>/audit-<YYYY-MM>.subactid-archive.gz, with the checkpoints themselves beside them. An export is never written over. - The export is read back. Every root in it is recomputed from the records it carries and compared with the checkpoints the ledger holds. An export that does not round-trip is deleted, and the run stops with nothing detached.
- An
audit.archivedrecord is written to the ledger. Itsdetailnames the partition, the checkpoint and sequence ranges, the export’s location and the export’s SHA-256. The next checkpoint seals this record, so the digest of what left is anchored in what stays. - The partition is detached and dropped, once it is certain to hold nothing above the export’s last sequence number. A record appended into the month after step 1 read it, by a slow commit or an instance whose clock is behind, is in no export. A partition holding one is put back, and the run stops with the record still online.
A checkpoint whose range straddles the boundary, sealing records both in the month that is leaving and in the month that is staying, is archived whole. An export can therefore carry a few records that are also still online. Nothing is ever dropped that is not in an export.
The export is gzip-compressed text: a header, then the checkpoints, then the
records, one per line, tab-separated. A null field is \N. The backslash and
the six control characters are written as \\, \b, \f, \n, \r, \t and
\v. This is the form a Postgres COPY … TO STDOUT produces, so exporting a
month costs one sequential read. Timestamps in it are UTC with six fractional
digits, which is what the ledger stores. A record’s canonical JSON still uses
three, so a leaf rebuilt from an export is the leaf that was sealed. An export
compresses to about 43 bytes a record.
Verification after a month has gone. The checkpoints stay in
audit_checkpoints, because they are the seal of the export. The chain of
checkpoints is unbroken across the boundary: the first online checkpoint’s
prev_checkpoint_hash names the last archived one.
audit-verifywalks the online ledger from that floor, and checks the boundary link on the way in.audit-verify --archive <export>verifies that export on its own against the published key set, and reads nothing from the database.- To verify everything, verify each export and then the online ledger.
- A
checkpoint_id:rootprinted by a run before a month left still confirms after it. The checkpoint it names is below the floor, but checkpoints never leave the table, so it is read where it stands and must still sign the same root.
Cold in place. To keep a month queryable in SQL without paying for it, an
operator can detach the partition and drop its query indexes without dropping
the table. The month stays readable by name at roughly half the bytes per row,
and /audit no longer reaches it. This is an operator’s option, not a command.
8. Errors
Section titled “8. Errors”Standard OAuth error bodies. The ones that matter:
| Error | When |
|---|---|
invalid_grant |
subject token expired, invalid, from an untrusted issuer, or carrying no usable value for the sponsor key claim |
invalid_scope |
scope intersection empty, or a widening attempt on refresh |
invalid_target |
audience not in allowed_audiences |
access_denied |
agent disabled, task revoked, sponsor no longer one the control plane will act for, too little of the task left to issue a token for, or delegation depth exceeded. On an exchange it means no task was created, not that one ended |
invalid_client |
client authentication failed: no assertion, a bad signature, an unknown agent, a replayed jti; answered as 401 |
invalid_request |
the request is missing a parameter, repeats one, or carries one outside its limits |
temporarily_unavailable |
the decision could not be made now: an upstream it depends on could not be reached (the identity provider’s keys, or, under the polling mode, its answer about the sponsor), the control plane’s own database could not answer, or the instance is at capacity. Answered as 503, with Retry-After in the last two cases. Never read as a yes |
slow_down |
too many requests from this source; answered as 429 with Retry-After |
slow_down is admission control, not an authorization decision. It is answered
before the request is read, so it names nothing about the caller. It is recorded
as a summary (section 7.2) under the reason rate_limited.
- The limit is per source and per instance. Introspection and the signal
receivers have limits of their own. The settings are deployment configuration,
listed in
docs/configuration.md. - Behind a proxy, the source is taken from
X-Forwarded-Foronly when the proxy is a configured trusted network. Otherwise every caller counts as the proxy. - Liveness and readiness are never limited.
temporarily_unavailable at capacity is also admission control. Requests are
limited separately for the token endpoint, for introspection, for the paths that
take access away (revocation, back-channel logout, SCIM, Shared Signals and the
admin API), and for everything else. In each group:
- at most
SubactId:Overload:ConcurrencyLimitrequests run at once; - at most
SubactId:Overload:QueueLimitwait, none longer thanSubactId:Overload:QueueTimeout; - the rest are answered before any work is done on them, and recorded as a
summary under the reason
overloaded.
A form body is read before its request waits for a turn, so a slow sender holds only its own connection. A caller that leaves while waiting is never served.
When the control plane’s own database cannot be reached, or cannot answer in
time, a request that needs it is answered temporarily_unavailable too. No
record of that answer can be written, since the ledger is what could not be
reached. No token is handed out without its record, since issuing a token and
recording it are one transaction. The one edge case is a commit whose
acknowledgement is lost on the way back: the token is recorded, and the caller
never receives it.
Always include a human-readable error_description. Every denial is written to
the audit ledger with decision: "deny" and a machine-readable reason.
Denials must never be dropped.
9. What a tool server must do
Section titled “9. What a tool server must do”The SDK middleware (not released yet) will do all of this. The contract:
- Fetch and cache JWKS from the control plane.
- Validate signature,
iss,aud,exp. - Enforce required scope for the route.
- If the route is high-risk, introspect instead of validating locally.
- Log
sub(the human) andact.sub(the agent) on every request. - Reject any token whose
actchain is deeper than the server’s own limit.
© 2026 Nikola Živković PR Agencija za programerske usluge Novi Sad. Subact ID is its product.