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 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.


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.


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_required means no token is ever issued without a human subject token. In v0.1 it must be true. A registration with false is rejected, because every exchange requires a subject token.

  • allowed_scopes and allowed_audiences are the operator’s ceiling. The issued scope is the intersection of allowed_scopes, the human’s own scopes and the request (section 3). An audience outside allowed_audiences is invalid_target. Both must name at least one entry. A registration leaving either empty is rejected per field. high_risk_audiences may be empty, and is by default.

  • max_task_ttl is the lifetime of the whole task. max_token_ttl is 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:DefaultTaskTtl and SubactId: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, MinTokenTtl and MaxTokenTtl. Use these bounds to hold every agent to something shorter.

  • max_delegation_depth is the longest act chain a token issued to this agent may carry. Any value from 1 to 5 is accepted. In v0.1 the control plane issues depth: 1 only (section 4), so the value does not change what is issued.

  • high_risk_audiences names 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 carries introspect_required: true (section 4), so the tool server learns the requirement from the token and needs no configuration of its own.

  • jwks_uri names 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 as jwks, an RFC 7517 key set with public members only. The control plane then serves them at GET /agents/{agent_id}/jwks.json and fetches nothing. That route serves inline keys only, so a jwks_uri pointing 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.


The main endpoint. Standard RFC 8693.

POST /oauth2/token
Content-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:comment

Effective scope is the intersection of three sets:

effective = user_scopes ∩ agent.allowed_scopes ∩ requested_scopes

An 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.

{
"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.


{
"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.

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.


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:

  1. The grant exists.
  2. The task is not revoked.
  3. task.exp has not passed.
  4. task.exp is far enough off to be worth a token.
  5. The sponsor may still be acted for.
  6. The requested scope ⊆ the previously granted scope.
  7. 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 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_unavailable at 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.


POST /oauth2/revoke # RFC 7009, revokes one token or a task grant
DELETE /admin/tasks/{id} # kills the task and every token under it
DELETE /admin/agents/{id}/tasks # kills every live task for an agent
DELETE /admin/sponsors/{sponsor_key}/tasks # kills every live task acting for one human

POST /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 404
PUT /admin/sponsors/{sponsor_key}/block # refuse them, and end what is running
DELETE /admin/sponsors/{sponsor_key}/block # lift a block an operator placed

A 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. DELETE by the wrong source is 409, 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_at and revocation_reason only 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.

POST /backchannel-logout
Content-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;
  • iss equal to the upstream issuer;
  • aud containing the configured client;
  • a recent iat;
  • an exp honoured when present;
  • an events claim carrying http://schemas.openid.net/event/backchannel-logout;
  • at least one of sub and sid;
  • no nonce;
  • a jti that 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.

  • 200 when the logout was acted on, including when the person or session had nothing running.
  • 400 with an OAuth error body when the token does not validate. The body does not say which check failed.
  • 503 with temporarily_unavailable when 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.

POST /scim/v2/Users
GET /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/ServiceProviderConfig

RFC 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.

POST /events
Content-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:

  • 202 with an empty body when the event was acted on.
  • 400 with RFC 8935’s err and a description naming 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.
  • 401 with authentication_failed and nothing more, for a missing or wrong credential.
  • 503 when 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.


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 count and detail (below);
  • the timestamp as UTC ISO 8601 with exactly three fractional digits;
  • the decision in lowercase;
  • without seq and checkpoint, 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, \f and \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.

{
"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.

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 null checkpoint is 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 404 too.
  • An audit_path is 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 500 rather than a path that folds to nothing. audit-verify says 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.

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.refreshed for 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.expired from the sweeper, or task.revoked however it was revoked), one token.refreshed summary carrying count, 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:

  1. its signature against the published key set;
  2. its link to the checkpoint before it;
  3. 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.

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 POST with 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_outbox holds 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 seq as the identity and orders by it.

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-30
Authorization: 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 sponsor and agent_id filters 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=deny filter 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).

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:

  1. 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.
  2. 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.
  3. 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.
  4. An audit.archived record is written to the ledger. Its detail names 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.
  5. 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-verify walks 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:root printed 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.


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-For only 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:ConcurrencyLimit requests run at once;
  • at most SubactId:Overload:QueueLimit wait, none longer than SubactId: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.


The SDK middleware (not released yet) will do all of this. The contract:

  1. Fetch and cache JWKS from the control plane.
  2. Validate signature, iss, aud, exp.
  3. Enforce required scope for the route.
  4. If the route is high-risk, introspect instead of validating locally.
  5. Log sub (the human) and act.sub (the agent) on every request.
  6. Reject any token whose act chain is deeper than the server’s own limit.
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