Skip to content

ADR-0036: Request-scoped logged user

Amended 2026-11-07 for SDK issue #251: in local mode an application can reuse the platform's answer to who holds a Bearer token across requests, for a period it sets with install_request_identity(app, local_caller_cache_seconds=...). 0, the default, keeps asking on every request. Section "Local callers reused across requests" is added.

Amended 2026-10-15 for SDK issue #225, second change: the platform signs no team list any more, so the SDK removes it. RequestUserIdentity has no team_uids, and the verifier refuses an assertion or a requester that lists teams. Team membership is checked only with in_teams().

Amended 2026-10-15 for SDK issue #225: team membership is checked on demand. The platform is replacing the team list it signs, which grows with an Organization and passes the verifier's 16,384-byte limit at about 300 teams, with a membership check. The verifier accepts the caller's facts and a requester with or without team_uids. RequestUserIdentity.in_teams() asks the platform with a cache whose lifetime the application sets, and team_uids is deprecated. Section "Team membership on demand" is added and section "Requester-bound calls" is edited.

Amended 2026-10-07 for SDK issue #208: one delegation rule. A requester-bound call carries the person's ordinary permissions, including administrative ones. The platform signs requester as exactly sub, team_uids and is_organization_admin, the person's own facts, and every assertion carries the caller's team_uids and is_organization_admin. The verifier rejects any other shape, including the two-field requester and an assertion without the caller's facts; there is no compatibility path or deprecation period. The request scope binds the person's admin flag, so User.get_requester() returns it, and each handler or tool decides whether it needs a person. SDK releases up to 9.0.17 reject the new requester claim, and an SDK with this amendment works only with a platform that signs it. See Requester-bound calls.

Amended 2026-10-07 for SDK issue #206: requester-bound identity is not a read-only permission. Application REST handlers and MCP tools authorize each read or write against the verified person's object- and operation-specific rights. The signed claim shape, getters, request lifetime and directory-read helper are unchanged; platform write opt-ins and platform-authorized Agent delegation do not give this SDK a forwarding or impersonation API. See the access guide for the coordinated platform rollout and write risk.

Amended 2026-10-06: an application can receive requester-bound calls. Another application, for example an Agent, calls it while it works for a person, the requester, and the platform signs that person into the caller assertion it already sends, as the claim requester. The assertion's sub stays the calling application. The verifier accepts the claim in its exact shape, the request scope binds the verified requester next to the caller, User.get_requester() returns it, User.get_logged_user() is unchanged, and reads_as_caller() refuses a requester-bound request. See Requester-bound calls.

Amended 2026-10-06 for SDK issue #198: an application can read the directory as its caller. The platform API accepts the call: with the release's workload credential and the caller assertion the application received, GET /api/v1/users/, /users/<uid>/, /teams/ and /teams/<uid>/ answer with the caller's visibility, and every other action refuses the assertion. The request scope now keeps the verified assertion, and mainsequence.server.fastapi.reads_as_caller() is the only way to present it. See Reads as the caller.

Implementation record 2026-09-28 for the owner's instruction to implement and commit the reviewed platform authentication plan.

This extends the retained authentication ownership in ADR-0034. The existing low-level verifier remains framework-independent; this integration supplies its request lifetime.

Contract

Caller verification is part of the standard SDK. PyJWT and cryptography are required runtime dependencies, supplied by a plain mainsequence installation.

Application handlers use User.get_logged_user(). Applications install mainsequence.server.fastapi.install_request_identity(app) once. The SDK integration uses the existing caller assertion verifier, binds one isolated context, invokes the application, and invalidates/resets the context on completion, error, or cancellation. The verifier is an internal building block rather than another handler recipe.

PodDeploymentOrchestrator remains independent of the SDK. It validates a package-neutral app.state.mainsequence_request_identity declaration (installed, mode, public_ingress) before serving. The declaration is a wiring check, not caller proof. Hosting, health, CORS, WebSocket transport policy and observability remain launcher responsibilities.

Django projects MAINSEQUENCE_CALLER_AUTH_MODE=assertion, the expected issuer and HTTPS key URL, APP_NAME and the Environment UID. Hosted HTTP requires signed proof without UID/process-account fallback. Local execution without deployment markers retains direct Bearer validation inside the same integration. Public/OPTIONS scopes are anonymous; WebSockets keep the existing gateway ticket/header contract with connection-scoped context.

The getter preserves the minimal uid/optional username result. Signed assertions have no username, so it is null. Request-state compatibility fields project the same identity. The process-level get_authenticated_user_details remains distinct.

Lifecycle and migration

Missing/invalid proof is 401; required verification unavailable is 503. Context resets in finally and an expired request scope cannot be reused from a copied child task. Synchronous routes inherit framework-propagated context. Outside an authenticated scope the getter raises RequestIdentityError.

Migrate existing factories to install the integration before using the matching launcher. Remove manual header/context/verifier handling. No public HTTP route, JWT wire format, package version, database model, or service is introduced. Keep Django signing and gateway proof transport ready before enabling proof-required applications.

Reads as the caller

Added 2026-10-06 for #198.

  • In assertion mode, the scope of an admitted HTTP request keeps the verified assertion next to the user. It expires with the scope, also in copied child contexts, and never enters repr, str, error messages or logs. Local mode, WebSockets and public or OPTIONS requests have none. The verifier's result still does not hold it.
  • reads_as_caller() is a context manager. Inside it, the reads of /users/, /users/<uid>/, /teams/ and /teams/<uid>/, that is filter, iter_filter, get and get_by_uid on User and Team, add X-MainSequence-Caller-Assertion with the request's own assertion. No other call sends it, and nothing sends it outside the helper: the SDK never attaches it implicitly.
  • The header is set on each request, never on the process-wide HTTP session, and goes only to the configured platform endpoint. A paging link on another origin raises instead of being followed with or without the header.
  • No fallback. Entering the helper outside an authenticated request, or in a request without an assertion, raises RequestIdentityError, and so does a read inside the helper after its request ended. Neither reads as the workload instead. The assertion lives at most 300 seconds and is not refreshed; a read after it expires fails with the platform's answer.
  • The platform answers these reads no-store; the SDK does not cache them.
  • Process credentials are unchanged. The workload credential still authenticates every call; the assertion only selects whose visibility the answer has.

Team membership on demand

Added 2026-10-15 for #225.

The application owns its team policies and decides how often it asks about membership. The platform answers one question, which of the given teams a person is an active member of, for the request's caller or, on a requester-bound request, its requester. It answers at most 100 teams per call and leaves out teams it does not count.

  • RequestUserIdentity.in_teams(team_uids, *, max_age_seconds=None) returns the subset of team_uids the person belongs to, as a frozenset of canonical UIDs. It works on User.get_logged_user() and User.get_requester() inside an authenticated HTTP request in assertion mode, and asks only about that request's caller or requester. It sends the request's assertion with the workload credential, to the configured platform endpoint only.
  • install_request_identity(app, team_membership_cache_seconds=0) sets how long an answer may be reused across requests. Reuse has three levels:
  • Within one request the same question is asked once.
  • Across requests an answer is reused while younger than the lifetime, or than max_age_seconds when a call gives one; 0, the default, reuses none.
  • max_age_seconds=0 is a fresh check: it always asks the platform, skips both kinds of reuse and stores the new answer.
  • Isolation: answers are keyed by the release and Environment the verifier trusts, from deployment configuration and never from the request, then by person and team. An answer for one release or Environment never answers another.
  • Bounds: the process keeps at most 10,000 answers and drops the oldest first. Longer lists are sent in calls of at most 100 teams.
  • Failure: a call the platform cannot answer raises the SDK's API error; in_teams() never answers "not a member" because of it. Outside an authenticated request, in local mode and on WebSockets, it raises RequestIdentityError.
  • No team list: RequestUserIdentity has no team_uids. Earlier releases read it from the assertion; the platform no longer signs one, and an assertion that lists teams is refused.

Local callers reused across requests

Added 2026-11-07 for #251.

In local mode every request is authenticated by GET {MAINSEQUENCE_ENDPOINT}/api/v1/users/me/ with its Bearer, a round trip to Main Sequence before any route runs; concurrent requests each made their own. Hosted mode verifies the assertion locally and has no such cost. Measured against a development platform on 2026-11-07, users/me took 2 to 4 seconds and six requests at once got several 503 from the check's 10-second timeout. Callers that reuse one token pay most: a workload identity reuses one access token for up to 14.5 minutes.

  • install_request_identity(app, local_caller_cache_seconds=0) sets how long an answer may be reused, following team_membership_cache_seconds. 0, the default, keeps today's behavior: every request makes its own call.
  • What is kept: only who holds the token, the UID and username users/me answered. The X-User-UID check runs on every request against that UID. An application that decides a person from other request data keeps deciding it on every request.
  • Key: MAINSEQUENCE_ENDPOINT and an HMAC-SHA256 of the token under a random key generated with the cache. The token is never stored, and the hash means nothing outside the process.
  • Lifetime: the period, and never past the token's exp when it is a JWT. The claim is read without verification, only to shorten the lifetime; users/me already verified the token.
  • Refusals: a 401, and a 503 from a 5xx, a timeout or a network error, are not kept.
  • Concurrent first requests: requests with the same key wait for the check in flight and share its result, success or failure.
  • Bounds: at most 10,000 callers per process, oldest dropped first; thread safe.
  • Logs: no token, key or hash appears in a log line, an error or a repr().
  • Revocation: a revoked token stays accepted for at most the period. 60 matches how long the gateway may reuse an admission in hosted mode.
  • Hosted mode and WebSocket admission are unchanged. The 10-second timeout and the mapping of failures to 503 stay as they are.

Requester-bound calls

Added 2026-10-06; amended 2026-10-07 for #208.

The platform applies one rule to every call an Agent makes for its work: without a delegation, the caller's own ordinary permissions; with a valid delegation, the person's ordinary permissions, including administrative roles; an invalid or expired delegation is rejected and never switched to another identity.

  • Every assertion carries the caller's is_organization_admin, a boolean, and no team list (amended for #225). The assertion of a requester-bound call carries one more claim, requester: a JSON object with exactly sub, the requester's User UID as a canonical lowercase UUID, and is_organization_admin, the requester's own Organization-admin flag as a boolean. The assertion's own sub and is_organization_admin stay the caller's: the acting application.
  • The verifier accepts the base claims with the caller's admin flag and the optional requester claim, and nothing else. An assertion without is_organization_admin or with team_uids, or a requester with a missing or extra key (team_uids included), a value of the wrong type, an admin flag that is not a boolean or a UID that is not canonical, rejects the whole assertion, so the request gets 401 before any handler or tool runs. The verifier's result keeps the verified requester, with its admin flag, beside the caller, under a private attribute.
  • The request scope binds the requester next to the caller, as a RequestUserIdentity with the claim's is_organization_admin; in_teams() answers for the requester's teams. It expires with the scope, also in copied child contexts. Local mode, WebSockets and public or OPTIONS requests have none.
  • User.get_requester() returns it, the checked delegation, or None when the request is not requester-bound. Outside an authenticated request it raises RequestIdentityError, as User.get_logged_user() does. get_logged_user() is unchanged: it returns the caller, the acting application.
  • Each handler or tool decides whether it needs a person. One that needs a person fails with its own error when get_requester() is None. One where the person is optional authorizes against get_requester() when present, and against get_logged_user() otherwise. None parses assertions, headers or MCP _meta, takes a person's UID from a request body, header or query parameter, or combines the caller's grants with the person's. The SDK keeps no list of which handlers or tools accept a person.
  • An application authorizes requester-bound REST and MCP reads and writes against the person's ordinary permissions, including administrative ones, for the exact object and action. A read grant or team membership is not write permission. The getter proves identity, not permission, and never enables platform endpoints. Application-owned authorization checks are necessary for model-driven writes because of prompt-injection risk.
  • reads_as_caller() raises RequestIdentityError as soon as it is entered in a requester-bound request, and never reads as the application instead. A requester-bearing assertion cannot be forwarded to the platform's directory reads; the SDK fails early and says why. Platform-authorized Agent delegation is distinct: the platform preserves the original person and request time, without increasing permissions or restarting the 24-hour maximum. Receiving applications cannot establish that binding themselves, and their request scope still expires with the inbound request.
  • SDK releases up to 9.0.17 reject the requester claim the platform now signs: up to 9.0.14 every requester claim, and 9.0.15 to 9.0.17 any but the two-field one. Requester-bound calls to an application on such a release fail closed with 401, and its other calls are unchanged. From this amendment on, the SDK accepts only the shapes above, so it works only with a platform that signs them; there is no deprecation period.