1. auth
temper
  • Temper documentation
  • 2026-07-06-cogmap-telos-differentiation
  • handoffs
  • plans
    • archived
    • evidence
  • reviews
  • specs
  • spikes
  • agents
  • auth
    • Temper auth & security
    • The authorization seam (`temper-services::auth`)
    • Cognitive-map & resource authoring authorization
    • JWT verification
    • The issuer / resource-server boundary & the machine-token contract
    • The internal reconcile channel
  • code-reviews
  • cognitive-maps
  • decisions
  • development
  • guides
    • Building a cognitive map from a large corpus
    • Using temper from Claude Desktop (and claude.ai)
    • Cloud Agent Development Guide
    • Ingesting a corpus into a context
    • Development setup
    • Drain operator queries (TraceQL)
    • Enterprise Install — Ground Up
    • Operator guide: read-only GitHub credential via a BYO App + Vercel Connect
    • Operator guide: provisioning a GitHub connection in temper
    • Installing Temper
    • Delivering L0 Kernel Cogmap Content
    • Machine Credentials
    • OpenTelemetry Setup
    • Operational Memory
    • Bootstrapping a Temper org
    • Releasing Temper
    • Self-Hosting Temper with Okta
    • Self-Hosting Temper with a SAML IdP
    • Self-Hosting Temper
    • The Slack Integration — identity, credentials, and what revocation actually means
    • Operator guide: `@temper` on Slack — end-to-end setup
    • Bootstrapping a team's self-cognition cognitive map
    • Working with Teams
    • Integrating Ruby with temper-rb
    • Deploying an Eve agent to Vercel (the steward and the citation auditor)
    • WS6 Collapsed-Schema Dev Environment
  • research
  • security
  • specs
  • doors
    • Building against Temper
    • Running Temper
    • Using Temper
  • Temper /api/query — the compositional read surface
    • Run a declared composition of situated acts.
    • Schemas
      • Composition
      • OutcomeDeclaration
      • ReturnSpec
      • Intention
      • StageNode
      • ActInvocation
      • CombineNode
      • StageInput
      • StageRelation
      • Disclosure
      • QueryResponse
      • StageResult
      • StageOutput
      • ScoreKind
      • Scoring
      • ResourceHit
      • CompositionTrace
      • StageTrace
      • InputSource
      • NarrowedBy
      • StageName
      • ActName
      • IdKind
      • IdSet
      • BoundTerm
      • Extent
      • StageDisposition
      • ActRefusal
      • PlanRefusal
      • RefusalReason
      • ActQuantity
      • QuantityScale
      • ResourceFilter
      • EdgeFilter
      • PropertyPredicate
      • PropertyOp
      • ResourceSection
      • EdgeKind
      • OrdOp
      • CogmapId
      • ContextId
      • IdProvenance
      • FacetPredicate
      • CombineOp
      • BlockId
      • MatchLocation
      • BodyStorage
      • ResourceId
      • IngestState
      • ManagedMeta
      • ProfileId
      • ResourceView
      • Polarity
      • ViaEntry
      • LensId
      • RegionId
      • CogmapRegionRow
      • RegionHit
      • StageInputTrace
      • Standing
      • ActorAuthority
      • Refusal
      • SystemAccessDetails
      • PlanRefusalDetails
      • ErrorDetails
      • ErrorDetail
      • ErrorBody
  • Temper Cloud API
    • Access
      • POST /api/access/requests — submit a join request for the gating team.
      • GET /api/access/requests/me — check own join request status.
      • DELETE /api/access/requests/me — withdraw a pending join request.
      • POST /api/access/reviews — a revoked principal asks an admin to reconsider (spec D15).
      • GET /api/access/settings — read public system settings.
    • Slack Link
      • Disconnect any principal. Operator path — offboarding and stuck users.
      • Disconnect EVERY Slack principal bound to the caller's own profile.
    • Auditor
      • auditor_dispatch
      • auditor_sweep
      • complete_auditor_job
    • Graph
      • POST /api/cogmaps/{id}/graph/slice — R4 cogmap-scoped neighborhood slice.
      • GET /api/graph/cogmaps/{id}/panorama — enter-a-cogmap Tier-0 interior.
      • GET /api/graph/contexts/composition — Beat E Tier-1: the force-graph composition of a container's (or a residual bucket's) members.
      • GET /api/graph/contexts/panorama — Beat E Tier-0: goal-container territories + residual tray.
      • GET /api/graph/home — the you→teams→cogmaps membership home.
      • GET /api/graph/regions/composition — Beat D region→resources composition drill.
    • Cognitive Maps
      • list_cognitive_maps
      • genesis
      • get_cognitive_map
      • reconcile
      • analytics
      • grant_cogmap_access
      • revoke_cogmap_access
      • materialize
      • materialize_delta
      • region_metrics
      • shape
      • bind_team
      • unbind_team
    • Contexts
      • list_contexts
      • create_context
      • get_context
      • context_materialize
      • reassign
      • context_region_metrics
      • rename
      • context_shape
      • share_team
      • unshare_team
    • Events
      • cursor
      • GET /api/graph/elements/{kind}/{id}/trail — R5 element event-trail. kind ∈ {node, edge}.
    • Facets
      • set_facet
      • Read the live facets of one edge.
      • Set a facet whose owner is an **edge** rather than a resource.
      • Read the live facets of one resource — the confirming read for a write that steers region formation and Atlas grouping.
    • Health
      • health_check
    • Ingest
      • create_ingest
      • update_ingest
      • list_blocks
      • append_block
      • finalize_resource
    • Invitations
      • accept
      • decline
      • list_mine
      • list_team_invitations
      • revoke_team_invitation
      • create_team_invitation
    • Invocations
      • list_invocations
      • open
      • show
      • close
    • Profile
      • get_profile
      • update_profile
      • list_auth_links
    • Query
      • `POST /api/query`.
    • Relationships
      • assert
      • fold
      • retype
      • reweight
    • Resources
      • `GET /api/resources` — **one response type, unconditionally.**
      • create_resource
      • get_resource
      • delete_resource
      • update_resource
      • List the finding at `{id}`'s citation-audit trail — one row per audit, each naming its auditor.
      • Record an auditor's signed defensibility verdict on one `(block, source)` citation of the finding at `{id}`. CONFORM to `handlers::edges::assert` (the sibling authored-write handler): thin — build the command, dispatch it, map the error. No persistence he
      • get_content
      • list_resource_edges
      • resource_evidence
      • grant_resource_access
      • revoke_resource_access
      • resource_lineage
      • provenance
      • annotate_resource
    • Meta
      • get_meta
      • update_meta
    • Reassign
      • reassign_resource
      • reassign_team
    • Search
      • `POST /api/search`.
    • Steward
      • candidates
      • dispatch
      • sweep
      • delta
      • advance
    • Teams
      • list_teams
      • create_team
      • detail
      • delete_team
      • update_team
      • add_member
      • remove_member
      • change_role
    • Schemas
      • AcceptInvitationResponse
      • ActInput
      • ActInvocation
      • ActName
      • ActQuantity
      • ActRefusal
      • ActorAuthority
      • AddMemberRequest
      • AdvanceWatermarkAck
      • AdvanceWatermarkRequest
      • AgentAuthorship
      • AppendBlockPayload
      • AssertRelationshipRequest
      • AtlasEdge
      • AtlasHome
      • AtlasNode
      • AtlasSubgraph
      • AuditCitation
      • AuditSweepRow
      • AuditorDispatchTickRequest
      • AuditorDispatchTickResponse
      • AuditorJobCompleteAck
      • BindTeamOutcome
      • BindTeamRequest
      • BlockId
      • BlockProvenanceRow
      • BlocksResponse
      • BodyStorage
      • Bridge
      • BulkReassignAck
      • BulkReassignRequest
      • ChangeRoleRequest
      • CharterBlock
      • CharterDisposition
      • CitationAuditRequest
      • CitationAuditRow
      • ClaimedAuditJob
      • ClaimedJob
      • CloseInvocationRequest
      • CogmapAnalyticsRow
      • CogmapDetail
      • CogmapFoundationRow
      • CogmapGrantBody
      • CogmapId
      • CogmapRegionMetricsRow
      • CogmapRegionRow
      • CogmapRegulationRow
      • CogmapRevokeBody
      • CogmapRow
      • CogmapStaleness
      • CombineNode
      • CombineOp
      • Composition
      • CompositionTrace
      • ConfidenceBand
      • ContentResponse
      • ContextCreateRequest
      • ContextId
      • ContextOwnerRef
      • ContextPanorama
      • ContextRow
      • ContextRowWithCounts
      • CorrelationId
      • CreateCogmapOutcome
      • CreateCogmapRequest
      • CreateInvitationRequest
      • CreateRequestBody
      • CreateReviewBody
      • DeleteResponse
      • DeviceOverrides
      • DispatchTickRequest
      • DispatchTickResponse
      • Disposition
      • DriftSweepRow
      • EdgeFacetRow
      • EdgeFacetSetRequest
      • EdgeFacetsResponse
      • EdgeFilter
      • EdgeId
      • EdgeKind
      • ElementEvent
      • ElementKind
      • EmbedDispatchSummary
      • Entitlements
      • ErrorBody
      • ErrorDetail
      • ErrorDetails
      • EventCursorResponse
      • EventTrail
      • ExactArm
      • ExactHit
      • Extent
      • FacetAck
      • FacetPredicate
      • FacetSetRequest
      • FinalizePayload
      • FoldRelationshipRequest
      • GrantOutcome
      • GraphEdgeRow
      • GroupKeyMeta
      • HealthResponse
      • HomeCogmap
      • HomeContext
      • IdKind
      • IdProvenance
      • IdSet
      • IdpRevocation
      • IngestCreateResponse
      • IngestDelta
      • IngestPayload
      • IngestState
      • InheritedReadGrant
      • InheritedShare
      • InputSource
      • Intention
      • InvitationStatus
      • InvitationTokenRequest
      • InviteeInvitation
      • InvocationAck
      • InvocationActRow
      • InvocationCloseAck
      • InvocationId
      • InvocationSummary
      • InvocationView
      • JoinRequest
      • JoinRequestStatus
      • LensId
      • LineageNode
      • ManagedMeta
      • MatchLocation
      • MaterializeAck
      • MaterializeDelta
      • MaterializeRequest
      • MergePolicy
      • MetaUpdatePayload
      • NarrowedBy
      • NodeHome
      • OpenInvocationRequest
      • OrdOp
      • OrphanNode
      • OutcomeDeclaration
      • PlanRefusal
      • PlanRefusalDetails
      • Polarity
      • Profile
      • ProfileAuthLink
      • ProfileId
      • ProfileUpdateRequest
      • ProfileWithEntitlements
      • PropertyOp
      • PropertyPredicate
      • ProvenanceSource
      • PublicSystemSettings
      • QuantityScale
      • QueryResponse
      • ReassignAck
      • ReassignContextOutcome
      • ReassignContextRequest
      • ReassignResourceRequest
      • ReconcileCogmapRequest
      • ReconcileEdge
      • ReconcileEdgeTombstone
      • ReconcileEntry
      • ReconcileOutcome
      • ReconcileTelos
      • ReconcileTelosBlock
      • ReconcileTombstone
      • Refusal
      • RefusalReason
      • RegionHit
      • RegionId
      • RelationshipAck
      • RemoveMemberOutcome
      • RenameContextOutcome
      • RenameContextRequest
      • ResidualBucket
      • ResidualContext
      • ResidualGroups
      • ResidualOwnedReach
      • ResourceAnnotateRequest
      • ResourceCreateRequest
      • ResourceFacetRow
      • ResourceFacets
      • ResourceFacetsResponse
      • ResourceFilter
      • ResourceGrantBody
      • ResourceHit
      • ResourceId
      • ResourceLineage
      • ResourceListResponse
      • ResourceRevokeBody
      • ResourceSection
      • ResourceSortField
      • ResourceUpdateRequest
      • ResourceView
      • ReturnSpec
      • RetypeRelationshipRequest
      • RevokeOutcome
      • ReweightRelationshipRequest
      • ScoreKind
      • Scoring
      • SearchParams
      • SearchReason
      • SearchResponse
      • SearchResultRow
      • SearchScope
      • SearchScopeInfo
      • SegmentInfo
      • SegmentedBegin
      • SegmentedBeginResponse
      • ShareContextOutcome
      • ShareContextRequest
      • SlackDisconnectRequest
      • SlackDisconnectResponse
      • SlackDisconnectedPrincipal
      • SliceRequest
      • SortOrder
      • StageDisposition
      • StageInput
      • StageInputTrace
      • StageName
      • StageNode
      • StageOutput
      • StageRelation
      • StageResult
      • StageTrace
      • Standing
      • StandingShape
      • Subscription
      • SubscriptionOverride
      • SystemAccessDetails
      • TeamCreateRequest
      • TeamDetail
      • TeamInvitation
      • TeamMemberDetail
      • TeamMemberRow
      • TeamMemberSource
      • TeamRole
      • TeamRow
      • TeamUpdateRequest
      • Territory
      • TerritoryKind
      • TerritoryOverview
      • UnbindTeamOutcome
      • UnshareContextOutcome
      • VaultConfig
      • ViaEntry
      • WideArm
      • WideHit
  1. auth

The authorization seam (`temper-services::auth`)

The seam is where the order of authorization gates lives — exactly once, shared by
both surfaces. temper-api and temper-mcp both call it and map its errors to their own
transport; neither re-implements the sequence.
Source: crates/temper-services/src/auth/mod.rs (the chain),
auth/normalize.rs (classification), auth/email.rs (the human email ladder).

The seam owns principal construction#

A surface hands in a verified token and gets back an AuthenticatedProfile. It never
builds an AuthClaims — and nothing in the seam's public API accepts one.
This is the level below the one classification closed. Making classify total (PR #384)
meant no surface could say "unrecognized ⇒ human" — but each surface still hand-built its
own human AuthClaims, and the two disagreed: temper-api ran a three-rung email ladder,
temper-mcp set email: "" and auto-provisioned. Any surface that can construct an
AuthClaims can construct a PrincipalKind::Human, which is precisely the asymmetry that
made #384's bug asymmetric in the first place. One constructor, one ladder, one answer per
token.
Forgery is inert, not forbidden. AuthClaims is still a public type a surface can
construct. But authenticate, classify, Principal and resolve_from_claims are all
crate-private, so a hand-built AuthClaims has nowhere to go — there is no public function
that takes one. The enforcement is the absence of a door, not a runtime check.

Three public entry points, a typestate chain#

Level 1's gate function itself — authenticate(pool, &claims) — is pub(crate). It is
reachable only through authenticate_token, which is what makes "the seam owns the
principal" true by construction rather than by convention.
authenticate_token classifies (classify), runs the email ladder on the human arm
(email::resolve_email_from_claims), builds the AuthClaims, and calls authenticate.
The machine arm deliberately skips the ladder — an M2M principal has no email and no
/userinfo to ask, so a ladder there would be an authentication failure dressed as a
lookup. That ordering is load-bearing; it is pinned by
machine_token_authenticates_without_running_the_email_ladder.
authenticate runs resolve_from_claims (which JIT-provisions a human profile on first
sight, and is lookup-or-reject for a machine — see the
machine-token contract) then the is_active gate. It
yields an AuthenticatedProfile, read through .profile() / .claims().
require_system_access runs access_service::has_system_access (approved member of the
gating team). It yields SystemAuthorized(AuthenticatedProfile).
Typestate, not a marker bool. require_system_access only accepts an
AuthenticatedProfile, and that type is only produced by authenticate. So the type
system proves Level 1 ran before Level 2 — you cannot call the system-access gate on an
unauthenticated principal. This is parse-don't-validate: a call site that needs system
access asks for SystemAuthorized, and possessing that value is the proof the gate ran.
It is deliberately lightweight (SystemAuthorized is a thin newtype wrapping the already-
existing AuthenticatedProfile), not a heavyweight typestate framework.
All three proofs are sealed. AuthenticatedProfile, SystemAuthorized and SystemAdmin
all live in temper_services::auth with private fields, so the only way to hold one is to
call the gate that mints it — a struct-literal forgery elsewhere in the workspace is a
compile error, pinned by the compile_fail trybuild fixtures.
That is newer than the paragraph above it. AuthenticatedProfile sat in temper-core with
public fields until 2026-07-22, so "only produced by authenticate" described intent rather
than enforcement, and sealing the two proofs above it was decorative: you could forge Level 1
and walk the real Level 2 and Level 3 gates with it. Sealing the bottom rung is what makes
the sentence true.

Why two levels, not one monolith#

temper-api splits into two router tiers (see routes.rs):
auth-only tier — view own profile, request access, team join. Runs Level 1
(require_auth) but deliberately skips Level 2. This is how a not-yet-approved user
requests access in the first place.
gated tier — everything else. Adds the require_system_access layer.
temper-mcp has no auth-only tier: every tool requires Level 2.
A single authorize_request() that always ran all gates would break the request-access
flow. The honest model is the two-level chain, each level single-sourced. A future gate
belongs to exactly one level, so "add a gate = edit one function" holds per level — the
real, defensible version of the acceptance criterion.

AuthzError — one enum, mapped per transport#

The seam speaks the vocabulary of why a request was refused. It never chooses the
words-on-the-wire; each surface owns that mapping and only that.
AccessCheck is kept distinct from SystemAccessDenied so a surface can preserve the
pre-seam "failed to check system access" diagnostic instead of collapsing an infra
failure into a clean access-denied message. For the same reason EmailResolution is kept
distinct from ProfileResolution: nothing was resolved — we could not even name the
human, so no write was attempted.

Transport mapping#

AuthzErrortemper-apitemper-mcp
Refused401 Unauthorized — "Invalid or expired token" (the reason is logged with the sub, never put on the wire)INVALID_REQUEST, terminal ("do not retry")
EmailResolution(e)the inner ApiError (a 401)INVALID_REQUEST, terminal — a retry resolves nothing; the fix is a token carrying an email claim
Deactivated401 Unauthorized — "account is deactivated"INVALID_REQUEST, terminal
SystemAccessDeniedApiError::SystemAccessRequired { details } — carries SystemAccessDetails (email, display_name, access_mode, join-request status, request URL, CLI command)INVALID_REQUEST with the request-access guidance text
ProfileResolution(ApiError::Unauthorized)the inner ApiError (a 401)INVALID_REQUEST, terminal — this is usually the machine-registration gate denying an unregistered or revoked client_id, a permanent denial a Sidekiq-style client must not retry
ProfileResolution(e) / AccessCheck(e) (any other)the inner ApiErrorinternal_error (retryable — a genuine infra fault)
temper-api mappers: middleware/auth.rs (Level 1) and middleware/system_access.rs
(Level 2). temper-mcp mapper: service.rs::map_authz_error.
Both Level 2 mappers still have to spell the Level 1 variants (Refused,
EmailResolution) because the enum is shared and match is exhaustive; they map them to an
internal error, since require_system_access neither classifies a token nor resolves an
email. That arm is defensively unreachable, not live.
The SystemAccessDetails payload reflects the caller's own profile data
(email/display_name) back to them — safe, because OAuth already proved they own that
identity. See the SECURITY NOTE in middleware/system_access.rs.

How each surface wires in#

temper-api. require_auth verifies the JWT, decodes it into RawJwtClaims, then calls
authenticate_token(&state, &raw, &token) — classification, the human email ladder, claim
construction and the deactivation gate all happen inside the seam — and injects the
resulting AuthenticatedProfile into request extensions. The gated router adds a
require_system_access layer that reads that extension and calls the seam's Level 2 for
its gate effect.
Note (typestate on temper-api is partial). Because temper-api enforces Level 2 as a
router layer rather than inside a handler, the SystemAuthorized token cannot easily
reach handlers as a value — the layer runs the gate for effect and discards the token.
The typestate benefit is fully realized on temper-mcp's imperative call site and only
partially on temper-api. Handlers continue to read AuthenticatedProfile from
extensions via the AuthUser extractor.
temper-mcp. require_mcp_auth verifies the JWT and injects two things into the HTTP
extensions: the decoded RawJwtClaims and the raw BearerToken (the ladder's /userinfo
rung needs the token itself, not its claims). ensure_profile_from_parts — called at the top
of every tool — pulls both back out and calls authenticate_token then
require_system_access back to back, caching the resolved profile for the tool body. Both
refusals route through map_authz_error.
temper-api's internal SAML reconcile handler is the third caller, on the federated path:
handlers/internal_saml.rs calls resolve_federated_human. See
reconcile-channel.md.

Two MCP behaviors this closed#

Both were live gaps on temper-mcp only, and both are consequences of the surface no longer
constructing its own principal.
An unnamable human is now refused, not auto-provisioned. temper-mcp previously set
email: String::new() and skipped the ladder entirely, so a human token with no email
claim and no cached auth link created a junk profile with an empty email. It now runs the
one ladder like temper-api and refuses with EmailResolution — before any write. A human
we cannot name is a human we will not provision. (A returning MCP human with no email
claim still works: rung 2, the cached kb_profile_auth_links email from an earlier
sign-in, answers.) Pinned by
emailless_unlinked_human_is_refused_without_provisioning and
human_without_email_claim_resolves_from_cached_link.
initialize no longer skips the deactivation gate. MCP's initialize used to call
resolve_from_claims directly as a best-effort cache seed, which bypassed Level 1's
is_active check: a deactivated account was refused on every tool call but could still
open an MCP session. It now goes through authenticate_token, and a refusal there
propagates rather than being warned past. (initialize runs Level 1 only; Level 2 still
runs per tool call in ensure_profile_from_parts.)

The parity test — at the production caller's level#

tests/e2e/tests/auth_seam_parity_e2e.rs proves a deactivated profile and a no-access
profile are refused identically on both surfaces:
The API surface is driven over HTTP through the real middleware stack.
The MCP surface is driven by constructing a TemperMcpService over the same test
pool and calling the production gate ensure_profile_from_parts with hand-built request
Parts carrying RawJwtClaims + BearerToken — exactly what require_mcp_auth injects.
This is the test the per-surface is_active gap would have failed. A direct-call unit test
over authenticate_token / authenticate / require_system_access (which also exists, in
the seam module — those tests are in-crate, so they can reach the pub(crate) gate) proves
the decisions, but it passes even if a surface forgot to wire the seam in — the e2e is
what proves the wiring. Keep both.
tests/e2e/tests/auth_seam_m2m_e2e.rs is the same discipline for machine tokens: it drives
the real MCP gate and asserts an unregistered client is rejected and creates nothing, a
registered Auth0 client is admitted, and a temper-issued client resolves on MCP too.
Run them: cargo make test-e2e (both need only test-db).
Modified at 2026-08-19 17:59:52
Previous
Temper auth & security
Next
Cognitive-map & resource authoring authorization
Built with