Agent capability scoping
The per-agent capability registry sits above the tier-based attribution policy. Where the attribution policy asks "is this write attributable at all?", the capability registry asks "is this specific agent allowed to touch this specific entity_type via this operation?".
Capabilities are modelled as first-class agent_grant entities, one per (user, agent identity) pair, managed in the Inspector under Agents → Agent grants. The previous environment-variable registry (NEOTOMA_AGENT_CAPABILITIES_JSON, NEOTOMA_AGENT_CAPABILITIES_FILE, NEOTOMA_AGENT_CAPABILITIES_ENFORCE, config/agent_capabilities.default.json) has been removed.
When does this apply?
- User-authenticated callers (Bearer / OAuth / local Inspector session), not enforced; full access to their own user_id's data, modulo attribution policy.
- AAuth-verified agent matched to an
activegrant , enforced; restricted to declared(op, entity_type)pairs on the grant. - AAuth-verified agent with no matching grant , falls through to attribution-only behaviour (no admission, must use Bearer/OAuth).
- Anonymous / unverified-client tier , no admission; subject to attribution policy.
The canonical use is pinning the Netlify forwarder (sub: agent-site@neotoma.io) to the neotoma_feedback entity type, so a compromised forwarder key cannot be used to write observations for unrelated entities.
Grant shape
An agent_grant is a normal Neotoma entity, observation history doubles as the audit log. Canonical fields:
{
"entity_type": "agent_grant",
"owner_user_id": "usr_…",
"label": "Cursor on macbook-pro",
"match_sub": "agent-cursor@example.com", // AAuth sub claim
"match_iss": "https://agent.example.com", // optional; both must match when set
"match_thumbprint": "abcd…", // optional RFC 7638 JWK thumbprint
"capabilities": [
{ "op": "store_structured", "entity_types": ["neotoma_feedback"] },
{ "op": "create_relationship", "entity_types": ["neotoma_feedback"] },
{ "op": "correct", "entity_types": ["neotoma_feedback"] },
{ "op": "retrieve", "entity_types": ["neotoma_feedback"] }
],
"status": "active", // active | suspended | revoked
"notes": "issued 2026-04",
"last_used_at": "2026-04-26T09:54:00Z"
}Identity rule
At least one of match_sub or match_thumbprint MUST be set; match_iss is optional but, when set, BOTH match_sub AND match_iss MUST match the verified identity for the grant to admit.
Capability ops
store_structured, creating / observing entities (write path).create_relationship, creating relationships between entities.correct, correcting / updating existing observations / fields.retrieve, reading entities and observations.
entity_types is a string array of permitted entity types for that op. Use ["*"] to widen to every type, only do this for trusted grants.
Matching order
Admission resolves the verified identity to at most one grant:
- If the request carries a JWK thumbprint AND any of the user's grants has a matching
match_thumbprint, that grant wins. - Otherwise, the first
activegrant whosematch_subequals the request'ssuband (when set on the grant) whosematch_issequals the request'siss. - Otherwise, no admission, the request stays attribution-only.
Status lifecycle
active ⇄ suspended
│ │
▼ ▼
revoked (terminal in normal flow)
│
▼ restore (within grace window)
activeOnly the user who owns the grant (or an agent the user has authorised with the bootstrap (store_structured | correct, agent_grant) capability) can flip status. Admission caches the resolved grant for a small TTL plus invalidates on observation events, so a revoke propagates to in-flight clients within seconds.
Protected entity types, the trust mechanism
Writes to agent_grant (and any future protected type) are gated by the protected-entity-types guard:
- User-authenticated callers (Bearer / OAuth / local Inspector session for the same user) pass through.
- AAuth-admitted callers must hold an explicit capability in their grant for the protected type. The bootstrap capability is
{ op: "store_structured", entity_types: ["agent_grant"] }(andcorrect). - Anonymous / unverified-client tier writes to protected types are rejected with
capability_denied.
This is what lets a user safely delegate grant management to a trusted agent: only that one grant carries the bootstrap capability; every other grant remains locked out of agent_grant writes by the protected-types guard, even if it has otherwise broad capabilities.
Strict-require AAuth for claimed subjects
Set NEOTOMA_STRICT_AAUTH_SUBS to a comma-separated list of agent subjects that MUST present a valid AAuth signature whenever the request claims that identity via the X-Agent-Label header. This is a second line of defence against a compromised tunnel / edge:
X-Agent-Label: agent-site@neotoma.io+ missing signature → 401.X-Agent-Label: agent-site@neotoma.io+ signature verified, but thesubclaim is something else → 401.- Any label NOT listed in
NEOTOMA_STRICT_AAUTH_SUBSbehaves as before (best-effort attribution hint).
Error surface
A denial produces HTTP 403 with:
{
"error": {
"code": "capability_denied",
"message": "Agent \"agent-site@neotoma.io\" is not permitted to store_structured entity_type \"person\".",
"op": "store_structured",
"entity_type": "person",
"agent_label": "agent-site@neotoma.io",
"hint": "Agent \"agent-site@neotoma.io\" holds an active grant but no \"store_structured\" capability for entity_type \"person\". Edit the grant in Inspector → Agents → Agent grants if intentional."
}
}Operator runbook
Upgrading from the env-config era
The previous release loaded capabilities from NEOTOMA_AGENT_CAPABILITIES_JSON / _FILE / config/agent_capabilities.default.json. After upgrading, starting the server with any of those variables set fails fast with a structured error linking to the import command.
Migrate once, per deployment:
neotoma agents grants import --owner-user-id <usr_…> \ [--file path/to/agent_capabilities.json]
--owner-user-iddecides which user account owns the imported operational grants. Pick the operator's own user account, or a dedicated account for infrastructure agents.- The command is idempotent on
(match_sub, match_iss, match_thumbprint), re-running it after a partial migration upserts grants without duplicating. - Each created/updated grant is stamped with provenance
import_source: "env_config"so the audit timeline clearly records the migration origin. - Once the import succeeds, unset the legacy variables and redeploy.
Grant a new scope
- In Inspector, go to Agents → Agent grants → New grant.
- Paste the agent's AAuth
sub(andiss, or thumbprint) and a readable label. - Select capabilities by
(op, entity_type). - Save. Admission picks up the new grant within the cache TTL.
Revoke or suspend a scope
- Open the grant in Inspector → Agents → Agent grants → :id.
- Click Suspend (reversible) or Revoke (terminal).
- The next request from that agent reverts to attribution-only after the admission cache TTL.
Roll back a botched grant edit
Grant edits are observations, open the grant detail view and use the audit timeline to see what changed. Apply a correct to restore the prior values (or use the Restore action to roll back a recent revoke within the grace window).
Back to AAuth overview. See also integration, AAuth spec, attestation, CLI keys.