Checked Identity Carrier Contract (GOC-15)¶
CONCEPT:AU-OS.identity.verified-carrier-contract
Status: contract freeze (GOC-15-W02) for the carrier that already ships in production. This document is the canonical reference GOC-17–23 (and any other lane minting or consuming caller identity — GOC-85's remote MCP broker in particular) must build against. It corrects two false premises that circulated in planning before this lane checked
main; see Premise corrections before designing anything against this contract.Relationship to Graph Authority Convergence: that document is the narrative architecture of the
GraphSession/eg2boundary (why it exists, how minting/routing/work-state relate). This document is its field-level companion — the exact claim keys, their required/optional status, per-surface carrier coverage, and the explicit consumer handoff. Read Convergence for the "why", this for the "exactly what bytes." Neither supersedes the other; extend both together when the carrier's fields or surface coverage change.
Premise corrections (read first)¶
- There is no
CommitDescriptorV1onmain. A prior planning note claimed a sibling lane had landedcrates/eg-types/src/commit_descriptor.rswith atenant_ref/principal_ref/authority_ref/authority_epochshape. That file and that type do not exist anywhere inepistemic-graphas of this lane's verification (2026-08-16,epistemic-graph@02594f7). Do not build against it. What DOES exist, and IS the real identity carrier, is documented below. - The JSON-Schema
RequestContextmodel was dead code, not the carrier.agent_utilities/protocols/epistemic_operations/schemas/v1/request-context.schema.json($id: urn:epistemic-operations:v2:request-context,schema_versionconst"2") generated a pydanticRequestContextclass (agent_utilities/protocols/epistemic_operations/_generated.py). It had zero live callers anywhere inagent-utilitiesoutside its own test — grep confirmed no production code ever constructed ormodel_validated aRequestContext. Promoting this schema to "v3" (as an earlier lane plan proposed) would have formalized a projection nothing used, while the actual wire carrier (below) stayed undocumented. Retiring the second projection deleted the model, its catalog, and the generator entirely rather than wiring it into a live path — see What this lane did NOT do.
The carrier that is actually live today¶
Two checked, already-fail-closed, already-tested primitives, one per language, connected by one wire dict. This lane does not invent a third.
AU: ActorContext → GraphSession → GraphSession.engine_verified_context()
│ (Python dict, MessagePack over eg2.)
▼
EG: RequestContextClaims → server::auth::VerifiedRequestContext (post-verify)
│
▼
server::access::CarrierAuthority
(tenant_scope / actor_scope / owner_scope / admin)
- AU identity primitive:
agent_utilities.security.brain_context.ActorContext(security/brain_context.py) — minted only from validated credentials (actor_from_claims,security/request_identity.py). - AU session/authority primitive:
agent_utilities.knowledge_graph.core.session.GraphSession(knowledge_graph/core/session.py) — the one explicit currency (actor, tenant, scopes, graph, policy_version, audience, trace_context). Its__post_init__already fails closed on an unauthenticated actor, an emptyactor_id, an empty tenant, ortenant != actor.tenant_id(SessionRequiredError) — proven bytests/unit/test_graph_session.py::test_session_construction_rejects_unverified_or_mismatched_authority, parametrized over unauthenticated / empty-actor / empty-tenant / cross-tenant / empty-session-tenant. - The wire projection (the actual carrier bytes):
GraphSession.engine_verified_context()returns exactly the dict below, which the nativeepistemic_graphclient packs into theeg2.MsgPack envelope. This dict shape — not the JSON Schema — is the operative carrier. - EG identity primitive:
crates/eg-types/src/acl.rs::RequestContextClaims— the wire representation, untrusted until checked. - EG checked/authoritative primitive:
server::auth::VerifiedRequestContext(non-constructible outside the module; only produced after MAC + audience + tenant + policy-version + replay-nonce verification) →server::access::CarrierAuthority::from_verified(), which derivestenant_scope/actor_scope/owner_scope()(the actual per-agent isolation key —tenant_scope + actor_scope, opaque-hashed) /admin.
Canonical field table (identity/claims layer)¶
These are the fields GraphSession.engine_verified_context() emits and
RequestContextClaims deserializes (#[serde(deny_unknown_fields)] — an
unrecognized field on either side is a hard reject, not a soft ignore). This
is the field-level contract every consumer of this lane must match.
| Field | Type | Required | Notes |
|---|---|---|---|
principal |
string | always | Authenticated subject. AU sets it to actor.actor_id (the checked JWT sub/client_id/azp). Hashed by the engine before persisting ChangeEnvelope provenance — never logged raw. |
tenant |
string | always | Tenant security boundary. Must equal GraphSession.actor.tenant_id; EG separately compares it against the deployment's own configured tenant on auxiliary surfaces (Iceberg — see below). |
audience |
string | always | Server-validated intended audience (AUTH_JWT_AUDIENCE/MCP_JWT_AUDIENCE), never accepted from a request payload. |
agent_id |
string | always | Effective ACL/RBAC subject. Equals principal unless a spawn is running under an ENFORCED delegation (see Delegation below), in which case it is the per-run agent-instance id. |
roles |
string[] | always (may be empty) | IdP-agnostic capability set (ActorContext.roles), sorted, deduped. |
scopes |
string[] | always (may be empty) | GraphSession.scopes, restricted to the hierarchical kg:read/kg:write/kg:admin set with kg:admin ⊇ kg:write ⊇ kg:read expansion applied once at mint time (_mint_graph_session). |
delegation |
string[] | always (may be []) |
Ordered chain from principal to agent_id inclusive. Empty ⇒ no delegation, requires principal == agent_id. Only emitted non-empty when ENABLE_DELEGATED_IDENTITY=on AND the ambient SpawnDelegation.principal matches this session's own principal — a spawn can never forge a chain for a principal it does not run under (security/delegation.py, session.py::_apply_spawn_delegation). |
policy_version |
string | always | Exact policy bundle/version this authority was minted under (KG_POLICY_VERSION). |
node |
string | absent | optional | ADR-3/W1.9 node-bound envelope target. #[serde(default)]; omitted claim ≠ error on either side. |
priority |
string | absent | optional | Advisory QoS class (W2.4), one of interactive/orchestration/hydration/background_ingestion; MAC-covered so it cannot be forged to jump the admission queue. Omitted entirely for an untagged caller (byte-identical to a pre-W2.4 envelope). |
oidc_token |
string | absent | optional | RFC 8693 exchanged token, present only under an enforced SpawnDelegation that carries one. Not MAC-covered — its own signature is its trust anchor; the engine independently RSA/JWKS-checks it and cross-checks subject/tenant against the SAME context (server::auth::bind_verified_identity). |
Explicitly NOT part of this layer (a deliberate separation, not a gap):
request_id, trace_id, issued_at/expires_at, schema_version. Replay/
idempotency identity lives on MutationBatch/ChangeEnvelope
(envelope_id, idempotency_key, submitted_at_ms) — a different
currency with its own dedup/replay semantics — and trace correlation stays in
GraphSession.trace_context, which is attached to observability spans, not
sent to the engine as an identity claim. A consumer needing correlated
request/trace ids for a new surface (SPARQL, federation, observability
exporters) should propagate GraphSession.trace_context alongside — not
inside — the claims dict above; do not add these fields to
RequestContextClaims without a corresponding EG wire-compat sweep (adding a
field to a deny_unknown_fields struct is a breaking deploy-order change,
exactly like W2.4's priority rollout note already documents in
session.py::engine_verified_context's docstring).
Per-surface carrier status (checked against main)¶
| Surface | Carrier today | Per-caller principal? | Reject-bad proof |
|---|---|---|---|
eg2. primary protocol (REST/WS/SSE/MCP → engine) |
RequestContextClaims above, MAC + audience + tenant + policy + replay-nonce checked (server/auth.rs) |
Yes | tests/unit/test_graph_session.py (AU side, cross-tenant/unauthenticated); EG server/auth.rs unit suite (MAC/replay/audience) |
| Iceberg REST catalog | authenticated_iceberg_bearer — projects an already OIDC-checked bearer's own subject/tenant claim; tenant claim required (not merely compared-if-present) and rejected on mismatch against EPISTEMIC_GRAPH_TENANT (src/server/auth.rs:334) |
Yes (per-subject, since GOC-222) | Already a full negative matrix: src/server/auth.rs mod iceberg_bearer_carrier — different_tenant_mints_no_carrier_and_is_denied, missing_tenant_claim_mints_no_carrier, no_verified_claims_mints_no_carrier, empty_subject_mints_no_carrier, distinct_subjects_bind_distinct_non_admin_principals. No further work needed here — do not re-implement. |
S3 SigV4 / KV-cache bearer / /sparql SELECT-Build-ASK bearer |
mint_fixed_service_carrier — ONE fixed service:<name> principal for every caller who passes that surface's own protocol-native check |
No — deliberately: "none of these protocols carries a distinguishable per-caller principal" (doc comment, server/auth.rs) |
N/A by design — this is a real architectural decision already made, not an oversight. If GOC-18/19/20 (SPARQL/federation) need per-caller isolation, that requires adding an OIDC-bearer leg to those surfaces mirroring authenticated_iceberg_bearer — same shape, new call site. That is future work this lane scopes but does not implement (see below). |
| Native SQL wire (pgwire/mysql-wire/mssql-wire) | authenticated_sql_wire_actor — HMAC-derived opaque principal per (protocol, agent_id) after SCRAM/HMAC password proof |
Yes | Covered by that module's own SCRAM/HMAC test suite (not re-audited by this lane; out of the listed W01 surface set) |
| WebUI browser boundary | See below | Yes (the signed-in human's own token) | See below |
| Observability exports | Not yet instrumented with a carrier at all (W01 gap, confirmed) | No | Out of scope for this lane's implementation pass; flagged for GOC-15-W06 follow-on |
WebUI browser credential boundary (checked)¶
agent-webui's oidc_session.py (agent/agent_webui/oidc_session.py) is a
pure-ASGI middleware mounted outside
agent_utilities.security.request_identity.ActorIdentityMiddleware. Checked
mechanism, reading the live code (not the module docstring alone):
- The authorization-code exchange runs server-side with a confidential
client (
WEBUI_OIDC_CLIENT_ID/WEBUI_OIDC_CLIENT_SECRET) — the client secret never reaches the browser. - The resulting
access_tokenis stored in a Fernet-sealed session cookie (WEBUI_SESSION_KEY, 32-byte urlsafe-base64 key) — chunked across_chunk_name(index)cookies,secure, cleared on logout. The browser holds only the encrypted blob; it cannot read or replay the token content itself. - Per request, the middleware decrypts server-side and re-attaches
Authorization: Bearer {token}(oidc_session.py:704) before handing the request down to the unmodifiedActorIdentityMiddleware, which validates it via the same JWKS path as any other bearer caller — no parallel identity primitive, no widened authority. - The carried token's
sub/email/realm_access.rolesare the signed-in human's own — the WebUI backend never elevates or acts "on behalf of" with a shared privileged credential.
This satisfies the lane's "no data-plane surface may infer identity from an untrusted... browser credential" invariant already: the browser's own credential is opaque (sealed) and the identity actually used downstream is independently re-checked server-side on every request, not trusted from the cookie's mere presence. No code change was required here; this section is the recorded proof.
One doc-drift correction while checking this surface:
docs/architecture/identity-inheritance.md's "Deferred / roadmap" section
(unchanged since commit 3e13feeec) still lists "graph-os on-behalf-of
token exchange in execute_agent" — RFC 8693 delegation carrying the
original caller's identity to downstream calls — as not yet implemented.
It has been implemented since commit df3b69b90
(feat(identity): per-agent on-behalf-of delegation — connect the three
primitives (W2.1)): security/delegation.py (SpawnDelegation,
ENABLE_DELEGATED_IDENTITY) plus session.py::_apply_spawn_delegation
forward exactly this — the delegation chain and, since W2.1-1, the
oidc_token claim documented in the field table above. This lane corrects
that stale entry (see the diff to identity-inheritance.md in this change).
Bounds already enforced (vs. the lane's proposed bounds)¶
The lane brief proposed carrier ≤16 KiB / delegation depth ≤8 / scopes ≤128 /
TTL ≤15 min / clock skew ≤60 s as new bounds to add. Checked against main:
- TTL/clock skew: already enforced —
RequestContextPolicy+server/auth.rs's envelope timestamp/nonce checks (durable replay-nonce acceptance, per this file's module docstring) and AU'sGraphSession.ensure_authority_current()(fails closed on expired bearer). - Delegation depth / scope count / total size: not currently bounded
as an explicit numeric limit on either side — this is a real, checked gap.
_apply_spawn_delegationonly validates principal-first/agent-last/len≥2shape, not a maximum chain length;RequestContextClaims.scopeshas no cardinality cap; there is no explicit total-envelope-size cap distinct from the transport's own frame limits. This lane records the gap and defers the fix rather than adding an unreviewed bound to a shared, already-live,deny_unknown_fieldswire struct without the security-owner sign-off this lane's own acceptance gates require (Acceptance gate 1). Recommended next step for GOC-15-W06: add depth/count checks inVerifiedRequestContext::from_verified_claims(AU mint side) and a matching cap in_mint_graph_session/_apply_spawn_delegation, sized from real observed delegation chains (there are currently none deeper than 2 in the codebase's own call sites), not from an arbitrary constant.
What this lane did NOT do (and why)¶
- Did not promote the dead JSON Schema to "v3." Formalizing an unused projection will create a second, competing "canonical schema" next to the one actually on the wire — exactly the duplicate-implementation failure this program has hit three times. If a future lane wants a language-neutral schema artifact for the carrier (e.g. for a non-Rust, non-Python consumer), it should regenerate that schema from the field table above, then wire a real caller to it in the same change — not before.
- Did not add a competing carrier type in AU or EG.
ActorContext/GraphSession(AU) andRequestContextClaims/VerifiedRequestContext/CarrierAuthority(EG) are the carrier. GOC-85 and every other consumer should build/consume through these, not a new dataclass. - Did not re-implement the Iceberg tenant-mismatch negative matrix — it already exists and is complete (see table above).
- Did add (this change):
agent_utilities.security.request_identity.CARRIER_CLAIM_FIELDS/OPTIONAL_CARRIER_CLAIM_FIELDS(the exact field-name sets from the table above, exported as the one place to import them from) andvalidate_carrier_claims(), a fail-closed shape-check helper for any new adapter (SPARQL/federation/observability) that needs to check a claims dict has the right shape before propagating it — so those lanes import a shared check instead of hand-rolling a second key list. Seeagent_utilities/security/request_identity.pyand its test,tests/unit/security/test_carrier_claim_contract.py.
Handoff to GOC-85 (remote browser-OAuth MCP broker)¶
GOC-85's per-principal remote MCP session isolation in
agent_utilities/mcp/multiplexer.py should:
- Mint one
GraphSessionper checked remote principal through the existingmint_graph_session(actor)/_mint_graph_session(actor, audience=..., policy_version=...)path — the same function every other served transport already uses. Do not invent a parallel per-session identity object. - Key session isolation on
(tenant, actor_id), mirroring EG's ownCarrierAuthority.owner_scope()(tenant_scope + actor_scope, opaque- hashed) — this is the field-level isolation key this lane's contract defines. Two remote principals must never share aGraphSession/engine authority even if they share a tenant. - Reject reconnect that changes actor/tenant mid-session — the same
invariant
ActorIdentityMiddlewarealready enforces per-request; a long-lived remote MCP session must re-check on reconnect, not persist the first principal across a changed credential. - Narrow via
GraphSession.with_actor/scopes intersection only — never widen. If GOC-85 needs a scoped child session (e.g. one OAuth-broker session fanning out to multiple downstream tool calls with narrower scopes), that is a scope-intersecting copy of the SAME session type, not a new carrier shape. - Import
CARRIER_CLAIM_FIELDS/validate_carrier_claimsfromagent_utilities.security.request_identityif it needs to validate a claims dict shape before forwarding it (e.g. logging/audit redaction of an outbound carrier) — do not redefine the field list.
Unverified / open¶
- Observability export carrier (per this lane's own W01 surface list) is not instrumented at all — confirmed absent, not merely unproven.
- SPARQL/federation per-caller isolation (as opposed to today's fixed-service identity) is not implemented; the Iceberg-bearer pattern is the template but was not extended to these surfaces in this pass.
- Delegation-depth/scope-count/size bounds are unenforced (see above).
- Key rotation / overlap window for the OIDC verifiers EG already runs
(
server::oidc) was not re-audited by this lane — assume unchanged from whatever GOC-01 checked. - Mixed-version carrier behavior (an older client's envelope missing
priority/oidc_tokenagainst a newer engine, and vice versa) is documented as a deploy-ordering constraint insession.py::engine_verified_context's docstring (deny_unknown_fieldsrejects the WHOLE request on one unrecognized field) but has no live mixed-version test proving it — recorded, not fixed, by this lane.