Neotoma

AAuth integration guide

End-to-end wiring guide for MCP client authors, local-proxy authors, and operators who need to wire a new agent into Neotoma and confirm the attribution tier their writes land with. For the wire format and trust-tier definitions see the AAuth spec; for cryptographic envelopes see attestation; for CLI-side keygen see CLI keys.

1. Wire format

Neotoma identifies a writing agent via two complementary channels, stamped into every durable row (observations, relationships, sources, interpretations, timeline events):

  1. AAuth (RFC 9421 HTTP Message Signatures + AAuth profile). Caller signs the request and sends Signature, Signature-Input (MUST cover @authority, @method, @target-uri, content-digest when there is a body, and the signature-key header itself), and Signature-Key (the agent's JWK plus an agent-token JWT with typ: "aa-agent+jwt" carrying stable sub and iss claims). Neotoma verifies against the canonical authority from NEOTOMA_AUTH_AUTHORITY; using the Host header is explicitly unsafe.
  2. MCP clientInfo fallback. On initialize the MCP transport self-reports { name, version }. Self-reported; subject to generic-name normalisation.

Both channels contribute to a single AgentIdentity record persisted on every write.

2. Fallback precedence

For each request Neotoma resolves an AgentIdentity by walking these inputs in order; the first populated field at each layer wins:

AAuth (verified signature + JWT)   →  agent_thumbprint, agent_sub, agent_iss,
                                      agent_algorithm, agent_public_key
        │
        ▼
clientInfo.name + version          →  client_name, client_version
        │
        ▼
OAuth connection id                →  connection_id
        │
        ▼
(nothing)                          →  anonymous

The resulting trust tier is derived once per request. After v0.8.0 the cascade is attestation-aware:

  • hardware , AAuth verified AND the JWT carries a verified cnf.attestation envelope AND, in v0.12.0+, the bound key has not been revoked.
  • operator_attested , AAuth verified AND iss (or iss:sub) is in NEOTOMA_OPERATOR_ATTESTED_ISSUERS / NEOTOMA_OPERATOR_ATTESTED_SUBS.
  • software , AAuth verified but no attestation envelope (or attestation failed and operator allowlist did not match), regardless of algorithm.
  • unverified_client , No AAuth, but clientInfo.name survived normalisation.
  • anonymous , Nothing else. client_info may have been too generic.

3. Generic-name normalisation

Self-reported client names go through normaliseClientNameWithReason(). Names rejected as attribution surface a reason code on the /session response:

  • not_a_string, caller sent a non-string value.
  • empty, empty or whitespace-only string.
  • too_generic, name matches the generic-names blocklist (e.g. mcp, client, anonymous).

4. Verifying your session

4a. HTTP GET /session

Before enabling writes, a local proxy or CLI integrator should call GET /session with the same headers they intend to use for writes. The endpoint is read-only and is safe to poll.

curl -sS \
  -H "Signature: …" \
  -H "Signature-Input: …" \
  -H "Signature-Key: …" \
  "https://neotoma.example/session" | jq

Expected shape:

{
  "user_id": "usr_…",
  "attribution": {
    "tier": "software",
    "agent_thumbprint": "…",
    "agent_sub": "agent:…",
    "agent_iss": "https://agent.neotoma.example",
    "agent_algorithm": "RS256",
    "client_name": "my-proxy",
    "client_version": "0.3.1",
    "decision": {
      "signature_present": true,
      "signature_verified": true,
      "resolved_tier": "software"
    }
  },
  "aauth": {
    "verified": true,
    "admitted": true,
    "grant_id": "ent_…",
    "admission_reason": "admitted",
    "agent_label": "Cursor on macbook-pro"
  },
  "policy": { "anonymous_writes": "allow" },
  "eligible_for_trusted_writes": true
}

Integrator preflight rule: a healthy signed client should see attribution.tier === "hardware" or "software", attribution.decision.signature_verified === true, and eligible_for_trusted_writes === true.

aauth.verifiedaauth.admitted on purpose. Verified means the signature checked out. Admitted means Neotoma matched that signature to one of the user's agent_grant entities and is treating the caller as authenticated without OAuth/Bearer. A verified-but-unmatched signature stays attribution-only, the caller can still write under existing Bearer/OAuth flows but cannot use AAuth alone for admission. The admission_reason field reports the resolver outcome:

  • admitted, active grant matched. AAuth alone is sufficient on this request.
  • no_grants_for_user, the owner user has no grants at all yet (Inspector → Agent grants → New).
  • no_match, this identity does not match any of the user's grants.
  • grant_revoked / grant_suspended, identity matched a grant whose status is revoked or suspended.
  • strict_rejected, strict-AAuth gating rejected the signature before admission ran.
  • aauth_disabled, this deployment has AAuth disabled; admission did not run.
  • not_signed, no AAuth signature was presented; only attribution-only paths are open.

If signature_verified === false, inspect attribution.decision.signature_error_code, it mirrors the diagnostic log line 1:1.

4b. MCP tool get_session_identity

Same payload, reachable over the MCP transport, useful for clients that don't want a second HTTP round-trip:

{
  "method": "tools/call",
  "params": { "name": "get_session_identity", "arguments": {} }
}

4c. CLI

neotoma auth session        # JSON
neotoma auth session --text # Human-readable summary

5. Policy knobs

The Neotoma server publishes its active attribution policy on the /session response under policy:

  • anonymous_writes, controlled by NEOTOMA_ATTRIBUTION_POLICY=allow|warn|reject; default allow.
  • min_tier, controlled by NEOTOMA_MIN_ATTRIBUTION_TIER=hardware|software|unverified_client; default unset.
  • per_path, controlled by NEOTOMA_ATTRIBUTION_POLICY_JSON={"observations": "reject", …}; default unset.

Per-path overrides accept any of the canonical write paths: observations, relationships, sources, interpretations, timeline_events, corrections. Missing paths inherit anonymous_writes.

When a write is rejected by policy the server returns HTTP 403 with:

{
  "error": {
    "code": "ATTRIBUTION_REQUIRED",
    "min_tier": "software",
    "current_tier": "anonymous",
    "hint": "Sign requests with AAuth or set NEOTOMA_ATTRIBUTION_POLICY=allow"
  }
}

warn mode accepts the write, adds an X-Neotoma-Attribution-Warning response header, and emits a structured log event. allow is silent. For per-agent capability scoping (grants), see capabilities.

6. Diagnostics

The Phase 2 diagnostic log line is the single source of truth for attribution resolution decisions. Every request that hits the AAuth middleware emits exactly one:

// DEBUG
{
  "event": "attribution_decision",
  "signature_present": true,
  "signature_verified": false,
  "signature_error_code": "jwt_expired",
  "resolved_tier": "anonymous"
}

Stable fields:

  • event, always "attribution_decision".
  • signature_present, caller sent any of Signature, Signature-Input, or Signature-Key.
  • signature_verified, signature + JWT both valid.
  • signature_error_code, short code when signature_verified is false. Examples: signature_invalid, jwt_expired, jwt_invalid, verification_threw.
  • client_info_raw_name, added on the /session response when the caller sent a non-empty clientInfo name.
  • client_info_normalised_to_null_reason, too_generic / empty / not_a_string when the raw name was dropped.
  • resolved_tier, final tier after merging AAuth + clientInfo.

Safety invariants (must hold at INFO and above): public keys, agent tokens, and signature bytes MUST NEVER appear in logs at INFO or higher. agent_thumbprint is safe to log at any level.

Common integrator failures

  • signature_verified: false, signature_error_code: "signature_invalid", wrong @authority; body hashing misaligned; content-digest not covered.
  • signature_error_code: "jwt_expired", agent token past its exp. Refresh the token.
  • signature_error_code: "verification_threw", unreachable JWKS URL or malformed headers. Check network + header casing.
  • tier: "anonymous", no signature headers, caller forgot to sign; OR Host header rewriting stripped @authority.
  • tier: "unverified_client" but expected software/hardware, AAuth not wired; clientInfo.name survived but signature is missing.
  • client_info_normalised_to_null_reason: "too_generic" , clientInfo.name is in the blocklist (mcp, client, …). Pick a distinctive name.

7. Transport parity

Attribution is threaded uniformly across every transport that reaches Neotoma's write-path services. The single enforcement seam is enforceAttributionPolicy(path, identity) inside each service, what changes per transport is only how the identity gets into the per-request AsyncLocalStorage context that the services read from.

  • HTTP /mcp , aauthVerify global middleware; attributionContext + a nested runWithRequestContext inside the /mcp handler with the server-resolved identity.
  • HTTP direct routes (/store, /correct, /observations/create, /create_relationship, …), aauthVerify global middleware; attributionContext middleware (globally applied in src/actions.ts).
  • HTTP GET /session , aauthVerify global middleware; read-only; resolves identity inline for response assembly.
  • MCP stdio, no AAuth verification (stdio has no HTTP layer); NeotomaServer.setSessionAgentIdentity + sessionClientInfo assembled in InitializeRequestSchema; propagated by runWithRequestContext inside CallToolRequestSchema dispatch.
  • CLI-over-MCP (executeToolForCli) , same wrap as stdio.
  • CLI over HTTP (createApiClient) , aauthVerify global middleware on target; signs outbound requests with ~/.neotoma/aauth/ keypair when configured (see CLI keys); middleware stamps the same decision as any other HTTP caller.

HTTP fallback headers

Non-MCP HTTP callers (the CLI in particular) can self-report a clientInfo-equivalent value through two optional headers:

  • X-Client-Name → fallback attribution client_name.
  • X-Client-Version → fallback attribution client_version.

Both go through the same generic-name normalisation as MCP's initialize.clientInfo handshake, so a value of mcp or client is still dropped to anonymous.

CLI signer

The CLI ships an optional AAuth signer so neotoma … commands can land as hardware / software tier instead of anonymous:

neotoma auth keygen           # Generate ES256 keypair under ~/.neotoma/aauth/
neotoma auth session          # Inspect resolved tier + signer configuration
neotoma auth sign-example     # Print a debugging curl with a signed JWT

When a keypair is present at ~/.neotoma/aauth/private.jwk, createApiClient transparently signs outbound requests via @hellocoop/httpsig. Signing is silently skipped (no error) when no keypair is configured so existing CLI users are unaffected. Hardware-backed keygen is on CLI keys.

Back to AAuth overview. See also AAuth spec, attestation, CLI keys, capabilities.