Design Record: Agent Principals and Gateway Parity#
Status: implemented 2026-11-03, in v0.14.0. Accepted the same day; what changed or was added while it was built is under “Decided while building”.
Problem#
Before v0.14, in the advanced template an MCP client acted through a connection a person approved on the web, and the request ran as that person, with every permission they held. Audit, change tracking and the exchange log cannot tell the agent from the person. Only the Python reference gateway served MCP and OIDC sign-in; the TypeScript, Go, C# and Rust gateways served REST only.
Decision#
Who acts, and for whom#
- An approved connection gets its own agent principal: a third principal subtype beside user and service. It never signs in on the web and has no external identity.
- Every request carries an effective principal (whom the request is for)
and an actual principal (who made it), and an actor kind:
human,agent,serviceorsystem. For an agent call the effective principal is the person, the actual principal is the agent, the actor kind isagent. Row-level security sees the person and the connection’s organization. - An agent’s permissions are its role’s permissions intersected with the person’s, recomputed on every request. An agent can never do more than its person.
- The role lives on the connection (no agent membership row). Roles come
from reserved
agent.*entries indata/authorization.json. Approval refuses a role that is not a subset of the approver’s permissions, so a role’s name never overstates what the agent can do. - Every log records both principals and the actor kind: the audit log, change tracking, the access-denial log and the exchange log context. Replay of a logged exchange re-checks the connection.
How an agent authenticates#
Two credentials, one model:
- OAuth (default for MCP clients). The gateway is an OAuth 2.1 resource
server: protected-resource metadata (RFC 9728) at
/.well-known/oauth-protected-resourceand its/mcppath form, 401 withWWW-Authenticate: Bearer resource_metadata="..."for a missing or invalid token. Tokens come from an external OIDC provider and are verified locally (JWKS), with a strict audience check (RFC 8707) against the canonical/mcpURI. A provider that cannot set the audience is not supported. Web login tokens are never agent tokens. The first call from an unknown (person, client) creates a pending connection and answers with a short pairing code; the person approves it on the web, choosing organization and agent role. - Approved handle (
X-Connection, today’scn_handle) for headless scripts and development: approved on the web first, copied to the client. It resolves to the same agent principal and role model.
Defaults taken (owner may overrule)#
- Approval names the organization in the request body; an organization session is not required.
- Connections keep an expiry (1 to 365 days); OAuth tokens add their own.
- Lifecycle: pause, resume and revoke by the person; organization managers
(
members.manage) list, pause, resume and revoke their organization’s agents; an organization-wide switch (owner) turns all agents off. When a person’s membership ends, their connections in that organization are revoked. - Pending, paused, disabled and not-approved answer JSON-RPC
-31001withdata.code(and the pairing code while pending) at HTTP 200, so every MCP client shows it; a missing or invalid token is HTTP 401. - Rate limits live in the gateway: per client IP before authentication (so a
flood of bad tokens never reaches the identity provider) and per
(subject, client) after it; 429 with
Retry-After. Per process, documented. - Pairing codes are 6 characters from an alphabet without 0, O, 1, I, L and
U, valid for an hour; the first call after expiry issues a new one, stale
ones are never swept. A pending connection is its own table, so an
approved connection keeps every column it had; an OAuth connection has no
handle. One live connection per (person, issuer, client). The
-31001codes aremembership.connection.pending,.paused,.disabledand.not_admitted.
Gateways#
- MCP header checks move into the database.
api.mcp_request_policyreceivesMCP-Protocol-Version,Mcp-MethodandMcp-Nameand returns the-32020,-32022or-32602envelope; a refused request logs no exchange. Each gateway forwards three headers and maps error codes to HTTP statuses. - All five gateways reach parity: REST, MCP (both eras), OIDC sign-in, the
OAuth resource server and the rate limits. MCP needs no new dependency;
token verification uses one library per language, while the flow (state,
nonce, PKCE,
returnTo, cookies, problem codes) stays hand-written to match. - One language-neutral end-to-end harness (REST, MCP, login, OAuth, with a mock identity provider) runs against every gateway in CI, asserting codes and statuses, never message prose. Environment variable names are the same in every language.
Decided while building#
What the record above did not say, or said differently, and how it landed.
Identity#
- The role is a column of the connection,
role_id, NOT NULL, a foreign key to the three agent roles:agent.reader(a viewer’s grants),agent.contributor(a member’s) andagent.delegate(*). Connections that existed before got the narrowest role that covers their person, so their reach did not change on upgrade. Kernels refuse to give an agent role to a member, and CHECK constraints back that up. - The actor kind is derived, not stored beside the principal. It follows
from the actual principal’s kind: user is
human, agent isagent, service isservice, no principal issystem. The two can never disagree. The log columns are CHECKed on the four values andNOT NULL NOT VALID, so history written before is not mislabelled. - A different actual principal is honoured only when it is the agent of
a live connection of that same person; the organization is always the
connection’s. One function,
membership.fn_live_connection, decides liveness on every request and on replay, so a revoked agent replays as 401. - Only a person acting as themselves changes connections: actor kind
humanand the actual principal equal to the effective one. An agent never approves, extends or resumes an agent. - A manager’s pause is lifted only by a manager. Not in the record; added so a person cannot undo the pause of their own agent.
- A handle and a token are refused alike. A paused or switched-off
connection answers
-31001with its reason on both paths (membership.fn_connection_refusaldecides it for both, overmembership.fn_live_connection). A handle that names no live connection (unknown, revoked, expired, its membership ended, its person disabled) answers-31001pgmi.connection_unknown, which the gateway sends as HTTP 401 withWWW-Authenticate, as it does a refused token. The handle may come asX-ConnectionorAuthorization: Bearer cn_.... fn_add_memberrefuses an agent, and the audit subject of a lifecycle change is the agent, with the person in the detail.
OAuth and pairing#
- A pending connection is its own table,
membership.pending_connection. An approved connection keeps every column it had and carries exactly one credential: a handle, or an OAuth (issuer, client). A partial unique index allows one live connection per (person, issuer, client). - Pairing codes come from an unbiased CSPRNG over
23456789ABCDEFGHJKMNPQRSTVWXYZ. Concurrent first calls share one code (ON CONFLICT), and a code is only ever shown to the same issuer, subject and client.POST /connections/approvelocks the pending row and accepts only the code’s own person; a wrong or expired code is 422membership.connection.pairing_code_unknown. - With
MCP_RESOURCEset, an anonymous MCP call is 401: public tools are not reachable without a token or a handle. The owner may overrule this. - A client that cannot act yet is refused on every method, so its user sees the pairing code on the first call.
- Every connection and agent problem code is
membership.connection.<reason>(13 codes renamed, no aliases), following the template’s<schema>.<entity>.<reason>convention. - pgmi’s MCP codes sit outside the JSON-RPC reserved range (MCP
2026-07-28, “Error Codes”):
-31001the client cannot act,-31003access refused (was-32003),-31000retries exhausted or no database connection (was-32000); the isolation floor is-32603. Clients branch ondata.code. pgmi’s own JSON-RPC endpoint keeps-32001and-32003.
Gateways#
- The header checks returned a refusal, not an error:
api.mcp_request_policyreturnsmodernandrefusal, a refusal is answered 400 and nothing is dispatched or logged. Only a refusal maps to 400: a-32602from dispatch (an unknown tool) stays 200, because the spec asks for 400 only for a header mismatch, an unsupported version and a missing required_metafield. A dispatched-32601is 404 in the 2026-07-28 era. - The gateway’s own refusals on
/mcpare problems with codes: 405pgmi.method_not_allowedwithAllow: POST, 406pgmi.not_acceptable, 415pgmi.unsupported_media_type, 403pgmi.origin_refused, 429pgmi.rate_limited. No pooled connection is 503pgmi.unavailable. - The path is the database’s. The path under
/apireaches the database exactly as sent: no cleaning, no decoding, no redirect. Two frameworks (Go’sServeMux, ASP.NET Core’s Kestrel) clean paths before routing, so those gateways read the raw request target. - An unreachable identity provider is 502
pgmi.login.provider_unavailableon sign-in, and 503pgmi.identity_provider_unavailableon/mcp. - One name per setting in all five gateways:
ALLOWED_ORIGINS,POOL_MAX_SIZE,RETRY_MAX_ATTEMPTS,RETRY_BASE_DELAY, and the OAuth and rate-limit variables. Every gateway servesGET /healthand answers an unknown path 404pgmi.not_found. - Idiomatic, not ported. Each gateway is written the way its language is
usually written, opens every file with a header saying it is an example to
read and not a library, and marks the decisions a project makes with
ADAPT. Token signatures use one pinned library per language; what the library leaves to the caller (nonce,azp,iat, single audience, leeway) is checked by hand.
Extension seams#
Added during the work, for projects that need embeddings, URL signing,
enrichment or redaction without editing request handling. The full contract
is gateways/PROTOCOL.md, “Extension seams”, in the scaffolded project.
- The database channel: one interface with one entry per database call
(REST policy and invoke, MCP policy and request). Decorators compose once:
ConnectionRetry(SerializationRetry(Tracing(Postgres(pool)))). Connection retry re-sends only policy lookups andREAD ONLYdispatches; MCP is never re-sent, and a pool timeout is not a lost connection. - Interceptors: a request interceptor runs after the policy call and
before the transaction opens, a response interceptor after the commit.
Each declares whether it needs parsed JSON, its failure policy (
failordegrade), a timeout and when it applies. It selects itself from what the policy functions return,handler_nameandinput_annotations(thex-*keywords of the input schema), never from a list of paths. - Zero cost when unused: a route no interceptor selects passes the client’s bytes unchanged, and the body is parsed once for all that do.
- A failed interceptor is 502
pgmi.interceptor_failed(MCP:-32603with thatdata.code); after the commit the detail says the request was applied.degradekeeps none of its changes. - A changed route resolves the policy again, and the session handles are set again after the pipeline, whatever an interceptor did. No database connection is held while a request interceptor waits.
Not copied from the downstream design#
A missing audience check, rate limiting only after token validation, connections without expiry, approving a role broader than the approver, and agent membership rows that outlive the person’s membership.