diff --git a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md
new file mode 100644
index 0000000..69a7c00
--- /dev/null
+++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md
@@ -0,0 +1,797 @@
+# Missions & PS Governance — Implementation Plan
+
+## Overview
+
+Bring the .NET SDK, samples, and docs into alignment with the AAuth spec's
+**mission** model and the **Person Server as the contextual policy evaluation
+point**. Fix the divergent `Mission` model, add the mission cryptographic
+binding through the token chain, implement the PS governance endpoints
+(client + minimal server seams), then update samples and docs to showcase the
+end-to-end mission flow, with a final multi-subagent review.
+
+See [research.md](research.md) for the full spec model and gap inventory
+(G1–G20). Every phase below cites the governing spec section in
+`aauth-spec/draft-hardt-oauth-aauth-protocol.md`.
+
+## Context
+
+- **Spec:** `draft-hardt-oauth-aauth-protocol` — §Missions, §Mission Approval,
+ §Mission Management, §Mission Status Errors, §Resource Token, §Auth Token,
+ §Authorization Endpoint Request (signed `aauth-mission`), §PS Token Endpoint,
+ §Clarification Chat, §Permission/Audit/Interaction Endpoints, §Person Server
+ Metadata, §Policy Evaluation Points, §Why Missions Are Not a Policy Language.
+- **Branch:** TBD.
+- **Sequencing:** Phases 1→5 are SDK; Phase 6 samples; Phase 7 docs; Phase 8 review.
+ Samples (6) and docs (7) are intentionally separate phases per the agreed
+ workflow.
+
+## Cross-Cutting Decisions
+
+The spec and SDK are both still in **draft** and backward compatibility is **not**
+a concern. Breaking changes to existing types, method signatures, DI registration,
+and the HTTP-signature covered-components contract are acceptable; callers are
+updated in place. This resolves prior open questions Q2 and Q3 directly:
+
+- **D1 — `Mission` replacement (resolved):** Replace `Mission` in place with the
+ spec blob. No rename, no `[Obsolete]` shim, no dual model — update all callers.
+- **D2 — `aauth-mission` signing (resolved):** Auto-cover `aauth-mission` in
+ `AAuthSigningHandler` when the header is present. No explicit
+ `AdditionalComponentsKey` fallback path is required.
+- **D3 — Server governance depth** (research Q4): SDK ships DTOs + serialization
+ + minimal endpoint mappers + store/relay interfaces; full policy stays in
+ MockPersonServer. (Scope choice, unaffected by compat.)
+- **D4 — Verbatim mission bytes** (research Q1): model retains raw approval body
+ bytes for `s256`; no re-serialization. (Correctness requirement for `s256`.)
+
+---
+
+## Phase 1 — Mission model & `s256` identity
+
+**Goal:** Replace the stale `Mission` model with the spec mission blob, store the
+verbatim approval bytes, and compute/verify `s256`. Fixes G1–G4, G17.
+
+**Spec:** §Mission Approval (blob fields; `s256` = base64url(SHA-256(exact body
+bytes)), store bytes "no re-serialization"); §Mission Management (states
+`active`/`terminated`); §Mission Approval (`capabilities` union into
+`AAuth-Capabilities`).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/Mission.cs` | **Rewrite** — spec blob fields + raw bytes + `s256` |
+| `src/AAuth/Agent/MissionState.cs` | **New** — `active`/`terminated` |
+| `src/AAuth/Agent/MissionTool.cs` | **New** — `{name, description}` record |
+| `src/AAuth/Crypto/` (existing hash util) or `Agent/Mission.cs` | **Modify/Use** — SHA-256 + base64url over raw bytes |
+| `src/AAuth/Agent/AAuthCapabilitiesHeader.cs` | **Modify** — `Union(missionCapabilities, agentCapabilities)` |
+| `tests/AAuth.Conformance/Missions/MissionModelTests.cs` | **New** |
+| `tests/AAuth.Conformance/Missions/MissionS256Tests.cs` | **New** |
+
+### API Surface (illustrative)
+
+```csharp
+public sealed class Mission
+{
+ public required string Approver { get; init; }
+ public required string Agent { get; init; }
+ public required DateTimeOffset ApprovedAt { get; init; }
+ public required string Description { get; init; } // Markdown
+ public IReadOnlyList ApprovedTools { get; init; }
+ public IReadOnlyList Capabilities { get; init; }
+ public required string S256 { get; init; } // computed identity
+ public ReadOnlyMemory RawBytes { get; } // verbatim approval body
+
+ public static Mission FromApprovalBytes(ReadOnlySpan body); // parse + compute s256
+ public bool VerifyS256(string expected);
+}
+```
+
+### Implementation Decisions
+
+- D1: replace `Mission` in place; update all callers (no shim).
+- D4 byte source: parse from the `mission_endpoint` response body bytes.
+
+### Definition of Done
+
+- [x] `Mission` exposes spec blob fields; non-spec fields removed.
+- [x] States limited to `active`/`terminated` (§Mission Management).
+- [x] `s256` computed as base64url(SHA-256(raw bytes)); raw bytes stored verbatim.
+- [x] `VerifyS256` round-trips against a known-good blob fixture.
+- [x] `AAuthCapabilitiesHeader.Union` merges mission ∪ agent capabilities (deduped).
+- [x] Model + `s256` tests pass; no re-serialization in the hash path.
+- [x] All existing callers updated to the new model (no shim).
+
+---
+
+## Phase 2 — Mission binding through the token chain
+
+**Goal:** Carry `{approver, s256}` as the `mission` claim in resource and auth
+tokens, surface it on verification, and ensure `aauth-mission` is covered by the
+HTTP signature. Fixes G9–G12.
+
+**Spec:** §Resource Token Structure (`mission` claim when `AAuth-Mission`
+present; `agent_jkt`); §Resource Token Verification step 7; §Auth Token
+Structure (`mission` claim); §Auth Token Verification; §Authorization Endpoint
+Request (~L619, add `aauth-mission` to signed components).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Tokens/ResourceTokenBuilder.cs` | **Modify** — first-class `Mission` ({approver,s256}) claim |
+| `src/AAuth/Tokens/AuthTokenBuilder.cs` | **Modify** — first-class `Mission` claim |
+| `src/AAuth/Tokens/TokenVerifier.cs` | **Modify** — surface `mission` on auth-token verify result |
+| `src/AAuth/Tokens/VerifiedToken*.cs` | **Modify** — expose parsed `mission` |
+| `src/AAuth/HttpSig/AAuthSigningHandler.cs` | **Modify** — auto-cover `aauth-mission` when header present (D2) |
+| `src/AAuth/HttpSig/AAuthVerifier.cs` | **Modify** (added) — accept `mission` param + validate `aauth-mission` covered component |
+| `src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs` | **Modify** (added) — pass `AAuth-Mission` header into the verifier |
+| `src/AAuth/Tokens/MissionClaim.cs` | **New** (added) — `{approver, s256}` value carried in tokens |
+| `tests/AAuth.Conformance/Missions/MissionClaimTests.cs` | **New** (placed under `Missions/`) |
+| `tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs` | **New** |
+
+### Implementation Decisions
+
+- D2 (resolved): auto-cover `aauth-mission` in `AAuthSigningHandler` when the
+ header is present; signing method signatures may change as needed.
+- D5 (resolved, user-approved): the verifier side (`AAuthVerifier` +
+ `AAuthVerificationMiddleware`) is extended in Phase 2 so signed mission
+ requests round-trip; without it every mission-context request would fail
+ HTTP-signature verification.
+- D6 — covered-component ordering (resolved per spec): `aauth-mission` is the
+ **last** covered component, after `signature-key` (spec §Authorization
+ Endpoint Request example, mission context). The pre-existing `authorization`
+ handling (appended after `signature-key`) is left unchanged — re-aligning it
+ to the spec's `authorization`-before-`signature-key` example is out of Phase 2
+ scope. Verifier accepts the optional trailing pair `authorization` then
+ `aauth-mission`, in that order.
+
+### Definition of Done
+
+- [x] `ResourceTokenBuilder` emits `mission` claim when a mission is present (§Resource Token).
+- [x] `AuthTokenBuilder` emits `mission` claim when a mission is present (§Auth Token).
+- [x] `TokenVerifier` exposes the verified `mission` claim on auth tokens.
+- [x] When `AAuth-Mission` header present, `aauth-mission` appears in
+ `Signature-Input` covered components (§Authorization Endpoint Request).
+- [x] Covered-components contract updated; token/signature tests adjusted to match.
+
+---
+
+## Phase 3 — PS token-request params, clarification chat, mission errors
+
+**Goal:** Complete the PS token endpoint client surface: missing request
+parameters, the clarification chat loop, and `mission_terminated` handling.
+Fixes G13, G14, G16.
+
+**Spec:** §Agent Token Request (params `justification`, `login_hint`, `tenant`,
+`domain_hint`, `platform`, `device`); §Clarification Chat (`requirement=
+clarification`; agent responses: `clarification_response` POST / updated
+`resource_token` POST / `DELETE` cancel; round limit; sanitization is PS-side);
+§Mission Status Errors (`403 mission_terminated`).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — add `Justification`, `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device`, `OnClarificationRequired`, `MaxClarificationRounds` |
+| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — emit new params; clarification loop; `mission_terminated` classification |
+| `src/AAuth/Agent/ClarificationExchange.cs` | **New** — `ClarificationResponse` decision object + respond / update / cancel actions + round tracking |
+| `src/AAuth/Agent/AAuthInteractionExceptions.cs` | **Modify** — add `AAuthClarificationCancelledException`, `AAuthClarificationLimitException` |
+| `src/AAuth/Headers/ClarificationRequirement.cs` | **New** — parse `{clarification, timeout?, options?}` |
+| `src/AAuth/Errors/TokenError.cs` | **Modify** — add `mission_terminated` |
+| `src/AAuth/Errors/AAuthMissionTerminatedException.cs` | **New** |
+| `tests/AAuth.Conformance/Missions/ClarificationChatTests.cs` | **New** |
+| `tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs` | **New** |
+| `tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs` | **New** — covers the six token-request params |
+
+> **Deviation:** `DeferredPoller.cs` was **not** modified. POST/DELETE to the
+> pending URL live in `ClarificationExchange` (its own `HttpClient`), and the
+> clarification stop reuses the existing `DeferredPollerOptions.StopWhenAccepted`
+> predicate (composed via `ComposePollerOptions`) — see research Part 5, Phase 3.
+
+### Definition of Done
+
+- [x] All six token-request params serialized into the POST body (§Agent Token Request).
+- [x] `requirement=clarification` parsed into a typed model (question/timeout/options).
+- [x] Agent can `clarification_response` POST, updated-`resource_token` POST, and
+ `DELETE`-cancel against the pending URL (§Agent Response to Clarification).
+- [x] Clarification round limit enforced (default 5) (§Clarification Limits).
+- [x] `403 mission_terminated` → `AAuthMissionTerminatedException` across PS calls
+ (§Mission Status Errors).
+- [x] New tests pass; existing deferred/interaction tests unaffected.
+
+---
+
+## Phase 4 — PS governance clients + metadata discovery
+
+**Goal:** Add agent-side clients for `mission_endpoint`, `permission_endpoint`,
+`audit_endpoint`, `interaction_endpoint`, with DTOs, and parse the missing
+metadata endpoints. Fixes G5–G8, G15, G19.
+
+**Spec:** §Mission Creation; §Permission Endpoint; §Audit Endpoint;
+§Interaction Endpoint; §Person Server Metadata. Audit **requires** a mission;
+permission/interaction optional. Reuse the deferred/`202` loop from Phase 3.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Discovery/ServerMetadata.cs` | **Modify** — parse `permission_endpoint`, `audit_endpoint` |
+| `src/AAuth/Agent/Governance/MissionClient.cs` | **New** — propose/approve (handles `202` review) |
+| `src/AAuth/Agent/Governance/PermissionClient.cs` | **New** — `{action,description?,parameters?,mission?}` → granted/denied |
+| `src/AAuth/Agent/Governance/AuditClient.cs` | **New** — fire-and-forget `201` (requires mission) |
+| `src/AAuth/Agent/Governance/InteractionClient.cs` | **New** — interaction/payment/question/completion |
+| `src/AAuth/Agent/Governance/*Request.cs` / `*Response.cs` | **New** — DTOs (MissionProposal, PermissionRequest/Result, AuditRecord, InteractionRequest/Result) |
+| `src/AAuth/Agent/Governance/GovernanceExchange.cs` | **New (deviation)** — shared signed-POST + deferred-`202` loop + endpoint origin-pinning; `GovernanceOptions` |
+| `tests/AAuth.Conformance/Missions/GovernanceClientTests.cs` | **New** (12 tests) |
+
+### Definition of Done
+
+- [x] `ServerMetadata` parses all four governance endpoints (§Person Server Metadata).
+- [x] `MissionClient.ProposeAsync` returns an approved `Mission` (verifies `s256`,
+ handles `202` review/clarification) (§Mission Creation).
+- [x] `PermissionClient.RequestAsync` returns granted/denied, honoring
+ `approved_tools` short-circuit and deferred responses (§Permission Endpoint).
+- [x] `AuditClient.RecordAsync` is fire-and-forget, requires a mission, expects
+ `201` (§Audit Endpoint).
+- [x] `InteractionClient` supports all four `type` values incl. `completion`
+ terminate/continue (§Interaction Endpoint).
+- [x] `mission_terminated` surfaces from each client (Phase 3 exception).
+- [x] Client tests pass against a stub PS.
+
+---
+
+## Phase 5 — PS server-side governance seams + mission log
+
+**Goal:** Provide minimal SDK primitives so a PS (e.g. MockPersonServer) can
+serve the governance endpoints without hand-rolling parsing: request parsing,
+response helpers, store/relay interfaces, and a mission-log seam. Fixes G18, G20
+(per decision D3 — thin seams, not a full PS).
+
+**Spec:** §PS Governance Endpoints; §Mission Log; §Mission Status Errors;
+§Policy Evaluation Points (PS evaluates against mission intent + log).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Server/Governance/IMissionStore.cs` | **New** — persist blob bytes + state; `StoredMission` record |
+| `src/AAuth/Server/Governance/InMemoryMissionStore.cs` | **New (deviation)** — default in-memory store (mirrors `InMemoryJtiStore`) |
+| `src/AAuth/Server/Governance/IPermissionDecider.cs` | **New** — `PermissionDecision` + reason enum + context |
+| `src/AAuth/Server/Governance/IAuditSink.cs` | **New** |
+| `src/AAuth/Server/Governance/IInteractionRelay.cs` | **New** |
+| `src/AAuth/Server/Governance/IMissionLog.cs` | **New** — ordered append; read; prior-consent lookup |
+| `src/AAuth/Server/Governance/InMemoryMissionLog.cs` | **New (deviation)** — default in-memory log |
+| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **New** — request parsers + `mission_terminated` helper |
+| `src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs` | **New** — `AddAAuthGovernance` registers storage seams |
+| `tests/AAuth.Conformance/Missions/GovernanceServerTests.cs` | **New** (17 tests) |
+
+### Implementation Decisions
+
+- D3 boundary: SDK = DTO parse + helpers + interfaces; policy/UI in mock.
+- `IPermissionDecider` returns a typed decision **with a reason** (in-scope /
+ prior consent / `approved_tools` / out-of-scope → prompt) so a PS can both act
+ on it and surface it to UIs; the SDK provides the inputs + reason enum, the PS
+ owns the policy (§Agent Token Request L385/L784/L828, §Permission L1017).
+
+### Definition of Done
+
+- [x] Request parsers for permission/audit/interaction/mission-create map to DTOs.
+- [x] `mission_terminated` helper emits spec `403` body (§Mission Status Errors).
+- [x] `IMissionStore` stores verbatim blob bytes + `active`/`terminated` state.
+- [x] `IMissionLog` appends token/permission/audit/interaction/clarification
+ entries in order, and supports a prior-consent read keyed by
+ `(s256, resource, scope)` (§Mission Log, §Agent Token Request L784).
+- [x] `IPermissionDecider` is invoked with mission + log context for the consent
+ decision (§Person Server L385).
+- [x] DI registration wires the seams.
+- [x] Server-seam tests pass.
+
+---
+
+## Phase 5.5 — Shared deferred transport + governance facade
+
+**Goal:** Remove the duplication between `TokenExchangeClient` and the Phase 4
+`GovernanceExchange` by extracting a single internal deferred-HTTP transport
+(T1 + T2), and hide governance-client construction behind a public facade and a
+builder factory so callers don't hand-wire the signed `HttpClient` + four
+clients (A + B). No behavioural change — the existing 417 conformance + 371 unit
+tests are the regression gate.
+
+**Spec:** §User Interaction; §Clarification Chat; §Mission Status Errors;
+§PS Governance Endpoints; §Person Server Metadata. (Pure refactor — same wire
+behaviour, same spec citations as Phases 3–5.)
+
+**Rationale (from feasibility review):** `GovernanceExchange` and
+`TokenExchangeClient` share ~120 lines of identical logic — endpoint origin-pin,
+the `202` deferred loop (interaction + clarification), `ComposePollerOptions`,
+`BufferBodyAsync` / `ReadJsonBodyAsync` / `ExtractRequirement` / `ResolveLocation`,
+the `mission_terminated` reader, and `AddIfPresent`. `TokenExchangeClient` is
+never exposed — `AAuthClientBuilder` constructs it internally and runs it behind
+a `ChallengeHandler` (`AAuthClientBuilder.cs` ~L495). Governance operations are
+*deliberate* (not challenge-driven) so they can't hide behind a handler, but
+their construction can be hidden the same way the builder already hides the
+token client's `(signedClient, metadata)` pair.
+
+### Implementation Decisions
+
+- **D8 (T1+T2 — single transport):** Introduce `internal sealed class
+ DeferredExchange` (in `AAuth.Agent`) as the one transport: `ResolveEndpointAsync`
+ + `PostAsync(endpoint, body, DeferredExchangeOptions, ct)` returning the
+ terminal `HttpResponseMessage` (caller parses + disposes), throwing
+ `AAuthMissionTerminatedException` on a terminal `403 mission_terminated`. It owns
+ all the shared helpers and the `AAuth.DeferredPoll` activity. `GovernanceExchange`
+ is **deleted**; governance clients use `DeferredExchange` directly, adapting
+ `GovernanceOptions` → `DeferredExchangeOptions`. `TokenExchangeClient.ExchangeAsync`
+ keeps its token-specific concerns (body builder + capability inference + the
+ `AAuth.TokenExchange` activity + `access_denied` classification + `auth_token` /
+ token-error reading) and delegates the transport to `DeferredExchange.PostAsync`.
+ `access_denied` moves from inside the poll loop to a post-`PostAsync` 403
+ classifier (a `403 access_denied` body is not `mission_terminated`, so
+ `PostAsync` returns it unthrown — order preserved). `TokenExchangeRequest` and
+ the public `TokenExchangeClient` API are **unchanged**.
+- **D9 (A+B — facade + factory):** Add public `AAuthGovernanceClient` bundling
+ `Mission` / `Permission` / `Audit` / `Interaction` over one signed `HttpClient`
+ + `MetadataClient` (ctor `(HttpClient signedClient, MetadataClient metadata)`;
+ the four sub-clients stay public for advanced use). Add
+ `AAuthClientBuilder.BuildGovernance()` returning an `AAuthGovernanceClient`
+ built from the **same** exchange pipeline the builder already constructs
+ (`AAuthClientBuilder.cs` ~L480–L495); factor that `(HttpClient signed,
+ MetadataClient metadata)` construction into a private helper shared by
+ `BuildHandler` and `BuildGovernance`. DI (`AddAAuthAgentGovernance`) is
+ **out of scope** here — deferred until a sample needs it (Phase 6).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/DeferredExchange.cs` | **New** — shared transport + `DeferredExchangeOptions` + all shared helpers (absorbs `GovernanceExchange`) |
+| `src/AAuth/Agent/Governance/GovernanceExchange.cs` | **Delete** — replaced by `DeferredExchange`; `GovernanceOptions` moves to its own file |
+| `src/AAuth/Agent/Governance/GovernanceOptions.cs` | **New** — public `GovernanceOptions` (moved out of the deleted file) |
+| `src/AAuth/Agent/TokenExchangeClient.cs` | **Modify** — delegate transport to `DeferredExchange`; keep token-specific body/error/diagnostics |
+| `src/AAuth/Agent/Governance/{Mission,Permission,Audit,Interaction}Client.cs` | **Modify** — use `DeferredExchange`; adapt `GovernanceOptions` → `DeferredExchangeOptions` |
+| `src/AAuth/Agent/Governance/AAuthGovernanceClient.cs` | **New** — public facade bundling the four clients |
+| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — extract shared `(signed HttpClient, MetadataClient)` build helper; add `BuildGovernance()` |
+| `tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs` | **New** — facade construction + `BuildGovernance()` wiring |
+
+### Definition of Done
+
+- [x] Single `DeferredExchange` transport; `GovernanceExchange.cs` deleted; no
+ duplicated deferred-loop / buffer / requirement helpers remain.
+- [x] `TokenExchangeClient` delegates transport to `DeferredExchange`; its public
+ API and wire behaviour are unchanged (`access_denied`, `mission_terminated`,
+ token-error codes, diagnostics activities all preserved).
+- [x] `AAuthGovernanceClient` facade exposes mission/permission/audit/interaction
+ over one signed client; sub-clients remain public.
+- [x] `AAuthClientBuilder.BuildGovernance()` returns a facade wired from the same
+ signed exchange pipeline as `BuildHandler()` (shared private helper).
+- [x] Full conformance (417 → **422** with new facade tests) + unit (371) suites
+ pass unchanged; new facade tests pass; SDK + full solution build 0/0.
+
+**Deviations (as built):**
+
+- The token-only `access_denied` classification and the token-only
+ fail-fast-without-callback behaviour are preserved through two
+ `DeferredExchangeOptions` seams rather than living in `TokenExchangeClient`:
+ `RequireInteractionCallback` (token = `true`, governance = `false`) reproduces
+ the token-exact "no onInteractionRequired callback" message, and
+ `OnPolledResponse` (token only) runs the `403 access_denied` classifier *after*
+ an interaction-branch poll (not after a clarification poll or the
+ initial/direct response), matching the original placement.
+- `ResolveEndpointAsync(personServer, field, ct)` emits generic `'{field}'`
+ error text that is byte-identical to the original `'token_endpoint'` messages
+ when `field == "token_endpoint"`.
+- The shared signed-channel helper is `BuildSignedChannel(provider, innerHandler)`.
+ `BuildHandler` passes `new HttpClientHandler()` (preserving the prior exchange
+ signer's exact inner handler); `BuildGovernance` passes
+ `_innerHandler ?? new HttpClientHandler()` so tests can inject a stub.
+- `BuildGovernance()` requires an explicit signing mode (`_provider`); it does
+ **not** reconstruct the lazy-refresh token-holder pipeline (that path stays
+ exclusive to `BuildHandler`). Throws `InvalidOperationException` otherwise.
+- `AAuth.DeferredPoll` now also fires for governance polls (additive
+ observability via the shared `DeferredExchange`, not a wire change).
+- DI (`AddAAuthAgentGovernance`) remains out of scope — deferred to Phase 6.
+
+---
+
+## Phase 6 — Samples
+
+**Goal:** Showcase the end-to-end mission flow across actors. Update
+MockPersonServer to a real governance PS and add an agent-side mission demo.
+
+**Spec:** §Missions; §PS Governance Endpoints; §Call Chaining (mission context);
+§Agent Token Request (consent decision: L385, L784, L828); §Permission Endpoint
+(`approved_tools`: L1017); the three worked flows in research (single-resource,
+multi-resource + permission/audit/interaction, multi-hop chaining).
+
+Missions are an **optional, orthogonal** governance layer (§Overview L141, L201;
+§Missions L397). Existing samples demonstrate valid no-mission flows and are
+**not** spec-incorrect; the mission flow is **added alongside** them, not a
+rewrite of existing flows.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `samples/MockPersonServer/Program.cs` | **Modify** — serve `mission_endpoint`, `permission_endpoint`, `audit_endpoint`, `interaction_endpoint`; embed `mission` claim in issued auth tokens; compute `s256`; implement the three-gate consent decision and record a decision reason (in-scope / prior consent / `approved_tools` / out-of-scope) in the mission log so samples can display it; expose a minimal "terminate mission" demo hook; maintain mission log via Phase 5 seams |
+| `samples/MockPersonServer/README.md` | **Modify** |
+| `samples/MissionAgent/` | **New** — dedicated CLI agent: propose mission → operate under it → permission + audit + interaction → completion |
+| `samples/Orchestrator/Program.cs` | **Modify** — show mission-governed downstream hop (call chaining) |
+| `samples/SampleApp/Components/Pages/Mission.razor` | **New** — golden one-page mission example; visualizes all three consent gates, each labelled **prompt** vs **silent (in scope)** |
+| `samples/SampleApp/Components/Pages/Home.razor` | **Modify** — add mission page link/card |
+| `samples/GuidedTour/TourOptions.cs` | **Modify** — add `TourMode.Mission` |
+| `samples/GuidedTour/TourSession.cs` | **Modify** — multi-step mission plan that drives each gate through **both** outcomes: (1) mission approval prompt; (2) in-scope token request that resolves **silently**; (3) out-of-scope token request that **prompts**; (4) `approved_tools` permission that resolves **silently**; (5) non-pre-approved permission that **prompts**; then audit / interaction / complete |
+| `samples/GuidedTour/CodeSnippets.cs` | **Modify** — mission client snippets |
+| `samples/GuidedTour/Components/SequenceDiagram.razor` (+ `EntityHighlighter`) | **Modify** — render mission interactions; mark each step **prompt** vs **silent** and show the decision reason (in-scope / prior consent / `approved_tools`) |
+| `samples/GuidedTour/playwright-tests/mission.spec.ts` | **New** — drives `TourMode.Mission`; asserts each gate's **prompt** vs **silent** outcome + decision reason |
+| `samples/SampleApp/playwright-tests/mission.spec.ts` | **New** — exercises `Mission.razor`; asserts prompt vs silent gate labels |
+| `tests/e2e/playwright.config.ts` | **Modify (if needed)** — mission specs reuse existing `guided-tour`/`sample-app` projects + booted backends; add a PS env/policy toggle only if the silent-vs-prompt scenario needs pre-seeded `approved_tools` |
+| `tests/e2e/README.md` | **Modify** — document the mission specs |
+| `tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs` (or a dedicated integration test project) | **New** — **.NET integration test**: boot MockPersonServer + WhoAmI + MockAgentProvider, run `samples/MissionAgent`, and assert **every consent permutation** (see Consent Test Matrix) plus the `mission_terminated` path |
+| `Makefile` | **Modify** — add `demo-mission` target and an `e2e-mission` target |
+| `tests/e2e/package.json` | **Modify** — add `test:mission` script (Blazor mission specs) |
+| `samples/README.md` | **Modify** — index the mission demo |
+
+### Implementation Decisions
+
+- **Test split:** Blazor apps (GuidedTour, SampleApp) are covered by **Playwright
+ e2e** in `tests/e2e/`; the `MissionAgent` CLI is covered by a **.NET
+ integration test** under `tests/` (spawns servers + CLI), run via `dotnet test`.
+- **Sample shape:** new dedicated `samples/MissionAgent/` (not extending
+ AgentConsole) for a legible showcase; SampleApp gets a new `Mission.razor`
+ page; GuidedTour gets a new `TourMode.Mission` with a multi-step plan
+ (mirroring the federated multi-step style). Existing flows untouched.
+- **`make demo` integration:** separate `make demo-mission` target (like
+ `demo-keycloak`) to keep the default `make demo` bundle uncluttered.
+- **Consent decision (three gates)** — the PS prompts the user only when needed,
+ not on every deferred point (§Agent Token Request L385/L784/L828, §Permission
+ L1017):
+ 1. **Mission approval** — always prompt once at mission proposal
+ (§Mission Creation).
+ 2. **Token request** — silent when the resource+scope is within the approved
+ mission intent or matches a remembered prior consent in the mission log;
+ otherwise prompt (L385, L784, L828).
+ 3. **Permission request** — silent when the action matches `approved_tools`;
+ otherwise prompt (L1017).
+ Prior-consent memory keyed by `(mission s256, resource, scope)`. The decision
+ is mock PS policy implemented over the Phase 5 `IPermissionDecider` /
+ `IMissionStore` / mission-log seams — not SDK behavior.
+- **Sub-phasing (agreed 2026-06-05):** Phase 6 executes in three committable
+ sub-phases, each independently buildable + tested:
+ - **6a — Backend foundation (DONE):** MockPersonServer governance endpoints + `s256`
+ / mission claim emission + three-gate consent + terminate hook; new
+ `samples/MissionAgent/` CLI; the 12-row Consent-Matrix .NET integration test.
+ - **6b — Blazor + e2e (DONE):** SampleApp `Mission.razor` (+ Home link); GuidedTour
+ `TourMode.Mission` (+ snippets, sequence diagram); the two Playwright specs.
+ - **6c — Glue (DONE, except deferred hop):** `make demo-mission` / `agent-mission`
+ (pulled into 6a); READMEs updated (samples index + MockPersonServer governance
+ section + GuidedTour Mission mode); call-chain samples annotated that mission
+ governance is optional/orthogonal and omitted (§Call Chaining no-mission path).
+ **Deferred (2026-06-06, user decision):** the Orchestrator mission-governed
+ downstream hop is NOT implemented; instead the call-chain samples carry comments
+ explaining missions are optional and left out of the demo. The existing
+ Orchestrator already follows the spec's valid "no mission, `iss` is a PS" path.
+- **Deterministic consent scripting (agreed 2026-06-05 — option A):** the
+ integration test drives mission-approval / token / permission outcomes by
+ extending the existing unsigned `/admin/*` demo pattern (e.g.
+ `/admin/mission-decision`, `/admin/permission-decision`, plus pre-seeding
+ `approved_tools` / prior-consent), mirroring today's `/admin/consent`. No
+ config/convention-encoded policy.
+
+
+#### Consent Test Matrix (CLI integration test)
+
+The `MissionAgent` integration test MUST cover **all** permutations of the three
+gates — both decision outcomes (approve/deny) and both PS paths (prompt/silent):
+
+| # | Gate | Scenario | Expected |
+|---|------|----------|----------|
+| 1 | Mission approval | user **approves** the proposed mission | `active` mission, `s256` bound |
+| 2 | Mission approval | user **denies** the proposed mission | no mission; agent aborts cleanly |
+| 3 | Token request | resource+scope **within** approved mission intent | **silent**, reason = in-scope |
+| 4 | Token request | resubmit same `(s256, resource, scope)` after prior consent | **silent**, reason = prior consent |
+| 5 | Token request | **out-of-scope** resource/scope → user **approves** | prompt → auth token issued |
+| 6 | Token request | **out-of-scope** resource/scope → user **denies** | prompt → access denied |
+| 7 | Token request (clarification) | user asks a question; agent responds; user approves | clarification round then issue |
+| 8 | Token request (clarification) | agent **cancels** via `DELETE` | pending `410 Gone`; no token |
+| 9 | Permission | action **in** `approved_tools` | **silent**, reason = approved_tools |
+| 10 | Permission | action **not** pre-approved → user **approves** | prompt → granted |
+| 11 | Permission | action **not** pre-approved → user **denies** | prompt → denied |
+| 12 | Termination | mission terminated mid-flow, agent retries | `403 mission_terminated` surfaced |
+
+The PS consent decisions are driven deterministically in the test (scripted
+approve/deny + pre-seeded `approved_tools` / prior-consent state) so each row is
+reproducible without manual interaction. Each row also asserts the recorded
+mission-log **decision reason**.
+
+### Definition of Done
+
+- [x] MockPersonServer serves all four governance endpoints (§PS Governance). _(6a)_
+- [x] MockPersonServer embeds `{approver, s256}` in issued auth tokens (§Auth Token). _(6a)_
+- [x] MockPersonServer implements the three-gate consent decision: mission approved
+ once, then resource/tool access proceeds without re-prompting unless outside
+ approved scope / `approved_tools` (§Agent Token Request, §Permission Endpoint). _(6a)_
+- [x] MockPersonServer exposes a minimal "terminate mission" hook so the
+ `mission_terminated` path is exercised end-to-end (§Mission Status Errors). _(6a)_
+- [x] `samples/MissionAgent/` proposes a mission, accesses ≥1 resource under it,
+ requests a permission, records an audit entry, relays an interaction, and
+ completes it. _(6a)_
+- [x] SampleApp `Mission.razor` page renders the flow and labels each consent gate
+ as **prompt** or **silent (in scope)**; Home links to it; existing pages
+ unchanged. _(6b)_
+- [x] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes
+ (mission approval prompt; in-scope token silent vs out-of-scope token prompt;
+ `approved_tools` permission silent vs non-pre-approved permission prompt) and
+ surfaces the PS decision reason for each; existing modes unchanged. _(6b)_
+- [x] The PS decision reason (in-scope / prior consent / `approved_tools` /
+ out-of-scope) is visible in both samples so the contrast between prompted
+ and silent gates is observable. _(6b)_
+- [ ] Orchestrator demonstrates a mission-governed downstream hop (§Call Chaining).
+ _(6c — DEFERRED by user decision 2026-06-06; call-chain samples instead carry
+ comments noting mission governance is optional/orthogonal and omitted. The
+ Orchestrator follows the spec's valid "no mission, `iss` is a PS" path.)_
+- [x] `make demo-mission` boots the mission demo; existing `make demo` unchanged.
+ _(pulled forward from 6c; also added `agent-mission` runner)_
+- [x] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the
+ existing `guided-tour`/`sample-app` projects; existing specs still pass.
+ _(6b — full suite 29 passed / 1 skipped locally)_
+- [x] **.NET integration test** for the `MissionAgent` CLI covers **all 12 rows**
+ of the Consent Test Matrix (every gate × approve/deny × prompt/silent),
+ including clarification and `mission_terminated`, each asserting the
+ recorded decision reason. _(6a — `MissionAgentFlowTests`, 12/12)_
+- [x] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally.
+ _(CLI integration 12/12; Blazor e2e full suite 29 passed / 1 skipped locally; CI not separately run)_
+- [x] Sample READMEs updated. _(MissionAgent README shipped in 6a; in 6c:
+ samples/README.md index adds MissionAgent + mission Quick Start,
+ MockPersonServer README adds the governance-endpoints section,
+ GuidedTour README adds Mission mode; call-chain samples (Orchestrator
+ Program.cs, SampleApp CallChain.razor) annotated that mission governance
+ is optional/orthogonal and intentionally omitted, per §Call Chaining
+ no-mission path)_
+
+#### Phase 6a additions (spec-driven, beyond the original file list)
+
+- **Mission-aware resource (SDK):** `AAuthMissionHeader.TryParseStructured`,
+ `ChallengeOptions.MissionAware`, and `AAuthChallengeMiddleware` copying the
+ parsed `{approver, s256}` into `ResourceTokenBuilder.Mission` so a resource can
+ surface the mission claim in the resource token it issues (§Terminology,
+ §Auth Token). Demonstrated by WhoAmI `/jwt/mission`. Covered by +3 conformance
+ tests (`ChallengeMiddlewareTests`, total 425).
+- **Interactive mission-creation consent screen:** `/mission` defers (202 +
+ interaction) to a real browser consent screen when running interactively
+ (`MissionConsentScript.InteractiveBrowser`), via the same deferred path the
+ token/permission gates use (SDK `MissionClient.ProposeAsync` already routes
+ through `DeferredExchange`). The PS `/interaction` page now renders all three
+ consent screens — mission creation (description + tools), out-of-scope token
+ (mission + tools + resource/scope), and out-of-tool permission (mission +
+ tools + action) — keeping the demo faithful to §Mission Creation /
+ §Permission Endpoint (`action` per-call vs mission `approved_tools`).
+ Scripted mode (the 12-row test) is unaffected (`InteractiveBrowser = false`).
+
+#### Phase 6b amendments (2026-06-06, spec-driven, added mid-phase)
+
+These refine the consent UX and **add the missing out-of-mission scope gate**.
+The original Phase 6 plan (file list, line "out-of-scope token request that
+**prompts**") always intended a prompted **token/scope** gate, but 6b shipped
+only the prompted **tool** gate (`delete_inbox`). These steps close that gap so
+both halves of the spec's gate model — a prompted *scope* (§Agent Token Request
+gate 3) **and** a prompted *tool* (§Permission Endpoint) — are demonstrated.
+
+**Spec grounding:**
+
+- **Tools are declared; scopes are evaluated** (§Mission Creation L1233 — proposal
+ is `description` + optional `tools` only, no scopes; §Mission Approval L1299–1303
+ — blob carries `approved_tools`, never scopes). The mission proposal lists no
+ scopes; the PS determines required scopes **per request, over the mission's
+ whole life** (§Scopes L1793 "The PS evaluates requested scopes against mission
+ context"; §Concurrent Token Requests L828 "some requests may be resolved
+ without user interaction … while others may require consent").
+- **Out-of-mission scope ⇒ prompt, not auto-deny** (§Agent Token Request gate 3;
+ §Scopes L1793). Only an explicit user deny (or `mission_terminated`, gate 1)
+ yields `access_denied`.
+
+**Decisions (agreed 2026-06-06 via interview):**
+
+- **D6 — New mission-aware endpoint + new scope (not reuse `whoami:admin`).**
+ WhoAmI gains a second mission-aware endpoint guarded by a **new** resource
+ scope so the out-of-mission scenario is clearly distinct from the existing
+ non-mission `/jwt/admin` step-up demo. Proposed: scope `whoami:history`
+ ("See your full account/profile history") at endpoint `/jwt/history`, wired
+ with `ChallengeForMission(ScopeWhoamiHistory)`. Under the seeded inbox mission
+ (in-scope = `whoami` only), requesting `whoami:history` falls outside the
+ mission → PS prompts (gate 3). _(final names confirmed at implementation.)_
+- **D7 — Add as a NEW gate (5 gates total), existing gates unchanged.** Final
+ order: (1) mission approval **PROMPT** → (2) `whoami` token **SILENT** (in
+ scope) → (3) `whoami:history` token **PROMPT** (out-of-mission scope) → (4)
+ `send_email` tool **SILENT** (pre-approved) → (5) `delete_inbox` tool
+ **PROMPT** (not pre-approved).
+- **D8 — "Agent console app" = `samples/MissionAgent/`** (the CLI), not
+ `samples/AgentConsole/` (which has no mission support and no mermaid). Its
+ README sequence diagram gains the new out-of-mission scope consent block.
+- **D9 — Consent-screen UX refinements (already applied in 6b):** PS
+ `/interaction` shows **scopes and tools as separate lists**; a spec-grounded
+ **tool (local) vs scope (remote)** definition box; the **creation screen lists
+ no scopes** (only a note that the PS determines them per-request from the
+ mission description); post-creation gates relabel the scope list **"Granted so
+ far"** (accrual), with empty state "nothing yet — this is the first request".
+
+### Amendment files
+
+| File | Action |
+|------|--------|
+| `samples/WhoAmI/Program.cs` | **Modify** — add scope `whoami:history` (+ `scope_descriptions` entry, scope policy) and a mission-aware endpoint `/jwt/history` via `ChallengeForMission`; exclude `/jwt/history` from the baseline `/jwt` branch; list it in the index payload |
+| `samples/WhoAmI/README.md` | **Modify** — document the new scope + endpoint |
+| `samples/SampleApp/Components/Pages/Mission.razor` | **Modify** — insert the out-of-mission **scope** gate (gate 3) between the silent `whoami` token and the tool gates; client + resource panels show `/protected_endpoint` requesting the elevated scope; 5-gate narrative |
+| `samples/GuidedTour/TourSession.cs` | **Modify** — extend `MissionPlan` with an out-of-mission scope token cycle (challenge → 202 PROMPT → approve → poll → exchange); renumber steps + approval/poll constants |
+| `samples/GuidedTour/CodeSnippets.cs` | **Modify** — add the out-of-mission scope snippet |
+| `samples/GuidedTour/Components/Pages/Tour.razor` | **Modify** — mission lane/flow text reflects the new scope gate |
+| `samples/MockPersonServer/Program.cs` | **Modify (done in 6b amendments)** — separate scope/tool lists, definition box, creation screen drops scopes + adds determined-per-request note, "Granted so far" relabel |
+| `samples/MissionAgent/Program.cs` | **Modify** — add a step that requests the out-of-mission scope under the mission (prompted token gate) |
+| `samples/MissionAgent/README.md` | **Modify** — add the out-of-mission scope consent block to the mermaid sequence diagram + the gate table / "Scope (remote) vs tool (local)" prose |
+| `docs/concepts.md` | **Modify (done)** — tools declared vs scopes evaluated; per-request, lifetime-long scope determination |
+| `samples/SampleApp/playwright-tests/mission.spec.ts` | **New/Modify** — assert the 5th gate (out-of-mission scope **prompt**) |
+| `samples/GuidedTour/playwright-tests/mission.spec.ts` | **New/Modify** — assert the out-of-mission scope **prompt** step |
+
+### Amendment Definition of Done
+
+- [x] WhoAmI exposes a new resource scope (`whoami:elevated_scope`) on a
+ mission-aware endpoint (`/jwt/mission/elevated`); `scope_descriptions` +
+ scope policy updated; baseline `/jwt/mission` branch excludes it
+ (§Resource Metadata, §Scopes). _(6b — final names landed as
+ `whoami:elevated_scope` / `/jwt/mission/elevated`, not the proposed
+ `whoami:history` / `/jwt/history`; D6 deferred names to implementation)_
+- [x] Under the seeded inbox mission, requesting the new scope is **out of
+ mission** → PS **prompts** (gate 3), and on approval issues the auth token;
+ the granted scope then shows under "Granted so far" on later screens
+ (§Agent Token Request, §Scopes L1793). _(6b)_
+- [x] SampleApp `Mission.razor` shows **5 gates** incl. the out-of-mission scope
+ prompt, distinct from the out-of-tool permission prompt. _(6b)_
+- [x] GuidedTour `TourMode.Mission` drives the out-of-mission scope cycle
+ (challenge → prompt → approve → exchange) with the decision reason visible.
+ _(6b)_
+- [x] `samples/MissionAgent/` requests the out-of-mission scope under the mission;
+ its README mermaid diagram + gate prose include the new consent block. _(6b)_
+- [x] Consent-screen UX refinements (D9) reflected and live-verified. _(6b)_
+- [x] Playwright specs assert the new prompted-scope gate; existing specs pass.
+ _(6b)_
+
+---
+
+## Phase 7 — Docs
+
+**Goal:** Rewrite the stale missions doc and add mission/PS-governance docs
+reflecting the implemented surface. Separate phase from samples per the agreed
+workflow.
+
+**Spec:** §Missions; §Mission Approval; §Mission Log; §Policy Evaluation Points;
+§Why Missions Are Not a Policy Language; §Permission/Audit/Interaction Endpoints;
+§Clarification Chat; §Mission Status Errors.
+
+> **Scope revised (2026-06-06).** A read-only audit of the shipped API against
+> the existing docs (research.md "Phase 7 — doc audit") showed the original
+> six-file list under-covered what Phases 1–6 actually built. `docs/advanced/
+> missions.md` describes a **non-existent** model (`Mission.Id`/`Status`/
+> `Requirements`/`FromJson`, `AAuthMissionHeader.Format(missionId)`), and the
+> entire **agent-side governance client** surface, **clarification chat**,
+> **server governance seams**, **`mission_terminated`**, **`ChallengeOptions.
+> MissionAware`**, and **`AddAAuthGovernance`** have **zero** doc coverage. The
+> file list and DoD below are expanded accordingly. `docs/concepts.md` (the
+> tools-declared-vs-scopes-evaluated split) and `docs/workflows/call-chaining.md`
+> (mission forwarding via `WithCallChaining`) were already corrected in Phase 6
+> and need only cross-link/index touch-ups.
+
+### Implemented surface the docs must match (audited 2026-06-06)
+
+- **Mission model** (`AAuth.Agent`): `Mission { Approver, Agent, ApprovedAt,
+ Description, ApprovedTools, Capabilities, S256, RawBytes, State }`,
+ `FromApprovalBytes`, `VerifyS256`, `ComputeS256`; `MissionState
+ { Active, Terminated }`; `MissionTool(Name, Description?)`.
+- **Header / claim**: `AAuthMissionHeader.FormatStructured(approver, s256)` +
+ `TryParseStructured(...)`; `MissionClaim(Approver, S256)` in tokens.
+- **Agent governance clients** (`AAuth.Agent.Governance`): facade
+ `AAuthGovernanceClient { Mission, Permission, Audit, Interaction }`;
+ `MissionClient.ProposeAsync`; `PermissionClient.RequestAsync` (missionless +
+ mission-scoped pre-approved-tool short-circuit); `AuditClient.RecordAsync`;
+ `InteractionClient.SendAsync` / `RelayInteractionAsync`; DTOs `MissionProposal`,
+ `PermissionRequest`/`PermissionResult`/`PermissionGrant`, `AuditRecord`,
+ `InteractionRequest`/`InteractionResult`/`InteractionType`, `GovernanceOptions`.
+- **Builder**: `AAuthClientBuilder.BuildGovernance()`;
+ `MissionForwardingHandler`; `WithCallChaining(...)`.
+- **Clarification chat**: `ClarificationExchange` (rounds, `DefaultMaxRounds=5`),
+ `ClarificationResponse` (`Respond`/`Update`/`Cancel`), `ClarificationRequirement`.
+- **Token-exchange mission params** (`TokenExchangeRequest`): `Justification`,
+ `LoginHint`, `Tenant`, `DomainHint`, `Platform`, `Device`,
+ `OnClarificationRequired`, `MaxClarificationRounds`.
+- **Errors**: `AAuthMissionTerminatedException` (`mission_terminated`).
+- **Mission-aware resource**: `ChallengeOptions.MissionAware`.
+- **Server seams** (`AAuth.Server.Governance`): `IMissionStore`/`StoredMission`/
+ `InMemoryMissionStore`; `IPermissionDecider`/`PermissionDecisionContext`/
+ `PermissionDecision`/`PermissionOutcome`/`PermissionDecisionReason`;
+ `IAuditSink`; `IInteractionRelay`/`InteractionRelayResult`; `IMissionLog`/
+ `MissionLogEntry`/`MissionLogEntryKind`/`InMemoryMissionLog`;
+ `GovernanceEndpoints` parsers. DI: `AddAAuthGovernance`.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `docs/advanced/missions.md` | **Rewrite** — replace the stale model with the spec blob (`Approver`/`Agent`/`ApprovedAt`/`Description`/`ApprovedTools`/`Capabilities`/`S256`/`RawBytes`/`State`), `s256` identity, two states, `FromApprovalBytes`/`VerifyS256`, `AAuthMissionHeader.FormatStructured`/`TryParseStructured`, the `MissionClaim` binding chain through resource→auth tokens, and `ChallengeOptions.MissionAware`; fix the lifecycle mermaid (agent-proposes-to-PS, not resource-proposes) |
+| `docs/advanced/mission-governance-clients.md` | **New** — agent-side `AAuthGovernanceClient` facade + `Mission`/`Permission`/`Audit`/`Interaction` clients, their DTOs, `BuildGovernance()`, and the tool-declared (permission endpoint, `approved_tools` short-circuit) vs scope-evaluated split |
+| `docs/advanced/clarification-chat.md` | **New** — `ClarificationRequirement`, `ClarificationExchange` rounds + `DefaultMaxRounds`, `ClarificationResponse` (`Respond`/`Update`/`Cancel`), the token-exchange `OnClarificationRequired`/`MaxClarificationRounds` hooks (§Clarification Chat) |
+| `docs/server/mission-governance.md` | **New** — PS as contextual policy point; server seams `IMissionStore`/`IMissionLog`/`IPermissionDecider`/`IAuditSink`/`IInteractionRelay`, `GovernanceEndpoints` parsers, the three-gate decision + reasons, prior-consent lookup, `mission_terminated` (§PS Governance, §Mission Log, §Why Missions Are Not a Policy Language) |
+| `docs/workflows/mission-governed-access.md` | **New** — end-to-end walkthrough: propose → operate (silent in-scope + prompted out-of-mission scope) → permission (silent tool + prompted action) → audit → interaction → completion, with the binding chain |
+| `docs/server/token-issuance.md` | **Modify** — add `s256` verify + `mission` claim emission/echo on resource + auth tokens |
+| `docs/server/challenge-middleware.md` | **Modify** — document `ChallengeOptions.MissionAware` (mission-aware resource copies the `AAuth-Mission` claim into the resource token) |
+| `docs/advanced/error-handling.md` | **Modify** — add `AAuthMissionTerminatedException` (`mission_terminated`) |
+| `docs/reference/dependency-injection.md` | **Modify** — add `AddAAuthGovernance` (server seams) + governance-client wiring |
+| `docs/workflows/call-chaining.md` | **Modify (light)** — cross-link mission forwarding to the new mission docs (forwarding itself already accurate) |
+| `docs/concepts.md` | **Modify (light)** — cross-link the new mission docs (tools-vs-scopes split already corrected in Phase 6) |
+| `docs/README.md` | **Modify** — index the new docs; add API-Map rows for `AAuthGovernanceClient`, the four governance clients, clarification types, and the server seams |
+
+### Definition of Done
+
+- [x] `docs/advanced/missions.md` matches the implemented model (no stale
+ `Id`/`Status`/`Requirements`/`FromJson`/`Format(missionId)`; correct blob
+ fields, two states, `s256`, structured header, binding chain).
+- [x] Agent-side governance clients doc covers the facade + four clients + DTOs +
+ `BuildGovernance()`, with a runnable propose→permission→audit→interaction
+ example.
+- [x] Clarification-chat doc covers requirement parsing, the round loop +
+ `DefaultMaxRounds`, and `Respond`/`Update`/`Cancel` (§Clarification Chat).
+- [x] Server governance doc explains the deterministic-vs-contextual split
+ (§Why Missions Are Not a Policy Language) and documents every seam +
+ `mission_terminated`.
+- [x] Walkthrough doc covers create → operate (silent + prompted) → permission
+ (silent + prompted) → audit → interaction → completion, with the binding chain.
+- [x] Touch-ups landed: `token-issuance.md` (`s256`/mission claim),
+ `challenge-middleware.md` (`MissionAware`), `error-handling.md`
+ (`mission_terminated`), `dependency-injection.md` (`AddAAuthGovernance`).
+- [x] All doc code samples compile against the shipped Phase 1–6 API
+ (exact type/member names).
+- [x] docs index/README updated; API-Map rows added; cross-links valid.
+
+---
+
+## Phase 8 — Review
+
+**Goal:** Validate each logical change set against the spec and the plan with a
+dedicated subagent per set, then remediate findings.
+
+### Review subagents (one per change set)
+
+| # | Change set | Scope |
+|---|------------|-------|
+| R1 | Mission model + `s256` (Phase 1) | spec §Mission Approval/Management fidelity |
+| R2 | Token binding + HTTPSig (Phase 2) | §Resource/Auth Token, §signed `aauth-mission` |
+| R3 | Token params + clarification + errors (Phase 3) | §Agent Token Request, §Clarification Chat, §Mission Status Errors |
+| R4 | Governance clients + metadata (Phase 4) | §Permission/Audit/Interaction, §PS Metadata |
+| R5 | Server seams + mission log (Phase 5) | §PS Governance, §Mission Log |
+| R6 | Samples (Phase 6) | flows run end-to-end; spec-faithful |
+| R7 | Docs (Phase 7) | accuracy vs implemented API + spec |
+
+### Definition of Done
+
+- [x] Each review subagent produces severity-graded findings with spec citations.
+ _(R1–R7 run 2026-06-06; R7 PASS, R1–R6 PASS-WITH-NITS — see research Phase 8.)_
+- [x] All critical/high findings remediated or explicitly deferred (with rationale).
+ _(Remediated: removed non-spec `Capabilities.Mission`, fixed `§14.1`
+ citations, added untrusted-Markdown remark to `Mission.Description`.
+ Deferred with rationale: R3 pre-existing `capabilities` body param —
+ flagged to user; R4 lenient audit status codes; R2/R4 test-coverage gaps;
+ R5 dev-grade in-memory store growth.)_
+- [x] Full solution builds; `AAuth.Tests` + `AAuth.Conformance` green.
+ _(Post-remediation: build 0/0, unit 383, conformance 425.)_
+- [x] e2e mission flow green. _(guided-tour + sample-app mission specs: 4 passed.)_
+- [x] research.md updated with any spec/behavior corrections discovered.
+ _(Phase 8 section added.)_
+
+---
+
+## Out of Scope
+
+| Item | Reason |
+|------|--------|
+| Full AS implementation in SDK | SDK ships builders + verification; AS stays in mock/Keycloak |
+| Mission revocation / delegation-tree queries / admin APIs | Spec defers to a companion specification (§Mission Management) |
+| Payment settlement (x402/MPP) beyond surfacing `402` | Out of AAuth core scope; already surfaced as exception |
+| Cross-PS agent correlation / multi-device regrouping | PS-side concern per bootstrap spec; not SDK |
+| Pairwise `sub` directed-identifier generation strategy | Existing PS-asserted work; not mission-specific. Mock reuses its existing `sub` generation as-is; the `mission` claim is additive alongside it |
diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md
new file mode 100644
index 0000000..1e733ae
--- /dev/null
+++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md
@@ -0,0 +1,876 @@
+# Missions & PS Governance — Research
+
+## Problem Statement
+
+The AAuth protocol defines **missions** (scoped authorization contexts for agent
+governance) and positions the **Person Server (PS)** as the *contextual* policy
+evaluation point — distinct from the deterministic policy enforced by resources
+and access servers. The .NET SDK (`src/AAuth/`), samples (`samples/`), and docs
+(`docs/`) implement only fragments of this surface, and the central `Mission`
+model is materially divergent from the specification.
+
+This document captures the spec model, the current SDK/sample/doc state, and the
+gap inventory. It contains **no** implementation steps — those live in
+[implementation-plan.md](implementation-plan.md).
+
+## Source Documents
+
+| Document | Location | Relevant Sections |
+|----------|----------|-------------------|
+| AAuth Protocol | `aauth-spec/draft-hardt-oauth-aauth-protocol.md` | §Policy Evaluation Points; §Agent Governance; §Missions (overview + normative); §PS Governance Endpoints; §Person Server; §PS Token Endpoint; §Clarification Chat; §Permission Endpoint; §Audit Endpoint; §Interaction Endpoint; §Resource Token; §Auth Token; §Upstream Token Verification; §Call Chaining; §Person Server Metadata; rationale (Why Missions Are Not a Policy Language / Two States) |
+| AAuth Bootstrap | `aauth-spec/draft-hardt-aauth-bootstrap.md` | PS lazy user-binding on first interaction; `ps` claim |
+| Upcoming changes | `aauth-spec/upcoming-changes-02.md` | `capabilities` in PS token body; `prompt`/`provider_hint` params |
+
+> Spec section anchors are cited by `{#anchor}` name and approximate line where a
+> stable anchor is absent. Line numbers reference the current revision of
+> `draft-hardt-oauth-aauth-protocol.md` and may drift on spec updates.
+
+---
+
+## Part 1 — Spec Model
+
+### 1.1 Policy Evaluation Points (§Policy Evaluation Points, ~L380)
+
+Policy is **distributed**; no single party is the decision point. Each of the
+four server roles re-evaluates the agent's activity from its own vantage point,
+and token lifetimes provide a natural re-evaluation cadence:
+
+- **Agent Provider** — issues/refuses agent tokens (device posture, attestation).
+- **Person Server** — decides whether to issue an auth token for a resource/scope
+ based on **user consent** and, under a mission, the **mission intent + log**
+ against the PS governance policy.
+- **Access Server** — decides issuance on behalf of the resource (resource policy,
+ PS-provided claims, deferred requirements).
+- **Resource** — *decides what is required* when issuing a resource token, and
+ *enforces* the auth token at access time.
+
+### 1.2 The Mission as Contextual Governance (§Why Missions Are Not a Policy Language, ~L2789)
+
+The spec deliberately separates two authorization kinds:
+
+- **Deterministic policy** — scopes, resource tokens, AS policy. Machine-evaluable.
+- **Contextual governance** — missions, justifications, clarification at the PS.
+ *Not* machine-evaluable; concentrated at the PS, the only party with the mission
+ content, the user relationship, and the full action history.
+
+Consequence: mission **content never leaves the PS**. Only the mission **hash**
+(`s256`) travels in tokens/headers. Distributing mission content would be "a
+privacy leak and a false promise of enforcement."
+
+### 1.3 Mission Object & Identity (§Mission Approval, ~L1259)
+
+The approved **mission blob** is JSON:
+
+| Field | Req? | Meaning |
+|-------|------|---------|
+| `approver` | MUST | HTTPS URL of approving entity (currently always the PS) |
+| `agent` | MUST | Agent identifier `aauth:local@domain` |
+| `approved_at` | MUST | ISO 8601 approval timestamp (makes `s256` globally unique) |
+| `description` | MUST | Markdown describing approved scope |
+| `approved_tools` | MAY | `[{name, description}]` usable without per-call permission |
+| `capabilities` | MAY | e.g. `["interaction","payment"]` the PS can provide; agent unions into `AAuth-Capabilities` |
+
+**Identity** — `s256` = base64url(SHA-256(**exact approved response body bytes**)).
+The agent MUST store the body bytes verbatim — *no re-serialization* — and
+verifies by recomputing over those bytes (§Mission Approval, ~L1275).
+
+### 1.4 Mission Lifecycle
+
+- **Creation** (§Mission Creation, ~L1228): agent POSTs `{description, tools}` to
+ the PS `mission_endpoint` (signed, agent token via `Signature-Key: sig=jwt`).
+ PS MAY return `202` for human review + clarification; approved blob MAY differ
+ from the proposal.
+- **States** (§Mission Management, ~L1322): exactly **two** — `active`,
+ `terminated`. No suspended state (§Why Missions Have Only Two States, ~L2805).
+- **Errors** (§Mission Status Errors, ~L1331): a request referencing a
+ non-active mission → `403` `{error:"mission_terminated", mission_status:"terminated"}`.
+- **Completion** (§Mission Completion, ~L1318): agent sends `type=completion`
+ to the interaction endpoint with a summary; user accepts (terminate) or
+ follows up (continues).
+- **Mission Log** (§Mission Log, ~L1310): ordered record of *all* agent↔PS
+ interactions (token requests + justifications, permission req/resp, audit
+ records, interaction requests, clarification chats). PS-maintained.
+
+### 1.5 Cryptographic Binding Chain
+
+Mission binding is **by hash reference**, layered on top of **key-bound
+proof-of-possession** and the **`act` delegation chain**:
+
+1. Agent in mission context adds `aauth-mission` to the **signed** HTTPSig
+ covered components (§Authorization Endpoint Request, ~L619). The `s256`
+ reference is thus covered by the agent's signature.
+2. Mission-aware **resource** embeds `{approver, s256}` as the `mission` claim in
+ the **resource token** it signs (§Resource Token Structure, ~L780). Resource
+ token also binds `agent_jkt` (RFC 7638 thumbprint).
+3. PS/AS embeds `{approver, s256}` as the `mission` claim in the **auth token**
+ it signs (§Auth Token Structure, ~L1560). Auth token binds `cnf.jwk` (PoP)
+ and `act` (RFC 8693 actor chain).
+4. **Delegation**: in call chaining the PS nests the upstream `act` inside a new
+ `act` identifying the intermediary (§Upstream Token Verification, ~L1621),
+ preserving the full chain for downstream authorization decisions.
+
+**Integrity & provenance are cryptographic; appropriateness is a PS policy
+decision** — the resource embeds the mission reference it received but does not
+re-evaluate mission fitness. Only the PS resolves `s256` → content against the
+mission log.
+
+### 1.6 PS Endpoints (§Person Server, ~L810)
+
+| Endpoint | Spec | Purpose | Mission? |
+|----------|------|---------|----------|
+| `token_endpoint` | §PS Token Endpoint ~L814 | Exchange resource token → auth token; consent; three/four-party | optional |
+| `mission_endpoint` | §Mission Creation ~L1228 | Propose/approve missions | — |
+| `permission_endpoint` | §Permission Endpoint ~L1013 | Pre-action governance for non-resource actions (tool calls) | optional |
+| `audit_endpoint` | §Audit Endpoint ~L1077 | Fire-and-forget action logging (`201`) | **required** |
+| `interaction_endpoint` | §Interaction Endpoint ~L1131 | Relay interaction/payment/question/completion to user | optional |
+
+**Token request params** (§Agent Token Request, ~L830): `resource_token` (req),
+`upstream_token`, `justification`, `login_hint`, `tenant`, `domain_hint`,
+`platform`, `device`. `capabilities`/`prompt` per upcoming-changes-02.
+
+**Clarification chat** (§Clarification Chat, ~L906): PS returns `202`
+`requirement=clarification` with `{clarification, timeout?, options?}`. Agent
+responds with one of: `clarification_response` POST, updated `resource_token`
+POST, or `DELETE` to cancel. Round limit recommended ≤5; agent responses are
+untrusted and MUST be sanitized by the PS before display.
+
+**Permission** (§Permission Endpoint): POST `{action, description?, parameters?,
+mission?}` → `{permission: granted|denied, reason?}` or deferred. `approved_tools`
+short-circuit the call. **Audit** (§Audit Endpoint): POST `{mission(req),
+action, description?, parameters?, result?}` → `201`. **Interaction**
+(§Interaction Endpoint): POST `{type, description?, url?, code?, question?,
+summary?, mission?}`; `question` → `{answer}`, `completion` → terminate/continue.
+
+**PS Metadata** (§Person Server Metadata, ~L2199): publishes the optional
+`mission_endpoint`, `permission_endpoint`, `audit_endpoint`,
+`interaction_endpoint`.
+
+---
+
+## Part 2 — Current SDK State
+
+### 2.1 What works (keep)
+
+| Capability | Evidence | Spec |
+|------------|----------|------|
+| `AAuth-Mission` structured header format | `AAuthMissionHeader.FormatStructured` ([Agent/Mission.cs](../../../src/AAuth/Agent/Mission.cs)) | §AAuth-Mission Header |
+| Mission forwarding on downstream calls | [Agent/MissionForwardingHandler.cs](../../../src/AAuth/Agent/MissionForwardingHandler.cs); wired in [AAuthClientBuilder.cs](../../../src/AAuth/AAuthClientBuilder.cs) L556 | §Call Chaining |
+| `act` chain build/read/validate | `AuthTokenBuilder.UpstreamAct`, [Tokens/ActChainBuilder.cs](../../../src/AAuth/Tokens/ActChainBuilder.cs), [Tokens/ActChainReader.cs](../../../src/AAuth/Tokens/ActChainReader.cs), [Tokens/UpstreamTokenValidator.cs](../../../src/AAuth/Tokens/UpstreamTokenValidator.cs); depth limit in [Tokens/TokenVerifier.cs](../../../src/AAuth/Tokens/TokenVerifier.cs) | §Upstream Token Verification |
+| Deferred `202` loop, interaction, Retry-After/Prefer, `402`, polling errors | [Agent/DeferredPoller.cs](../../../src/AAuth/Agent/DeferredPoller.cs), [Agent/TokenExchangeClient.cs](../../../src/AAuth/Agent/TokenExchangeClient.cs) | §User Interaction; §Deferred Responses |
+| `mission.approver` constraint on resource token | `TokenVerifier.VerifyResourceToken` (~L512) when `expectedApprover` supplied | §Resource Token Verification step 7 |
+| Call-chaining router (mission.approver → PS) | [Server/CallChaining/CallChainingRouter.cs](../../../src/AAuth/Server/CallChaining/CallChainingRouter.cs) | §Call Chaining |
+| PS metadata **emission** of all 4 governance endpoints | [Server/Metadata/AAuthPersonServerMetadataOptions.cs](../../../src/AAuth/Server/Metadata/AAuthPersonServerMetadataOptions.cs), [Server/Metadata/WellKnownEndpoints.cs](../../../src/AAuth/Server/Metadata/WellKnownEndpoints.cs) L178-184 | §Person Server Metadata |
+
+### 2.2 Gap Inventory
+
+Severity: **C** critical (incorrect/non-compliant behavior), **H** high (missing
+core capability), **M** medium (missing convenience / ecosystem coverage).
+
+| # | Sev | Gap | Spec | Current state |
+|---|-----|-----|------|---------------|
+| G1 | C | `Mission` model uses 4 states `pending/approved/denied/completed` | §Mission Management (2 states) | [Agent/Mission.cs](../../../src/AAuth/Agent/Mission.cs) L17 |
+| G2 | C | `Mission` missing required blob fields `approver`,`agent`,`approved_at`; carries non-spec `Id`,`Requirements`,`StatusUrl`,`InteractionUrl` | §Mission Approval | Agent/Mission.cs L12-44 |
+| G3 | C | `Mission.FromJson` parses `mission_id` (throws if absent), wrong keys | §Mission Approval | Agent/Mission.cs L32-43 |
+| G4 | C | No `s256` compute over exact body bytes; no verbatim byte storage; no verify | §Mission Approval ~L1275 | absent |
+| G5 | H | No `mission_endpoint` client (propose/approve, 202 review) | §Mission Creation | absent |
+| G6 | H | No `permission_endpoint` client | §Permission Endpoint | absent |
+| G7 | H | No `audit_endpoint` client (fire-and-forget) | §Audit Endpoint | absent |
+| G8 | H | No `interaction_endpoint` client (relay/question/completion) | §Interaction Endpoint | absent |
+| G9 | H | `ResourceTokenBuilder` cannot emit `mission` claim | §Resource Token Structure ~L780 | [Tokens/ResourceTokenBuilder.cs](../../../src/AAuth/Tokens/ResourceTokenBuilder.cs) |
+| G10 | H | `AuthTokenBuilder` cannot emit `mission` claim (only `AdditionalClaims`) | §Auth Token Structure ~L1560 | [Tokens/AuthTokenBuilder.cs](../../../src/AAuth/Tokens/AuthTokenBuilder.cs) |
+| G11 | H | `TokenVerifier.VerifyAuthToken` does not surface `mission` claim | §Auth Token | TokenVerifier.cs L161-268 |
+| G12 | H | HTTPSig does not auto-cover `aauth-mission` when header present | §Authorization Endpoint Request ~L619 | [HttpSig/AAuthSigningHandler.cs](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs) L40 (fixed base components) |
+| G13 | H | Clarification chat fully absent (parse, response POST, updated-request POST, DELETE cancel, round limit) | §Clarification Chat | DeferredPoller is GET-only |
+| G14 | H | Token request missing params `justification`,`login_hint`,`tenant`,`domain_hint`,`platform`,`device` | §Agent Token Request ~L830 | [Agent/TokenExchangeRequest.cs](../../../src/AAuth/Agent/TokenExchangeRequest.cs) |
+| G15 | H | `ServerMetadata` does not parse `permission_endpoint`/`audit_endpoint` | §Person Server Metadata | [Discovery/ServerMetadata.cs](../../../src/AAuth/Discovery/ServerMetadata.cs) L43-44 |
+| G16 | H | No `mission_terminated` error code / typed exception / handling | §Mission Status Errors | not in [Errors/TokenError.cs](../../../src/AAuth/Errors/TokenError.cs) |
+| G17 | M | No `capabilities` union (mission blob ∪ agent) into `AAuth-Capabilities` | §Mission Approval; §AAuth-Capabilities | [Agent/AAuthCapabilitiesHeader.cs](../../../src/AAuth/Agent/AAuthCapabilitiesHeader.cs) format/parse only |
+| G18 | M | No PS-side governance handlers (mission/permission/audit/interaction) or DI seams | §PS Governance Endpoints | absent; MockPersonServer hand-rolls partial flows |
+| G19 | M | No governance DTOs (Permission/Audit/Interaction/MissionCreate req+resp) | §PS Governance Endpoints | absent |
+| G20 | M | No mission-log abstraction (store seam) | §Mission Log | absent |
+
+### 2.3 SDK role scope (context for plan)
+
+The SDK is **client/agent + resource-verification** focused; it ships token
+**builders** but not full PS/AS servers. [samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs)
+hand-rolls token issuance and consent UI (`GET /interaction`, `POST
+/interaction/{approve,deny}`) but no spec governance endpoints, no mission
+extraction, no `s256`, no mission claim in issued tokens. Server-side governance
+helpers (G18/G19/G20) should follow the existing pattern: thin SDK primitives
+(DTOs + parse/format + optional minimal endpoint mappers / DI seams) that the
+mock servers consume, rather than a full PS implementation.
+
+---
+
+## Part 3 — Samples & Docs State
+
+### 3.1 Samples
+
+0 of 9 samples demonstrate mission creation, permission, audit, interaction
+relay, or completion. Orchestrator forwards the `AAuth-Mission` header only.
+
+| Sample | Mission-aware | Governance demo |
+|--------|---------------|-----------------|
+| WhoAmI (resource) | no | no |
+| Orchestrator (call chain) | header forward only | no |
+| MockPersonServer | no mission claim/s256 | consent UI only |
+| MockAgentProvider / MockAccessServer | n/a | no |
+| GuidedTour / SampleApp / AgentConsole / LiveWhoAmITest | no | no |
+
+### 3.2 Docs
+
+| File | Status |
+|------|--------|
+| [docs/advanced/missions.md](../../../docs/advanced/missions.md) | **STALE** — mirrors broken model (4 states, `mission_id`/`status_url`/`interaction_url`); no `s256`, no blob fields, no governance endpoints |
+| [docs/workflows/call-chaining.md](../../../docs/workflows/call-chaining.md) | partial — mentions forwarding, no mission blob/governance |
+| [docs/workflows/ps-asserted-access.md](../../../docs/workflows/ps-asserted-access.md) | aligned for consent; no mission context |
+| [docs/workflows/deferred-consent.md](../../../docs/workflows/deferred-consent.md) | aligned; no mission/permission context |
+| [docs/workflows/federated-access.md](../../../docs/workflows/federated-access.md) | partial; no mission governance |
+| [docs/server/token-issuance.md](../../../docs/server/token-issuance.md) | mentions `mission.approver`; no `s256`/lifecycle |
+| docs/server/{permission,audit,interaction} | **absent** |
+
+Docs index: [docs/README.md](../../../docs/README.md) (slot new governance docs
+under `docs/server/` + refresh `docs/advanced/missions.md` + add a
+`docs/workflows/` mission-governance walkthrough).
+
+---
+
+## Part 4 — Gaps & Open Questions
+
+1. **`s256` over which bytes?** Spec: exact response body bytes of the mission
+ approval. SDK must retain the raw `byte[]`/`string` from the `mission_endpoint`
+ response (and any persisted blob) — model must expose a verbatim-bytes
+ accessor, not a re-serialized `JsonObject`. (§Mission Approval ~L1275)
+2. **Auto-signing `aauth-mission`.** Should `AAuthSigningHandler` auto-detect the
+ `AAuth-Mission` request header and add `aauth-mission` to covered components,
+ or should the mission-aware client layer set `AdditionalComponentsKey`?
+ Leaning auto-detect for correctness (G12), but must confirm it does not
+ double-add when callers also set it. (§Authorization Endpoint Request ~L619)
+3. **Backward compatibility of `Mission`.** Replacing the model is a breaking
+ change. Confirm whether to rename (e.g. `ApprovedMission`) + keep a
+ `[Obsolete]` shim, or replace outright. (Affects docs + any sample usage.)
+4. **Server governance depth.** How much PS-side to put in the SDK vs. the mock?
+ Proposed: DTOs + serialization + minimal endpoint mappers + store/relay
+ interfaces (`IMissionStore`, `IPermissionDecider`, `IAuditSink`,
+ `IInteractionRelay`); full policy lives in MockPersonServer. (§PS Governance)
+5. **Clarification scope.** Implement the full agent-side three-action loop
+ (respond / update / cancel) plus a server-side helper, or agent-side only in
+ round one? (§Clarification Chat)
+6. **`mission_terminated` propagation.** Where to surface — `TokenExchangeClient`,
+ governance clients, or a shared error mapper consumed by all PS calls?
+ (§Mission Status Errors)
+
+> **Update (2026-06):** Initial research complete. Open questions above to be
+> resolved as Implementation Decisions per phase before coding.
+
+## Part 5 — Phase Findings
+
+### Phase 1 — Mission model & `s256` (2026-06-05, complete)
+
+- **Decisions resolved.** Q1 (verbatim bytes): `Mission.RawBytes`
+ (`ReadOnlyMemory`) stores the exact approval body; `FromApprovalBytes`
+ hashes those bytes directly (no `JsonNode` re-serialization). Q3 (compat):
+ given draft status + no back-compat constraint, the old `Mission` model was
+ **replaced in place** — no rename, no `[Obsolete]` shim.
+- **Old model had zero external references.** `Mission` (with `Id`/`Status`/
+ `Requirements`/`StatusUrl`/`InteractionUrl`) and `Mission.FromJson` were not
+ referenced anywhere in `src/`, `tests/`, or `samples/`. The rewrite therefore
+ broke no callers — confirmed by a full-solution build (0 warnings/errors).
+- **`AAuthMissionHeader` kept as-is.** `FormatStructured(approver, s256)` already
+ matched spec; only the dead `Format(string missionId)` overload was removed.
+ `MissionForwardingHandler` (the sole consumer) is unaffected.
+- **`s256` is byte-sensitive.** A test confirms pretty-printed vs compact JSON of
+ the same logical content produce **different** `s256` — reinforcing the
+ "store verbatim, never re-serialize" requirement (§Mission Approval).
+- **Capabilities union** added as `AAuthCapabilitiesHeader.Union(mission, agent)`
+ (mission-first, order-preserving, case-sensitive dedupe).
+- **New files:** `Mission.cs` (rewritten), `MissionState.cs`, `MissionTool.cs`.
+ **Tests:** `Missions/MissionModelTests.cs`, `Missions/MissionS256Tests.cs`,
+ plus `Union` cases in `CapabilitiesHeaderTests.cs`.
+- **Validation:** 364 conformance + 371 unit tests green; full solution builds.
+- **No new open questions or design choices for Phase 1.** `VerifyS256` uses a
+ fixed-time comparison (defensive; the value is not secret but the helper is
+ cheap and avoids early-exit surprises).
+
+### Phase 2 — Mission binding through the token chain (2026-06-05, complete)
+
+- **Mission claim shape.** Introduced `MissionClaim(string Approver, string
+ S256)` (`src/AAuth/Tokens/MissionClaim.cs`) as the `{approver, s256}` value
+ carried in tokens. `ResourceTokenBuilder` and `AuthTokenBuilder` gained an
+ optional `Mission` property; the `mission` claim is emitted **only when set**
+ (§Resource Token Structure, §Auth Token Structure).
+- **Verification surface.** `TokenVerifier.VerifiedToken` exposes a computed
+ `Mission` property (parses `payload.mission`; `null` when absent/malformed).
+ The existing `expectedApprover` constraint in `VerifyResourceTokenAsync`
+ (step 7) is unchanged.
+- **DISCOVERY (mid-phase, surfaced to user).** Adding `aauth-mission` on the
+ signing side alone would **break** every mission-context request: the
+ production verifier `AAuthVerifier.Verify` rigidly rejected any covered
+ component beyond the base 4 + optional `authorization` (threw when
+ `components.Count > 5`). This required extending the verifier +
+ `AAuthVerificationMiddleware`, two files **not** in the original Phase 2 file
+ list. **User approved** adding them (design decision D5).
+- **Covered-component ordering (D6, resolved per spec).** Spec
+ §Authorization Endpoint Request shows mission context as
+ `("@method" "@authority" "@path" "signature-key" "aauth-mission")` — i.e.
+ `aauth-mission` is the **last** component, after `signature-key`. The signer
+ appends it after the (pre-existing) `authorization` block, so the verifier
+ accepts the optional trailing pair `authorization` then `aauth-mission`.
+ **Pre-existing deviation noted:** the spec's §AAuth-Access example places
+ `authorization` *before* `signature-key`, but the SDK appends it *after*;
+ re-aligning that is out of Phase 2 scope (would churn existing tests).
+- **No double-cover.** `aauth-mission` is added to the signer's `seen` set so an
+ explicit `AdditionalComponentsKey` request for it is ignored (covered once via
+ header auto-detection). Test asserts a single occurrence.
+- **Header value consistency.** Signer covers the verbatim `AAuth-Mission` header
+ value (`approver="..."; s256="..."` via `AAuthMissionHeader.FormatStructured`).
+ Middleware passes `req.Headers["AAuth-Mission"].FirstOrDefault()`; single-valued
+ in practice so producer and verifier see identical bytes.
+- **Files:** `MissionClaim.cs` (new); modified `ResourceTokenBuilder.cs`,
+ `AuthTokenBuilder.cs`, `TokenVerifier.cs`, `AAuthSigningHandler.cs`,
+ `AAuthVerifier.cs`, `AAuthVerificationMiddleware.cs`. **Tests:**
+ `Missions/MissionClaimTests.cs` (6), `HttpSignatures/MissionSignedComponentTests.cs`
+ (5) — note `MissionClaimTests` placed under `Missions/` (no `Tokens/` folder
+ exists in the conformance project).
+- **Validation:** 375 conformance (+11) + 371 unit tests green; full solution
+ builds 0/0.
+
+### Phase 3 — PS token-request params, clarification chat, mission errors (2026-06-05, complete)
+
+- **Token-request params (§Agent Token Request).** Added six optional `string?`
+ properties to `TokenExchangeRequest` — `Justification`, `LoginHint`, `Tenant`,
+ `DomainHint`, `Platform`, `Device` — serialized into the POST body as
+ `justification`, `login_hint`, `tenant`, `domain_hint`, `platform`, `device`
+ via a new `AddIfPresent` helper that omits unset/empty values.
+- **Clarification model (§Clarification Chat).** `ClarificationRequirement`
+ (`src/AAuth/Headers/ClarificationRequirement.cs`) parses the `202` body
+ `{clarification, timeout?, options?}` for `requirement=clarification`,
+ modeled on the existing `ClaimsRequirement`. Throws `FormatException` when the
+ `clarification` string is missing.
+- **Clarification API design (D7, user-approved).** The agent supplies a
+ callback `OnClarificationRequired` on `TokenExchangeRequest` (mirrors
+ `OnInteractionRequired`) that returns a `ClarificationResponse` *decision*
+ object. `ExchangeAsync`'s response handling was rewritten into a
+ `while (StatusCode == 202)` loop that resolves the requirement, dispatches
+ interaction vs. clarification, applies the decision, and re-polls.
+- **ClarificationResponse + ClarificationExchange.** `ClarificationResponse`
+ (nested `Kind { Respond, Update, Cancel }`) carries the agent's choice; the
+ factories are `Respond(markdown)`, `Update(resourceToken, justification?)`,
+ `Cancel()`. `ClarificationExchange` performs the wire calls against the pending
+ URL: `clarification_response` POST, updated `resource_token` POST, and `DELETE`
+ cancel (which surfaces `AAuthClarificationCancelledException`).
+- **Round limit (§Clarification Limits).** Default `MaxRounds = 5` (configurable
+ via `TokenExchangeRequest.MaxClarificationRounds`); `Respond`/`Update` consume a
+ round, `Cancel` does not. Exceeding the limit throws
+ `AAuthClarificationLimitException`.
+- **DEVIATION FROM PLAN FILE LIST.** The plan listed `DeferredPoller.cs` as
+ **Modify — allow POST/DELETE to pending URL**. In implementation the POST/DELETE
+ calls live entirely in `ClarificationExchange` (using its own `HttpClient`),
+ and the clarification-stop during polling reuses the **existing**
+ `DeferredPollerOptions.StopWhenAccepted` predicate (composed via
+ `ComposePollerOptions`). `DeferredPoller.cs` was therefore **not** modified.
+- **Mission-terminated (§Mission Status Errors).** Added `TokenErrorCode.
+ MissionTerminated` (`mission_terminated`, round-trips through `TokenErrorResponse`)
+ and `AAuthMissionTerminatedException` (with `MissionStatus`). `ExchangeAsync`
+ classifies a terminal `403` body `{error:"mission_terminated", mission_status}`
+ via `TryReadMissionTerminatedAsync` — both on the direct token response and on a
+ `403` surfaced during polling (the poller returns the unrecognized `403` rather
+ than throwing, so the client classifies it). A shared `BufferBodyAsync` lets the
+ `access_denied`, `mission_terminated`, and auth-token readers all re-read the body.
+- **Files:** new `Headers/ClarificationRequirement.cs`, `Agent/ClarificationExchange.cs`
+ (holds `ClarificationResponse` + `ClarificationExchange`),
+ `Errors/AAuthMissionTerminatedException.cs`; modified `Agent/TokenExchangeRequest.cs`,
+ `Agent/TokenExchangeClient.cs`, `Agent/AAuthInteractionExceptions.cs` (added
+ `AAuthClarificationCancelledException`, `AAuthClarificationLimitException`),
+ `Errors/TokenError.cs`. **Tests:** `Missions/ClarificationChatTests.cs` (8),
+ `Missions/MissionTerminatedTests.cs` (3), and an **added** (not in original plan
+ list) `Missions/TokenRequestParamsTests.cs` (2) covering the six params.
+- **Validation:** 388 conformance (+13) + 371 unit tests green; full solution
+ builds 0/0.
+
+### Phase 4 — PS governance clients + metadata discovery (2026-06-05, complete)
+
+- **Metadata (§Person Server Metadata, ~L2199).** `ServerMetadata.FromJson`
+ already parsed `mission_endpoint` + `interaction_endpoint`; added
+ `permission_endpoint` and `audit_endpoint`, so all four governance endpoints
+ (all OPTIONAL in spec) are now surfaced. The clients resolve an endpoint by
+ fetching the PS `aauth-person.json`, validating https-or-loopback, and
+ origin-pinning the returned URL to the PS authority.
+- **Shared exchange (DEVIATION — added beyond plan file list).** A new
+ `Agent/Governance/GovernanceExchange.cs` holds the common signed-POST +
+ deferred-`202` loop + `mission_terminated` classification + endpoint
+ origin-pinning, plus a public `GovernanceOptions`
+ (`OnInteractionRequired`, `OnClarificationRequired`, `MaxClarificationRounds`,
+ `PollerOptions`). This mirrors `TokenExchangeClient`'s deferred/clarification
+ loop. **Design note for user:** `GovernanceExchange` duplicates some
+ `TokenExchangeClient` logic; `TokenExchangeClient` was deliberately left
+ untouched (zero regression risk). A future shared-helper refactor is possible
+ if desired.
+- **MissionClient (§Mission Creation ~L399/L1228, §Mission Approval ~L1265).**
+ `ProposeAsync(personServer, MissionProposal, options?, ct)` posts
+ `{description, tools?}` to `mission_endpoint`, handles the `202` review/
+ clarification loop, then reads the approval body **verbatim** and calls
+ `Mission.FromApprovalBytes`. It parses the `AAuth-Mission` header's `s256` and
+ verifies it against the recomputed blob hash (throws on mismatch/missing).
+- **PermissionClient (§Permission Endpoint ~L1013).** `RequestAsync` posts
+ `{action, description?, parameters?, mission?}` → `200 {permission, reason?}`.
+ An overload taking a `Mission` short-circuits to `Granted`
+ ("Pre-approved tool on the active mission.") when the action matches
+ `mission.ApprovedTools`, **without** calling the PS (spec `approved_tools`).
+- **AuditClient (§Audit Endpoint ~L1077).** `RecordAsync` posts
+ `{mission, action, description?, parameters?, result?}` (mission REQUIRED),
+ returns on `201`/`200`/`204` (fire-and-forget); `mission_terminated` surfaces
+ via `GovernanceExchange`.
+- **InteractionClient (§Interaction Endpoint ~L1131).** `SendAsync` posts
+ `{type, ...}` for all four `type` values; `question` → `Answer` from
+ `body["answer"]`, `completion` → `Terminated` when `mission_status != "active"`.
+ Convenience helpers: `RelayInteractionAsync`, `RelayPaymentAsync`,
+ `AskQuestionAsync`, `ProposeCompletionAsync`. (DEVIATION — added
+ `InteractionResult.cs` DTO not explicitly in plan list.)
+- **DTOs.** `MissionProposal`, `PermissionRequest`/`PermissionResult`
+ (`PermissionGrant` enum), `AuditRecord`, `InteractionRequest`
+ (`InteractionType` enum)/`InteractionResult`. Each request DTO has an
+ `internal ToJsonObject()`; `PermissionResult.FromJson` maps granted/denied.
+- **Layering decision.** Agent DTOs own serialization (`ToJsonObject`); the
+ server side owns parsing (Phase 5 `GovernanceEndpoints.Parse*`). Agent
+ governance clients are constructed directly (like `TokenExchangeClient`) and
+ are **not** DI-registered.
+- **Files:** modified `Discovery/ServerMetadata.cs`; new
+ `Agent/Governance/{GovernanceExchange,MissionProposal,MissionClient,
+ PermissionRequest,PermissionResult,PermissionClient,AuditRecord,AuditClient,
+ InteractionRequest,InteractionResult,InteractionClient}.cs`. **Tests:**
+ `Missions/GovernanceClientTests.cs` (12) against a stub PS.
+- **Validation:** 399 conformance (+11) + 371 unit green; SDK + full solution
+ build 0/0.
+
+### Phase 5 — PS server-side governance seams + mission log (2026-06-05, complete)
+
+- **Decision boundary (D3).** The SDK ships thin server-side seams — request
+ parsers, a `mission_terminated` helper, storage interfaces + in-memory
+ defaults, and the policy/relay interfaces — so a PS can serve the governance
+ endpoints without hand-rolling parsing. Policy and UI live in the PS
+ (MockPersonServer, Phase 6).
+- **Request parsers (§PS Governance Endpoints ~L463).**
+ `GovernanceEndpoints.Parse{Permission,Audit,Interaction,MissionProposal}`
+ map a `JsonObject` to the Phase 4 DTOs, throwing `FormatException` on missing
+ required fields (`action`; `mission`+`action`; `type`; `description`) and on
+ unknown interaction `type`. Mission objects are read via
+ `MissionClaim.FromPayload`. This keeps parsing **server-side** rather than
+ adding `FromJson` to the agent DTOs (clean agent-serializes / server-parses
+ split).
+- **Mission-terminated helper (§Mission Status Errors ~L1331).**
+ `MissionTerminatedStatus = 403`; `MissionTerminatedBody(missionStatus =
+ "terminated")` emits `{error:"mission_terminated", mission_status}` (error code
+ from `AAuthMissionTerminatedException.ErrorCode`); `MissionTerminated(...)`
+ returns an `IResult` via `Results.Json(..., statusCode: 403)`.
+- **Mission store (§Mission Approval — verbatim bytes).** `IMissionStore` +
+ `StoredMission(S256, Approver, Agent, Blob)` with `MissionState State`
+ (default `Active`). `InMemoryMissionStore` (DEVIATION — added in-memory default,
+ mirrors `InMemoryJtiStore`) stores the blob bytes verbatim and transitions
+ state via `existing with { State = state }`.
+- **Mission log (§Mission Log ~L1310, §Agent Token Request ~L784).** `IMissionLog`
+ + `MissionLogEntry(S256, Kind, Timestamp)` with `MissionLogEntryKind`
+ {Token, Permission, Audit, Interaction, Clarification} and optional
+ Resource/Scope/Action/Granted/Detail. `InMemoryMissionLog` (DEVIATION — added)
+ appends in order, `ReadAsync` preserves order, and `HasPriorConsentAsync(s256,
+ resource, scope)` returns true only for `Token` entries with `Granted == true`
+ matching `(s256, resource, scope)` — the prior-consent context a PS uses to
+ skip re-prompting.
+- **Decider seam (§Person Server ~L385, §Permission ~L1017).**
+ `IPermissionDecider.DecideAsync(PermissionDecisionContext, ct)` is invoked with
+ the request + resolved `StoredMission` + ordered log, and returns a
+ `PermissionDecision(Outcome, Reason, Message?)` where `PermissionOutcome`
+ {Granted, Denied, Prompt} and `PermissionDecisionReason` {InScope, PriorConsent,
+ ApprovedTool, OutOfScope}. The SDK supplies the inputs + reason taxonomy; the
+ PS owns the policy. `IAuditSink` and `IInteractionRelay` are the audit/relay
+ seams.
+- **DI.** `AddAAuthGovernance` (`Microsoft.Extensions.DependencyInjection`
+ namespace) `TryAddSingleton`s `IMissionStore`→`InMemoryMissionStore` and
+ `IMissionLog`→`InMemoryMissionLog`; the policy seams (decider/sink/relay) are
+ left for the PS to register.
+- **Files:** new `Server/Governance/{IMissionStore,InMemoryMissionStore,
+ IMissionLog,InMemoryMissionLog,IPermissionDecider,IAuditSink,IInteractionRelay,
+ GovernanceEndpoints}.cs` and
+ `DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs`. **Tests:**
+ `Missions/GovernanceServerTests.cs` (17).
+- **Validation:** 417 conformance (+18 across Phases 4+5) + 371 unit green; SDK +
+ full solution build 0/0.
+
+### Phase 5.5 — Shared deferred transport + governance facade (2026-06-05, complete)
+
+- **Why (duplication).** `GovernanceExchange` (Phase 4) and
+ `TokenExchangeClient` (Phase 3) shared ~120 lines: endpoint origin-pin, the
+ `202` deferred loop (interaction + clarification), `ComposePollerOptions`,
+ `BufferBodyAsync` / `ReadJsonBodyAsync` / `ExtractRequirement` /
+ `ResolveLocation`, the `mission_terminated` reader, and `AddIfPresent`. Pure
+ refactor — same spec citations (§User Interaction, §Clarification Chat,
+ §Mission Status Errors, §PS Governance Endpoints, §Person Server Metadata).
+- **Single transport (D8).** `internal sealed class DeferredExchange`
+ (`AAuth.Agent`) now owns the transport: `ResolveEndpointAsync(personServer,
+ field, ct)` (metadata fetch + https-or-loopback + same-origin pin, generic
+ `'{field}'` errors byte-identical to the old `'token_endpoint'` ones) and
+ `PostAsync(endpoint, body, DeferredExchangeOptions, ct)` (the `202` deferred
+ loop, returning the terminal `HttpResponseMessage` for the caller to parse +
+ dispose, throwing `AAuthMissionTerminatedException` on terminal `403
+ mission_terminated`). It owns the `AAuth.DeferredPoll` activity and every
+ shared helper. `GovernanceExchange.cs` is **deleted**; `GovernanceOptions`
+ moved to its own file with `internal DeferredExchangeOptions ToExchangeOptions()`
+ (`RequireInteractionCallback = false`, no `OnPolledResponse`). The four
+ governance clients now hold a `DeferredExchange`.
+- **Preserving token-only behaviour through two option seams.** Rather than
+ leaving token-specific branches inside `TokenExchangeClient`, two
+ `DeferredExchangeOptions` knobs reproduce them exactly: (1)
+ `RequireInteractionCallback` (token = `true`) throws the token-exact
+ "no onInteractionRequired callback" message on **any** non-clarification `202`
+ with no callback, whereas governance (`false`) only throws when an interaction
+ requirement is actually present; (2) `OnPolledResponse` (token only) runs the
+ `403 access_denied` → `AAuthInteractionDeniedException` classifier **after** an
+ interaction-branch poll (not after a clarification poll, not on the
+ initial/direct response), matching the original call-site placement. The
+ initial/direct/clarification `403`s still flow to `ReadAuthTokenAsync` as token
+ errors. `TokenExchangeRequest` and the public `TokenExchangeClient` API are
+ unchanged.
+- **Facade + factory (D9).** Public `AAuthGovernanceClient` bundles
+ `Mission` / `Permission` / `Audit` / `Interaction` over one signed `HttpClient`
+ + `MetadataClient` (ctor `(HttpClient signedClient, MetadataClient metadata)`,
+ `ArgumentNullException` guards; sub-clients stay public).
+ `AAuthClientBuilder.BuildGovernance()` builds it from a shared private
+ `BuildSignedChannel(provider, innerHandler)` helper that also backs
+ `BuildHandler`'s exchange channel. `BuildHandler` passes `new
+ HttpClientHandler()` (preserving the prior exchange signer's inner handler);
+ `BuildGovernance` passes `_innerHandler ?? new HttpClientHandler()` for
+ testability. `BuildGovernance` **requires an explicit signing mode**
+ (`UseHwk`/`UseJwt`/`UseJwksUri`/`UseJktJwt`/`UseProvider`) and throws
+ `InvalidOperationException` otherwise — it does not reconstruct the lazy-refresh
+ token-holder pipeline (that stays exclusive to `BuildHandler`).
+- **Observability note.** `AAuth.DeferredPoll` now also fires for governance
+ polls (additive, via the shared transport) — not a wire change; the token
+ diagnostics tests still observe `AAuth.TokenExchange` + `AAuth.DeferredPoll`.
+- **DI deferred.** `AddAAuthAgentGovernance` is still out of scope; it will land
+ in Phase 6 only if a sample needs DI-resolved governance.
+- **Files:** new `Agent/DeferredExchange.cs`,
+ `Agent/Governance/{GovernanceOptions,AAuthGovernanceClient}.cs`; deleted
+ `Agent/Governance/GovernanceExchange.cs`; modified `Agent/TokenExchangeClient.cs`,
+ the four governance clients, and `AAuthClientBuilder.cs`. **Tests:**
+ `Missions/GovernanceFacadeTests.cs` (5).
+- **Validation:** 422 conformance (417 + 5 facade) + 371 unit green, both
+ unchanged from before the refactor; SDK + full solution build 0/0 — the
+ existing suites were the regression gate and caught nothing.
+
+### Phase 6a — Samples backend foundation (2026-06-05, complete)
+
+- **MockPersonServer governance.** `MissionGovernance.cs` adds the scriptable
+ decision model (`MissionConsentScript`), per-mission policy snapshot
+ (`MissionPolicyStore`), approval blob builder, and the
+ decider/sink/relay sample implementations; `Program.cs` wires
+ `AddAAuthGovernance` + the four governance endpoints, the three-gate `/token`
+ mission gate, and `/admin/mission-script` + `/admin/mission-terminate` (option
+ A deterministic scripting). The `/token` gate reads
+ `MissionClaim.FromPayload(verified resource token)` and decides terminated →
+ `403`, in-scope/prior-consent → silent issue, else prompt (live `202`
+ deferred), logging the decision reason each time.
+- **12-row Consent Matrix.** `tests/AAuth.Tests/Integration/
+ MissionAgentFlowTests.cs` covers every gate × approve/deny × prompt/silent
+ (incl. clarification respond/cancel and `mission_terminated`) in-process via
+ `WebApplicationFactory` + the SDK governance/exchange clients, asserting the
+ recorded mission-log reason per row. +12 unit tests (371 → 383).
+- **SPEC-DRIVEN ADDITION — mission-aware resource (§Terminology ~L177:** "a
+ mission-aware resource includes the mission object from the AAuth-Mission
+ header in the resource tokens it issues"; §Mission flow ~L423/L429; signed
+ `aauth-mission` component §HTTP signing ~L619). Promoted this from a sample
+ hack to a **first-class SDK feature** so the chain — agent `AAuth-Mission`
+ header → mission-aware resource copies `{approver, s256}` into the resource
+ token → PS reads it from the verified resource token → embeds it in the auth
+ token — works for *any* resource, not just the mock. SDK: `AAuthMissionHeader.
+ TryParseStructured`; `ChallengeOptions.MissionAware` (opt-in, default false);
+ `AAuthChallengeMiddleware` sets `ResourceTokenBuilder.Mission` when enabled and
+ the header parses. WhoAmI gains a dedicated `GET /jwt/mission` endpoint
+ (`ChallengeForMission(ScopeWhoami){MissionAware=true}`) so the mission-aware
+ path is demoed independently of the plain three-party `/jwt`. +3 conformance
+ tests (422 → 425).
+- **MissionAgent CLI (`samples/MissionAgent/`).** New standalone console driving
+ the full lifecycle against the **live** mock servers (AP:5301 → PS:5100 →
+ WhoAmI:5000 `/jwt/mission`): enrol → propose mission → access the mission-aware
+ resource (out-of-scope **prompt**, then prior-consent **silent**) → pre-approved
+ permission (silent short-circuit) → non-pre-approved permission (prompt) →
+ audit → question → completion/terminate. Each resource access **refreshes the
+ agent token** (`AgentProviderClient.RefreshAsync` → fresh `jti`) — required
+ because the live resource's default JTI replay detection rejects a re-presented
+ agent token; this also mirrors real agent token rotation.
+- **DEVIATION — genuine browser interactivity (user chose "interactive").**
+ Rather than only printing the consent URL while the PS auto-resolves, the mock
+ PS now supports a real browser decision, gated by
+ `MissionConsentScript.InteractiveBrowser` (default false, so the 12-row test's
+ scripted auto-resolve is unaffected). When set: the mission-pending /
+ permission-pending GETs hold at `202` (re-emitting the interaction header)
+ until a `MissionPendingEntry.Decision` is recorded, and the browser
+ `/interaction` GET + `/interaction/approve` + `/interaction/deny` handlers now
+ recognise mission-pending codes and set that decision. `/permission` emits the
+ interaction header when interactive; `/admin/mission-script` accepts
+ `{interactive}`. The CLI defaults to interactive (`--auto` opts back into
+ scripted). Verified live end-to-end both ways (auto full run; interactive via
+ out-of-band `POST /interaction/approve` for the step-3 token and step-6
+ permission prompts).
+- **Files:** new `samples/MissionAgent/{MissionAgent.csproj, Program.cs,
+ README.md}` (already in `AAuth.slnx`); modified `samples/MockPersonServer/
+ {MissionGovernance.cs, Program.cs}`, `samples/WhoAmI/Program.cs`,
+ `src/AAuth/Agent/Mission.cs`, `src/AAuth/Server/Challenge/{ChallengeOptions,
+ AAuthChallengeMiddleware}.cs`; new test `tests/AAuth.Tests/Integration/
+ MissionAgentFlowTests.cs`; modified `tests/AAuth.Conformance/HttpSignatures/
+ ChallengeMiddlewareTests.cs`.
+- **Validation:** Conformance 425 (+3), AAuth.Tests 383 (+12); `AAuth.slnx`
+ builds 0 warnings / 0 errors; MissionAgent live smoke (auto + interactive) green.
+
+#### Phase 6a legibility follow-ups (2026-06-05, complete)
+
+- **Interactive mission-creation screen.** Mission approval is the most important
+ consent in the model, so `/mission` now defers (`202` + interaction) to a real
+ browser consent screen when `InteractiveBrowser` is set — the same deferred
+ path the token/permission gates use. SDK `MissionClient.ProposeAsync` already
+ routes through `DeferredExchange`, so no SDK change was needed; only the mock
+ PS gained `MissionPendingKind.Mission` + `MissionPendingEntry.Proposal`, a new
+ `GET /mission-create-pending/{id}` that builds+saves the approval blob on
+ approve (or `403` on decline), and the consent page now branches on creation
+ (heading "start a new mission"; shows description + `Tools`; no `s256`/resource
+ yet). `MissionAgent.ProposeAsync` passes `GovernanceFor(...)` so the browser
+ opens. Scripted mode (the 12-row test) keeps `InteractiveBrowser = false` and
+ the synchronous auto-approve path → unchanged, 12/12.
+- **Consent-screen tool context.** `MissionPolicyStore.ApprovedTools(s256)` added;
+ the token and permission consent screens now show the mission's approved tools
+ as context, so the human sees the full mission a request sits under. On the
+ permission screen this makes the gate self-explanatory: `Approved tools:
+ send_email, summarize` next to `Action: delete_inbox`.
+- **Spec wording confirmed (§Permission Endpoint L1015–1017, L1303).** Per-call
+ permission requests carry an **`action`**; the mission pre-approves
+ **`approved_tools`** (tool objects). The agent calls the permission endpoint
+ only for actions not covered by a pre-approved tool. Consent-screen labels and
+ the README sequence diagram use this distinction.
+- **README sequence diagram** now draws all three browser consent screens
+ (mission creation, out-of-scope token gate, out-of-tool permission) plus the
+ silent pre-approved-tool path, with the `action` vs `approved_tools` wording.
+- **Files (this follow-up):** modified `samples/MockPersonServer/
+ {MissionGovernance.cs, Program.cs}`, `samples/MissionAgent/{Program.cs,
+ README.md}`. No SDK/test changes; suites unchanged (425 / 383).
+
+### Phase 6b — Blazor apps + consent-UX refinements + out-of-mission scope gate (2026-06-06, in progress)
+
+- **GuidedTour + SampleApp mission flows (built, live-verified).** GuidedTour
+ `TourMode.Mission` (14-step raw-HTTP walkthrough) and SampleApp `Mission.razor`
+ (one-page 4-gate run) both drive: mission approval **prompt** → in-scope
+ `whoami` token **silent** → pre-approved `send_email` tool **silent** →
+ non-pre-approved `delete_inbox` tool **prompt**. Live-verified end-to-end.
+- **BUG FOUND + FIXED — `whoami` proposed as a tool.** GuidedTour's proposal
+ listed `whoami` in `tools`, which is wrong: `whoami` is a **resource scope**
+ (remote, §Scopes), not a local tool (§Permission Endpoint). After the
+ separate scope/tool lists were added it showed `whoami` in BOTH the consent
+ screen's scope list and tool list. Fixed the proposal to `summarize` +
+ `send_email`. Confirms the **tool-vs-scope** distinction matters in the demo
+ data, not just the docs.
+- **Consent-UX refinements (MockPersonServer `/interaction`, all live-verified):**
+ - **Separate scope + tool lists** (§Scopes vs §Permission Endpoint) so the
+ user sees both halves of the mission's authority distinctly.
+ - **Definition box** grounding the two words: *resource scope* = remote access
+ via auth token; *tool* = local action via the permission endpoint.
+ - **Creation screen lists NO scopes.** Per §Mission Creation (L1233) a proposal
+ carries only `description` + optional `tools` — never scopes. The creation
+ screen now shows the description + tools and a note that **the PS will
+ determine the resource scopes the mission needs from its description,
+ per-request, as the agent works** (§Scopes L1793 "The PS evaluates requested
+ scopes against mission context"; §Concurrent Token Requests L828). This
+ corrected an earlier draft that wrongly implied scopes were pre-determined
+ at creation.
+ - **CLARIFIED SPEC SEMANTICS — scopes are determined dynamically over the
+ mission's whole life, never fixed at creation.** A mission is a standing
+ natural-language context; the PS is a per-request judge (§Overview L141
+ "every resource access is evaluated in context"; §Token endpoint L784 — the
+ PS *remembers* prior consent within a mission so decisions accrue rather than
+ being declared up front). Out-of-mission scope ⇒ **gate 3 prompt**, not
+ auto-deny; only an explicit user deny (or `mission_terminated`) yields
+ `access_denied`.
+ - **Post-creation gates relabel the scope list "Granted so far"** (accrual
+ framing) with empty state "nothing yet — this is the first request"; the
+ token-gate request note now reads "Not yet covered by this mission — approve
+ to grant this scope (the agent may reuse it for the rest of the mission)",
+ teaching that the grant is remembered.
+ - Removed the now-unused `MissionConsentScript script` DI param from the
+ `/interaction` GET handler.
+- **docs/concepts.md** Governance section rewritten to convey the asymmetry:
+ **tools are declared, scopes are evaluated** (per-request, lifetime-long).
+ MissionAgent README gained the same distinction.
+
+- **NEW WORK (designed 2026-06-06, decisions D6–D9 in plan) — out-of-mission
+ scope gate.** The original Phase 6 plan intended a prompted **token/scope**
+ gate ("out-of-scope token request that prompts") but 6b shipped only the
+ prompted **tool** gate. Gap confirmed by reading `MissionPlan` (GuidedTour) and
+ `Mission.razor` — neither exercises an out-of-mission *scope*. To close it:
+ - **WhoAmI** gains a **new resource scope** (`whoami:history`, proposed) on a
+ **new mission-aware endpoint** (`/jwt/history`) via
+ `ChallengeForMission(...)`. Decision **D6**: new endpoint + new scope (not
+ reuse the existing non-mission `/jwt/admin` step-up) so the out-of-mission
+ scenario is unambiguous. WhoAmI already has the levers: `ChallengeForMission`
+ helper, `AddAAuthScopePolicy`, and `ScopeDescriptions` metadata — the new
+ path just needs excluding from the baseline `/jwt` branch.
+ - **Gate model becomes 5 gates** (decision **D7**): mission approval prompt →
+ `whoami` token silent → `whoami:history` token **PROMPT (out-of-mission
+ scope)** → `send_email` tool silent → `delete_inbox` tool prompt. Under the
+ seeded inbox mission (in-scope = `whoami` only), `whoami:history` naturally
+ falls outside → the existing `/token` gate-3 prompt path fires (no PS logic
+ change needed — the seed already excludes it).
+ - **Apps to update:** SampleApp `Mission.razor` (insert gate 3), GuidedTour
+ `TourSession.cs` MissionPlan + step methods + approval/poll constants +
+ `CodeSnippets.cs` + `Tour.razor`, and **`samples/MissionAgent/`** (decision
+ **D8**: the "agent console app" = MissionAgent, which has a mermaid sequence
+ diagram to extend with the out-of-mission scope consent block) + its README.
+ - **Playwright specs** assert the new prompted-scope gate.
+- **AgentConsole is NOT in scope** (subagent-confirmed): it has no mission
+ support and its README has **no mermaid diagrams** (only tables + example
+ invocations). The user's "agent console app" referred to MissionAgent.
+- **Status:** consent-UX refinements + bug fix + concept doc DONE and
+ live-verified; out-of-mission scope gate DESIGNED (this entry) and pending
+ implementation. SampleApp consent-URL reorder (amendment 3) built, not yet
+ live-verified. Playwright specs pending. Builds: MockPersonServer / SampleApp /
+ GuidedTour all 0/0.
+
+### Phase 6b — e2e validation + jti-replay fix (2026-06-06, complete)
+
+- **e2e specs written.** `samples/GuidedTour/playwright-tests/mission.spec.ts`
+ (20-step, three-cycle lifecycle + elevated-deny) and
+ `samples/SampleApp/playwright-tests/mission.spec.ts` (five-gate run + elevated-deny).
+ Both projects boot fresh via Playwright's `webServer` array.
+- **BUG FOUND via e2e + FIXED — GuidedTour mission flow hung at the elevated
+ cycle.** Root cause was a **jti replay**: `TourSession` reused a single
+ `_agentToken` (fixed `jti`) for BOTH the `/jwt/mission` (cycle 1) and
+ `/jwt/mission/elevated` (cycle 2) signed requests. WhoAmI's `InMemoryJtiStore`
+ recorded the `jti` on the first access and rejected its reuse on the second →
+ a **bare 401** (no `AAuth-Requirement`) → `_resourceToken` stayed stale
+ (`scope=whoami`) → the elevated `/token` exchange evaluated as in-scope and
+ returned **200 silent** instead of the spec-required **202 prompt** (§Agent
+ Token Request gate 3) → `_userApproved` was never reset to `false` →
+ `StepUserApprovesPlaceholder()` no-oped (it only throws when `!_userApproved`)
+ → the run-all dispatch loop never advanced past step 12 → infinite redispatch.
+ Both observed symptoms (silent-grant + infinite loop) had this single cause.
+- **FIX (spec §Agent Token, replay protection):** added
+ `TourSession.RefreshAgentToken()` (re-mints `_agentToken` with a fresh `jti`
+ via `AgentTokenBuilder.Build()`) and call it at the top of
+ `StepMissionResourceChallengeAsync` AND `StepMissionElevatedChallengeAsync`,
+ so each signed resource access presents a distinct agent token — exactly as
+ `MissionAgent` (and SampleApp's `AccessMissionResourceAsync`) already did via a
+ per-access refresh. SampleApp was already correct; GuidedTour was the only
+ app missing the refresh.
+- **TEST-HARNESS LESSON (not a product bug).** WhoAmI mints an ephemeral
+ resource signing key (`AAuthKey.Generate()`) at every startup, so restarting
+ WhoAmI alone while a long-running PS keeps its cached JWKS yields
+ `401 invalid_resource_token: JWT signature verification failed`. Always boot
+ the whole AAuth backend set together (let Playwright's `webServer` boot all,
+ or `pkill -f "dotnet run --project samples"` first). `jti` replay state also
+ accumulates across e2e runs against a long-lived WhoAmI — prefer fresh
+ full-stack boots. Recorded in repo memory.
+- **DIAG removed.** All temporary `[DIAG-CHAL]` (SDK
+ `AAuthChallengeMiddleware.cs`) and `[DIAG]` (`TourSession.cs`) tracing removed.
+- **Validation:** `AAuth.slnx` builds 0/0. `AAuth.Tests` 383/383,
+ `AAuth.Conformance` 425/425. GuidedTour mission specs (lifecycle + deny) green;
+ SampleApp mission specs (five-gate + deny) green.
+
+### Phase 7 — doc audit (2026-06-06)
+
+Read-only audit of every `docs/**` file that mentions missions/governance
+against the shipped Phase 1–6 public API, to re-scope Phase 7 before writing
+(user: "Phase 7 may need updates as we touched new things during
+implementation"). Findings drive the revised Phase 7 file list + DoD in the
+implementation plan.
+
+- **`docs/advanced/missions.md` is fundamentally stale.** It documents a
+ **non-existent** model: `Mission.Id`, `Mission.Status` (pending/approved/
+ denied/completed), `Mission.Requirements` (JsonArray), `Mission.StatusUrl`/
+ `InteractionUrl`, `Mission.FromJson(JsonObject)`, and
+ `AAuthMissionHeader.Format(string missionId)` — plus a resource-proposes-mission
+ lifecycle mermaid. The shipped model is the spec **approval blob**:
+ `Mission { Approver, Agent, ApprovedAt, Description, ApprovedTools,
+ Capabilities, S256, RawBytes, State(Active|Terminated) }`,
+ `FromApprovalBytes`/`VerifyS256`/`ComputeS256`, and
+ `AAuthMissionHeader.FormatStructured(approver, s256)`/`TryParseStructured`.
+ Requires a full rewrite, not a patch.
+- **Agent-side governance clients have ZERO doc coverage.** `AAuthGovernanceClient`
+ facade + `MissionClient.ProposeAsync`, `PermissionClient.RequestAsync` (both
+ overloads), `AuditClient.RecordAsync`, `InteractionClient.SendAsync`/
+ `RelayInteractionAsync`, all DTOs (`MissionProposal`, `PermissionRequest`/
+ `PermissionResult`/`PermissionGrant`, `AuditRecord`, `InteractionRequest`/
+ `InteractionResult`/`InteractionType`, `GovernanceOptions`), and
+ `AAuthClientBuilder.BuildGovernance()` — none documented.
+- **Clarification chat has ZERO doc coverage.** `ClarificationExchange`
+ (`DefaultMaxRounds = 5`), `ClarificationResponse` (`Respond`/`Update`/`Cancel`),
+ `ClarificationRequirement`, and the `TokenExchangeRequest`/`GovernanceOptions`
+ `OnClarificationRequired` + `MaxClarificationRounds` hooks.
+- **Server governance seams have ZERO doc coverage.** `IMissionStore`/
+ `StoredMission`/`InMemoryMissionStore`, `IPermissionDecider`/
+ `PermissionDecisionContext`/`PermissionDecision`/`PermissionOutcome`/
+ `PermissionDecisionReason`, `IAuditSink`, `IInteractionRelay`/
+ `InteractionRelayResult`, `IMissionLog`/`MissionLogEntry`/`MissionLogEntryKind`/
+ `InMemoryMissionLog`, `GovernanceEndpoints`, and `AddAAuthGovernance` DI.
+- **Smaller gaps.** `AAuthMissionTerminatedException` (`mission_terminated`) is
+ absent from `error-handling.md`; `ChallengeOptions.MissionAware` (the
+ mission-aware resource that copies the `AAuth-Mission` claim into the resource
+ token) is absent from `challenge-middleware.md`; `README.md`'s API Map lacks
+ rows for every governance client + server seam; the six mission token-exchange
+ params (`Justification`/`LoginHint`/`Tenant`/`DomainHint`/`Platform`/`Device`)
+ are undocumented.
+- **Already correct (Phase 6 / earlier work) — no rewrite needed.**
+ `docs/concepts.md` (tools-declared-vs-scopes-evaluated split, corrected in
+ 6b), `docs/workflows/call-chaining.md` (mission forwarding via
+ `WithCallChaining`/`MissionForwardingHandler`), and `docs/server/
+ token-issuance.md` (the `mission.approver` constraint) only need cross-link /
+ index touch-ups.
+- **Design choices recorded (D10–D11, user-approved 2026-06-06).** D10: the
+ agent-side governance clients get a dedicated `docs/advanced/
+ mission-governance-clients.md` (keeps `docs/server/mission-governance.md`
+ focused on PS/server seams). D11: clarification chat gets its own
+ `docs/advanced/clarification-chat.md`; all smaller touch-ups land in Phase 7.
+
+### Phase 8 — multi-subagent review (2026-06-06, complete)
+
+Seven review subagents (R1–R7), one per change set, produced severity-graded
+findings with spec citations. Baseline before remediation: build 0/0, unit 383,
+conformance 425, mission e2e 4/4. Verdicts: R7 PASS; R1–R6 PASS-WITH-NITS.
+
+**Spec corrections / remediations applied:**
+
+- **R1 (mission model) — non-spec capability value removed.** `AAuthCapabilities
+ Header.Capabilities.Mission = "mission"` was an out-of-spec fourth capability
+ value. §AAuth-Capabilities Request Header (~L1766) defines exactly three:
+ `interaction`, `clarification`, `payment`. The constant had **zero production
+ usages** (only an arbitrary-token `Union` dedup test). Removed the constant;
+ updated the test to use a real value; fixed stale `§14.1` citations to
+ `§AAuth-Capabilities` across `AAuthCapabilitiesHeader.cs`,
+ `AAuthSigningHandler.cs`, and `CapabilitiesHeaderTests.cs`.
+- **R1 / R3 / R7 — untrusted-Markdown hardening.** Added an XML-doc remark to
+ `Mission.Description` stating it is server-supplied untrusted content that
+ consumers MUST sanitize before rendering (§Markdown ~L192). The SDK already
+ never renders it; this is defense-in-depth guidance for consumers. (Blazor
+ samples already never render the description — confirmed by R6.)
+
+**Findings explicitly deferred (with rationale), NOT auto-remediated:**
+
+- **R3 — `capabilities` array in the PS token-request body.** §AAuth-Capabilities
+ (~L1776) says the *header* "is not used on requests to PS endpoints — the PS
+ learns the agent's capabilities through the mission approval flow." The SDK
+ sends a `capabilities` **body** parameter (not the header) on the token
+ request. This is **pre-existing** behavior (commits `d8cf70a` / `1f235a4`,
+ "live interop fixes"), validated against `person.hello.coop`, and fills a real
+ gap: for the non-mission deferred-consent flow the PS otherwise has no way to
+ know the agent can handle a `202` interaction redirect. It is a draft-02
+ token-endpoint extension, outside the mission change sets. Decision: flag to
+ the user as a spec-alignment question; do not change unilaterally (removing it
+ risks breaking deferred consent + live interop).
+- **R4 — `AuditClient` accepts `200`/`204` as well as `201`.** §Audit Endpoint
+ specifies `201 Created`. Accepting other 2xx is lenient for a fire-and-forget
+ audit call; low risk, kept as-is.
+- **R2 / R4 — minor test-coverage gaps** (aauth-mission anti-double-coverage
+ edge case; Interaction/Payment relay deferred-poll path). Implementations are
+ correct; gaps noted for a future test pass, not blocking.
+- **R5 — unbounded growth of the in-memory store/log.** By design: the SDK
+ defaults are documented dev-grade; production PSes register durable stores via
+ the `TryAdd` seams.
+
+**Post-remediation gate:** build 0/0, unit 383, conformance 425, mission e2e 4/4
+— all green.
+
diff --git a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
new file mode 100644
index 0000000..021b4df
--- /dev/null
+++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
@@ -0,0 +1,926 @@
+# Mission API Refactor — Implementation Plan
+
+## Overview
+
+Streamline the AAuth .NET SDK's mission/governance surface into an API that can be
+constructed three ways — **static factories**, **fluent builders**, and
+**DI registration** — for both agent (client) and resource (PS) sides, update every
+mission sample and doc to the new surface, add a combined **clarification +
+mission + call-chain** SampleApp example, and land two small spec-hardening fixes.
+
+The API surface is built in **two passes**: a first pass (Phase 1) that gets a
+working end-to-end surface, then a consistency pass (Phase 2) that learns from the
+first and fine-tunes naming/shape to match the conventions already used elsewhere in
+the SDK. The closing phases independently audit samples, docs, and spec compliance.
+
+See [research.md](research.md) for the full current-state, pain-point, and gap
+inventory (Parts A–G) and the recorded design decisions (Open Design Choices).
+Significant issues and spec deviations are logged in
+[issues-and-deviations.md](issues-and-deviations.md). Every phase below cites the
+governing spec section.
+
+> **R3 (Rich Resource Requests)** is out of scope here — tracked in its own
+> initiative, `.agent/plans/2026-06-06-r3-rich-resource-requests/`.
+
+## Working Agreement (2026-06-06)
+
+Directives captured from the user for this initiative:
+
+- **Two-pass API design.** Phase 1 does a first pass at the surface; Phase 2 learns
+ from it and fine-tunes for consistency with existing SDK patterns.
+- **Construction triad.** Support **static factories**, **fluent builders**, and
+ **DI-friendly** registration for the mission/governance API.
+- **No regressions.** None of the existing flows may break — old behavior is
+ preserved; only the API shape changes.
+- **New sample has an e2e spec.** The combined SampleApp page ships with a Playwright
+ spec.
+- **Closing audits use subagents.** The final sample and doc validation phases each
+ use a dedicated subagent to surface inconsistencies and readability issues
+ (especially docs, GuidedTour code snippets, and SampleApp).
+- **Independent spec reviewer is the last phase.** A separate reviewer subagent
+ validates every change against the AAuth spec for 100% compliance. Fix the SDK
+ where it is not spec compliant.
+- **Deviation tracking.** Significant issues/deviations are recorded in
+ [issues-and-deviations.md](issues-and-deviations.md); research is always updated
+ with new findings.
+- **Gated execution.** Ask the user for permission before starting each phase. Do
+ **not** commit until the user says so. Surface major decisions at the end for
+ input; refactoring afterward is acceptable.
+
+## Context
+
+- **Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` — §Agent Governance,
+ §Mission Creation/Approval, §Permission Endpoint, §Audit Endpoint, §Interaction
+ Endpoint, §Clarification Chat, §Call Chaining, §AAuth-Capabilities, §Person Server
+ Metadata, §Agent Token Request.
+- **Upcoming:** `aauth-spec/upcoming-changes-02.md` (F1 capabilities body — already
+ correct; F5/F6 wait on draft-02).
+- **Branch:** `feat/missions-ps-governance` (continue).
+- **Sequencing:** Phase 1 API first pass (agent + resource) → Phase 2 API
+ consistency pass → Phase 3 spec hardening → Phase 4 sample migration → Phase 5 new
+ combined sample + e2e → Phase 6 mission convenience seam (`WithMission`) → Phase 7
+ docs → Phase 8 samples audit (subagent) → Phase 9 docs audit (subagent) → Phase 10
+ independent spec-compliance review (subagent).
+
+## Cross-Cutting Decisions
+
+The SDK is pre-1.0 and backward compatibility is **not** a concern (confirmed
+2026-06-06). The mission API is a **breaking refactor**: low-level signatures change
+and all call-sites are updated in place — no `[Obsolete]` shim, no dual surface.
+
+- **DC1 — Breaking refactor (no shim).** Replace the low-level mission client/
+ resource surface; migrate all callers (research Part C inventory).
+- **DC2 — Both client + resource ergonomics.** Address agent pain points
+ (PT-A1…A6) and resource pain points (PT-R1…R4).
+- **DC3 — Align with existing conventions.** Fluent builder like
+ `AAuthClientBuilder`; DI like `AddAAuthDiscovery`; app-builder mapper like
+ `MapAAuthResource` (research Part B).
+- **DC4 — One combined sample page.** Clarification + mission + call-chain in a
+ single SampleApp page reusing Orchestrator + WhoAmI as hops.
+- **DC5 — Construction triad.** Every primary entry point is reachable via a static
+ factory, a fluent builder, and DI registration, mirroring how `AAuthClient`,
+ `AddAAuthAgent`, and `MapAAuthResource` already coexist (research Part B).
+- **DC6 — No regressions.** All existing mission/clarification/call-chain flows keep
+ working; the four mission e2e specs stay green throughout.
+- **DC7 — Independent closing review.** Sample audit, doc audit, and spec-compliance
+ review are separate final phases, each driven by a dedicated subagent; findings
+ are adjudicated against spec text and logged in `issues-and-deviations.md`.
+
+---
+
+## Phase 1 — API surface: first pass (agent + resource)
+
+**Goal:** Get a working end-to-end mission/governance surface across **both** the
+agent (client) and resource (PS) sides in one pass. Naming and shape need not be
+final here — Phase 2 refines them. Fixes the agent pain points (PT-A1…A6) and the
+resource pain points (PT-R1…R4), and lands the construction triad (DC5).
+
+**Spec:** §Agent Governance (governance clients call PS endpoints); §Mission
+Creation/Approval (mission carried as `{approver, s256}`); §Permission/Audit/
+Interaction Endpoints (per-call mission claim, request/response shapes,
+`mission_terminated` 403); §Clarification Chat (deferred 202 handling); §Person
+Server Metadata (endpoint advertisement).
+
+### Approach — agent side
+
+- **Bind the PS once.** `AAuthClientBuilder.WithPersonServer(...)` already exists;
+ `BuildGovernance()` returns an `AAuthGovernanceClient` bound to that PS so
+ per-call `personServer` params are removed (PT-A2).
+- **Mission session auto-threads the claim.** `Mission.ProposeAsync(...)` returns a
+ `MissionSession` that wraps the approved `Mission` + the bound client and exposes
+ `Permission`/`Audit`/`Interaction` calls that inject `{approver, s256}`
+ automatically (PT-A1, PT-A5).
+- **Construction triad (DC5).** Static factory (`AAuthGovernanceClient.Create(...)`),
+ fluent builder (`AAuthClientBuilder…BuildGovernance(...)`), and DI
+ (`AddAAuthGovernanceClient(name, Action)` mirroring `AddAAuthAgent`)
+ (PT-A4).
+- **Default callbacks.** Bound `GovernanceOptions` defaults set once on the builder,
+ overridable per call (PT-A6).
+
+### Approach — resource side
+
+- **`MapAAuthGovernance(...)`** app-builder mapper (mirrors `MapAAuthResource`) maps
+ `/mission`, `/permission`, `/audit`, `/mission-interaction` and their pending/poll
+ routes, using `GovernanceEndpoints` parsers + the registered seams (PT-R1, PT-R2).
+- **Default no-op seams** registered via `TryAdd` in `AddAAuthGovernance(configure?)`
+ so a PS overrides only what it needs (PT-R3).
+- **Resource governance builder** for mission-aware challenge config, replacing the
+ bare `ChallengeOptions.MissionAware` bool (PT-R4).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/Governance/AAuthGovernanceClient.cs` | **Modify** — bind PS URL + default `GovernanceOptions`; drop per-call PS param; add `Create(...)` factory |
+| `src/AAuth/Agent/Governance/MissionSession.cs` | **New** — mission-scoped facade auto-threading the claim |
+| `src/AAuth/Agent/Governance/MissionClient.cs` | **Modify** — `ProposeAsync(proposal, options?)` → `MissionSession` |
+| `src/AAuth/Agent/Governance/PermissionClient.cs` | **Modify** — drop PS param; mission injected by session |
+| `src/AAuth/Agent/Governance/AuditClient.cs` | **Modify** — drop PS param; mission injected |
+| `src/AAuth/Agent/Governance/InteractionClient.cs` | **Modify** — drop PS param; mission injected |
+| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — `BuildGovernance()` binds PS + default options |
+| `src/AAuth/DependencyInjection/AAuthGovernanceClientServiceCollectionExtensions.cs` | **New** — `AddAAuthGovernanceClient(...)` |
+| `src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs` | **New** — `MapAAuthGovernance(Action?)` |
+| `src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs` | **Modify** — `AddAAuthGovernance(configure?)` + default no-op `IPermissionDecider`/`IAuditSink`/`IInteractionRelay` |
+| `src/AAuth/Server/Governance/GovernanceEndpoints.cs` | **Modify** — promote per-endpoint handlers (carrier-token check, parse, mission lookup, state check) |
+| `src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs` | **New** — route prefix + deferred/pending config |
+| `src/AAuth/Server/Challenge/ChallengeOptions.cs` | **Modify** — keep `MissionAware`; surface via resource builder |
+| `tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs` | **New** |
+| `tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs` | **New** |
+| `tests/AAuth.Tests/Governance/MissionSessionTests.cs` | **New** |
+
+### API Surface (illustrative)
+
+```csharp
+// Agent — fluent builder + bound PS + mission session
+AAuthGovernanceClient governance = AAuthClientBuilder
+ .SelfIssuing(key).As(issuer, agentId)
+ .WithPersonServer(ps) // PS bound once
+ .WithChallengeHandling()
+ .BuildGovernance(o => o.MaxClarificationRounds = 3);
+
+MissionSession mission = await governance.Mission.ProposeAsync(
+ new MissionProposal("Keep the inbox under control") { Tools = [...] });
+
+// claim + PS auto-threaded:
+PermissionResult r = await mission.Permission.RequestAsync("send_email");
+await mission.Audit.RecordAsync("send_email", result: ...);
+bool done = await mission.ProposeCompletionAsync("Inbox triaged.");
+
+// Resource — DI + mapper
+builder.Services.AddAAuthGovernance(o => o.UseInMemoryStores());
+builder.Services.AddSingleton();
+app.MapAAuthGovernance(); // maps the 4 endpoints + pending polls
+```
+
+### Implementation Decisions
+
+- DC1: no shim; `MissionSession` replaces manual `MissionClaim` extraction.
+- DC3: mapper follows the `MapAAuthResource` precedent; seams keep policy in the PS.
+- The bound `AAuthGovernanceClient` remains usable mission-lessly for permission
+ requests that carry no mission (§Permission Endpoint — mission optional).
+- Default `IInteractionRelay` returns `Pending` (no user channel) so a bare PS still
+ compiles and behaves predictably.
+
+### Definition of Done
+
+- [x] `BuildGovernance()` binds the PS URL and default `GovernanceOptions`.
+- [x] `MissionSession` injects `{approver, s256}` into permission/audit/interaction.
+- [x] Per-call `personServer` parameters removed from the governance clients. _(done in Phase 2, D1 — bound client is the only path)_
+- [x] Client reachable via static factory, fluent builder, and `AddAAuthGovernanceClient(...)`.
+- [x] `MapAAuthGovernance()` maps mission/permission/audit/interaction + poll routes. _(mission-creation via `IMissionApprover`; deferred 202 + poll via `IDeferredConsentStore`, Phase 2 D3)_
+- [x] `AddAAuthGovernance(configure?)` registers default no-op seams via `TryAdd`.
+- [~] `mission_terminated` 403 + carrier-token checks centralized in the mapper. _(403 termination centralized; carrier-token checks pending Phase 2)_
+- [x] New unit + conformance tests pass; full suite green (build 0/0).
+
+
+---
+
+## Phase 2 — API surface: consistency pass
+
+**Goal:** Learn from the Phase 1 first pass and fine-tune the surface so it reads
+consistently with the conventions already used elsewhere in the SDK. No new
+capability — naming, shape, and ergonomics only. Confirms the construction triad
+(DC5) behaves uniformly across agent and resource sides.
+
+**Spec:** as cited in Phase 1 (no new spec surface; shape alignment only).
+
+### Approach
+
+- **Convention diff.** Compare the Phase 1 surface against `AAuthClientBuilder`,
+ `AddAAuthDiscovery`/`AddAAuthAgent`, and `MapAAuthResource` (research Part B);
+ list naming/return-type/option-pattern divergences before changing anything.
+- **Normalize the triad.** Ensure the static factory, fluent builder, and DI
+ registration share parameter names, option types, and defaults so the three paths
+ are interchangeable and predictable (DC5).
+- **Tighten names/return types.** Align method names, async suffixes, option-bag
+ shapes (`Action` vs records), and nullability with the rest of the SDK.
+- **Symmetry check.** Agent-side and resource-side builders/options use the same
+ vocabulary (e.g. `With…` / `Use…` / `Map…`) as their non-mission counterparts.
+- **D1 — remove the transitional dual surface.** Drop the per-call `personServer`
+ parameters from `MissionClient` / `PermissionClient` / `AuditClient` /
+ `InteractionClient`; the bound client + `MissionSession` become the only path
+ (with sample migration completing in Phase 4). Reaches DC1's no-shim end state.
+- **D2 — keep `MissionSession` flat.** Confirm flat methods
+ (`RequestPermissionAsync`, `RecordAuditAsync`, `AskQuestionAsync`,
+ `ProposeCompletionAsync`); no nested facades.
+- **D4 — typed action POCO (replace bare strings).** Introduce a small
+ `MissionAction` POCO for the invoked action so callers pass a value object
+ instead of a `string`. Today `action` is a bare `string` on `PermissionRequest`,
+ `AuditRecord`, `MissionSession.RequestPermissionAsync/RecordAuditAsync`, and
+ `PermissionClient`. **Decision (2026-06-06):** model the *invocation* as a
+ distinct `MissionAction` rather than reusing `MissionTool` — the spec's `action`
+ is broader than a tool (covers file writes, message sends), and a dedicated type
+ avoids the redundant `MissionTool.Description` on the invocation path. Named
+ `MissionAction` (not bare `Action`) to avoid the `System.Action` clash. Keep
+ `MissionTool` as the *catalog* entry (proposal / `approved_tools`); `MissionAction`
+ is the *specific invocation*. Serialize the wire `action` field from
+ `MissionAction.Name`; add an implicit `string → MissionAction` conversion so terse
+ call sites (`"WebSearch"`) still compile. Update the `DefaultPermissionDecider`
+ match to compare `MissionAction.Name` against `ApprovedTools[].Name`.
+- **D3 — promote PS mission machinery into the SDK (closes DEV-1/DEV-2).** Move the
+ approval-blob builder out of the sample into the SDK and add an
+ `IMissionApprover` seam so `MapAAuthGovernance` can map mission creation; add a
+ deferred/pending consent abstraction so a `Prompt` outcome returns a 202 deferred
+ response instead of a denial. `DefaultPermissionDecider`/relay remain conservative
+ no-ops but the deferred path becomes available to PS implementers.
+
+### Files
+
+| File | Action |
+|------|--------|
+| Phase 1 source files | **Modify** — rename/reshape per the convention diff |
+| `src/AAuth/Agent/Governance/*Client.cs` | **Modify** — remove per-call `personServer` params (D1) |
+| `src/AAuth/Agent/MissionAction.cs` (new) + `PermissionRequest`/`AuditRecord`/`MissionSession`/`PermissionClient` | **Add/Modify** — accept `MissionAction`; implicit `string` conversion (D4) |
+| `src/AAuth/Server/Governance/*` (approval builder, `IMissionApprover`, pending/deferred seam) | **Add/Modify** — promote from sample (D3) |
+| `src/AAuth/.../MapAAuthGovernance` | **Modify** — map mission creation + deferred 202 (D3) |
+| `samples/MockPersonServer/*` | **Modify** — consume promoted SDK pieces where it reduces sample-local code |
+| `tests/AAuth.Conformance/Missions/*` | **Modify** — update to the finalized names; cover mission-create + deferred |
+| `tests/AAuth.Tests/Governance/*` | **Modify** — update to the finalized names |
+
+### Implementation Decisions
+
+- DC5: the finalized names from this pass are the public contract migrated in
+ Phases 4–6; record any rename decisions in research before applying.
+- **D1/D2/D3 confirmed by the user (2026-06-06):** additive-then-remove, flat
+ `MissionSession`, and promote the mission machinery + deferred consent into the
+ SDK. See [issues-and-deviations.md](issues-and-deviations.md).
+- Divergences that cannot be reconciled with existing conventions are logged in
+ [issues-and-deviations.md](issues-and-deviations.md) with rationale.
+
+### Definition of Done
+
+- [x] Convention diff recorded in research (Part B / Open Design Choices).
+- [x] Factory, builder, and DI paths share names/options/defaults across both sides.
+- [x] Public names align with `AAuthClientBuilder` / `AddAAuth*` / `MapAAuth*`.
+- [x] Per-call `personServer` params removed; bound client is the only path (D1).
+- [x] Action passed as a `MissionAction` POCO (implicit `string` for terse call sites) (D4).
+- [x] Mission creation mapped by `MapAAuthGovernance` via `IMissionApprover` (D3, DEV-2).
+- [x] `Prompt` outcome returns a deferred 202 via the pending-consent seam (D3, DEV-1).
+- [x] All Phase 1 tests updated and green; full suite green (build 0/0).
+
+
+---
+
+## Phase 3 — Spec hardening (F3 + F4)
+
+**Goal:** Tighten audit response handling and add `device` validation.
+
+**Spec:** §Audit Endpoint (PS returns `201 Created`); §Agent Token Request
+(`device` MUST be UTF-8 printable, ≤64 chars).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/Governance/AuditClient.cs` | **Modify** — accept only `201 Created` (F3) |
+| `src/AAuth/Agent/TokenExchangeRequest.cs` | **Modify** — validate `device` (printable ASCII 32–126, ≤64) (F4) |
+| `tests/AAuth.Conformance/Missions/AuditResponseTests.cs` | **New/Modify** |
+| `tests/AAuth.Conformance/TokenExchange/DeviceValidationTests.cs` | **New** |
+
+### Definition of Done
+
+- [x] `AuditClient` rejects non-201 acknowledgments.
+- [x] `device` rejects control chars and lengths > 64 with a clear exception.
+- [x] Tests cover boundary cases; full suite green.
+
+---
+
+## Phase 4 — Migrate mission samples to the new API
+
+**Goal:** Update all mission sample call-sites (research Part C) to the Phase 1–2
+surface. No behavior change; API shape only.
+
+**Spec:** as cited in Phases 1–2.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `samples/MissionAgent/Program.cs` | **Done** — bound PS via `WithMission`; `MissionSession`; no manual `MissionClaim` (folded into Phase 6) |
+| `samples/MockPersonServer/Program.cs` | **Kept hand-wired** — see DEV-4; agent-facing parsing already on SDK (`GovernanceEndpoints` + `MissionApprovalBuilder`) |
+| `samples/MockPersonServer/MissionGovernance.cs` | **No change** — see DEV-4 |
+| `samples/SampleApp/Components/Pages/Mission.razor` | **Done** — `MissionSession` (`ProposeMissionAsync` → `session.RequestPermissionAsync`/`RecordAuditAsync`); gates preserved |
+| `samples/GuidedTour/TourSession.cs` + `CodeSnippets.cs` | **Done** — teaching snippets on the session API; raw-wire steps preserved (pedagogy) |
+| `samples/WhoAmI/Program.cs` | **No change** — `ChallengeOptions { MissionAware = true }` is the canonical resource seam (DEV-5) |
+
+### Definition of Done
+
+- [x] All mission samples build and run against the new API. _(SampleApp builds 0/0; agent-side call-sites on the session API.)_
+- [x] Mission e2e specs (4) pass unchanged in behavior. _(SampleApp + GuidedTour mission specs: 4/4 green after migration.)_
+- [x] No leftover manual `MissionClaim`/PS-URL threading in samples. _(Agent-side clean; server-side parses incoming claims, which is correct. DEV-4/DEV-5 record the two server-side line-items intentionally not rewritten.)_
+
+---
+
+## Phase 5 — New combined SampleApp example (clarification + mission + call-chain)
+
+**Goal:** Add one SampleApp page demonstrating a clarification round during mission
+approval, then a mission-governed multi-hop call chain. Fills the two sample gaps
+(research Part D).
+
+**Spec:** §Clarification Chat (202 + `AAuth-Requirement: clarification`; bounded
+rounds; untrusted text → sanitize); §Call Chaining (mission present → forward
+`AAuth-Mission` each hop; per-hop PS re-evaluation; `act` nesting).
+
+### Files
+
+| File | Action |
+|------|--------|
+| `samples/SampleApp/Components/Pages/MissionCallChain.razor` | **New** — combined flow page |
+| `samples/SampleApp/Components/Pages/Home.razor` | **Modify** — add card/link |
+| `samples/MockPersonServer/MissionGovernance.cs` | **Modify** — script a clarification round during mission approval |
+| `samples/Orchestrator/Program.cs` | **Modify (if needed)** — ensure mission forwarding hop is exercised |
+| `tests/e2e/` (Playwright spec) | **New** — combined-flow spec |
+
+### Implementation Decisions
+
+- DC4: single page; Orchestrator + WhoAmI as downstream hops.
+- Clarification text is rendered only after sanitization (untrusted input).
+
+### Definition of Done
+
+- [x] Page shows a clarification round (respond/update/cancel) during mission approval. _(`MissionCallChain.razor` step 2: `OnClarificationRequired` surfaces the sanitized question, agent answers, then the user approves the out-of-mission elevated scope.)_
+- [x] Mission is forwarded through the orchestrator; downstream hop is governed. _(step 3: `WithMission(...)` carries the mission to the Orchestrator `/mission` endpoint, which forwards `AAuth-Mission` to the WhoAmI `/jwt/mission` hop — chain result asserts `downstream.mode == "three-party"`, `agent == aauth:orchestrator@localhost:5200`, `mission` truthy.)_
+- [x] Mission log/trail surfaced in the UI. _(PS-held `/admin/mission-log` rendered in the `[data-test="mission-log"]` table, including the clarification entry.)_
+- [x] New Playwright spec passes; full backend stack boots via webServer array. _(`samples/SampleApp/playwright-tests/mission-call-chain.spec.ts` green; full `sample-app` suite 15 passed + 1 pre-existing skip, two consecutive clean CI runs.)_
+
+---
+
+## Phase 6 — Mission convenience seam (`WithMission`)
+
+**Goal:** Close PT-A7 (research Part A.2). Add an
+`AAuthClientBuilder.WithMission(Mission)` seam that auto-emits the `AAuth-Mission`
+header from an agent's own approved mission, so a mission-holding agent composes
+`WithMission(...) + WithChallengeHandling() + WithInteractionHandling()` and the
+entire resource-access leg (header + 401→exchange→retry) collapses to one signed
+`SendAsync`. Retrofit the non-pedagogical sample (`MissionAgent`) to the seam;
+leave the step-by-step teaching surfaces (`SampleApp/Mission.razor`, GuidedTour,
+and the Phase 5 combined page) deliberately explicit so each gate stays visible.
+
+**Spec:** §Mission Context at Resources — "The agent includes the `AAuth-Mission`
+header when sending requests to resources, unless the mission is already conveyed in
+an auth token"; §HTTP Message Signatures — "When the agent is operating in a mission
+context, it includes the `AAuth-Mission` header and adds `aauth-mission` to the
+signed components." The SDK already auto-covers `aauth-mission` whenever the header
+is present ([AAuthSigningHandler](../../../src/AAuth/HttpSig/AAuthSigningHandler.cs)),
+so the seam only needs to set the header.
+
+### Approach
+
+- **`MissionHeaderHandler` (new).** A small `DelegatingHandler` that sets
+ `AAuth-Mission` from a directly-held `Mission` (`{approver, s256}`) on each
+ outbound request, mirroring `MissionForwardingHandler` but sourcing the mission
+ directly instead of extracting it from an upstream token. The signing handler
+ beneath it covers the `aauth-mission` component automatically.
+- **`AAuthClientBuilder.WithMission(Mission)`.** Stores the mission and inserts the
+ handler at the top of the pipeline (above interaction/refresh/challenge), so the
+ header is present before the request is signed. Composes with
+ `WithChallengeHandling()` / `WithInteractionHandling()`. Idempotent with the
+ existing header — never emit `AAuth-Mission` twice (skip if the caller already set
+ it, matching the call-chaining carve-out).
+- **Carve-out honored.** `WithMission(...)` is for the **originating** agent that
+ holds its own approved mission; call-chaining intermediaries keep using
+ `MissionForwardingHandler` (mission extracted from the upstream token). The two are
+ mutually exclusive on a given client.
+- **Retrofit `MissionAgent`.** Replace the manual header + challenge cycle in
+ `AccessMissionResourceAsync` with a `WithMission(...)`-composed client; preserve
+ the per-request agent-token refresh (replay `jti`) behavior.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `src/AAuth/Agent/MissionHeaderHandler.cs` | **New** — emits `AAuth-Mission` from a held `Mission` |
+| `src/AAuth/AAuthClientBuilder.cs` | **Modify** — add `WithMission(Mission)`; insert handler in `BuildHandler()` |
+| `samples/MissionAgent/Program.cs` | **Modify** — collapse `AccessMissionResourceAsync` onto the seam |
+| `tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs` | **New** — header emitted + signed; not duplicated; carve-out |
+
+### Implementation Decisions
+
+- **D5 — originating-agent seam (2026-06-06).** `WithMission(...)` sources the
+ mission directly; `MissionForwardingHandler` stays the call-chaining path. The
+ "unless conveyed in an auth token" carve-out remains the agent's decision —
+ `WithMission(...)` is only wired when the agent holds an approved `Mission` and is
+ the originator. Spec-backed by research Part A.2 PT-A7 update.
+- Teaching surfaces stay explicit by design (DC4 pedagogy): only `MissionAgent` is
+ collapsed this phase; `Mission.razor`, the combined page, and GuidedTour keep the
+ visible gate-by-gate flow.
+
+### Definition of Done
+
+- [x] `WithMission(Mission)` emits a spec-correct `AAuth-Mission` header that the
+ signing handler covers as `aauth-mission`.
+- [x] Header is not emitted twice when already present (call-chaining carve-out).
+- [x] `MissionAgent.AccessMissionResourceAsync` collapsed onto the seam; behavior
+ unchanged (per-request refresh + replay `jti` preserved).
+- [x] New conformance test covers emit + signature coverage + de-dup; full suite
+ green (build 0/0).
+
+---
+
+## Phase 7 — Docs update
+
+**Goal:** Bring mission docs to the new API.
+
+**Spec:** as cited above.
+
+### Files
+
+| File | Action |
+|------|--------|
+| `docs/advanced/missions.md` | **Modify** — new surface |
+| `docs/advanced/mission-governance-clients.md` | **Modify** — `MissionSession` lifecycle |
+| `docs/advanced/clarification-chat.md` | **Modify** — link the new combined sample |
+| `docs/server/mission-governance.md` | **Modify** — `MapAAuthGovernance()` + default seams |
+| `docs/server/challenge-middleware.md` | **Modify** — resource governance builder |
+| `docs/workflows/mission-governed-access.md` | **Modify** — updated walkthrough |
+
+### Definition of Done
+
+- [x] All mission docs reflect the new API; code blocks compile against the surface. _(Rewrote the stale faceted/per-call-PS examples in `mission-governance-clients.md`, `mission-governed-access.md`, `clarification-chat.md`, and `error-handling.md` to the bound `AAuthGovernanceClient` + `MissionSession` surface; added `MapAAuthGovernance()` + the no-op default seams and `AddAAuthDeferredConsent()` to `server/mission-governance.md`; `challenge-middleware.md`'s `ChallengeOptions { MissionAware = true }` is already the canonical resource seam per DEV-5.)_
+- [x] `WithMission(...)` convenience seam documented alongside `WithChallengeHandling`. _(New "Carrying your own mission with `WithMission`" section in `missions.md`, and the resource-access step of `mission-governed-access.md` now composes `WithMission(...)` + `WithChallengeHandling()`; the combined sample is linked from both `missions.md` and `clarification-chat.md`.)_
+
+---
+
+## Phase 8 — Samples consistency audit (subagent)
+
+**Goal:** With fresh eyes, validate that **every** sample uses the new API surface
+and reads cleanly. A dedicated subagent surfaces inconsistencies, leftover old-API
+usage, and readability problems across the sample projects; findings are adjudicated
+and remediated.
+
+**Spec:** as cited in Phases 1–5 (the audit confirms samples match those citations).
+
+### Approach
+
+- **Subagent sweep.** Launch an exploration subagent scoped to `samples/**` to list
+ every mission/clarification/call-chain call-site, flag any that still use the old
+ surface, manual `MissionClaim` threading, repeated PS URLs, or inconsistent
+ construction styles, and rank readability issues.
+- **Adjudicate + remediate.** Triage findings against the finalized Phase 2 surface;
+ fix in place. Log anything that turns out to be a genuine SDK gap or deviation in
+ [issues-and-deviations.md](issues-and-deviations.md).
+
+### Definition of Done
+
+- [x] Subagent report captured; each finding marked fixed / deferred / not-an-issue. _(Audit found zero stale faceted calls and zero manual `MissionClaim` constructions across `samples/**`; every remaining manual `AAuthMissionHeader.FormatStructured` is either a deliberately-explicit teaching surface — `Mission.razor`, `MissionCallChain.razor` snippet, GuidedTour `TourSession.cs`/`CodeSnippets.cs` — or the MockPersonServer acting as the legitimate header producer. All marked not-an-issue.)_
+- [x] No sample retains old-API mission usage or manual claim/PS threading. _(Confirmed: all governance call sites use the PS-bound `AAuthGovernanceClient` ctor → `ProposeMissionAsync` → flat `MissionSession` methods; call-chaining intermediaries `AgentConsole`/`Orchestrator` correctly use `WithCallChaining`, never `WithMission`.)_
+- [x] Significant issues logged in `issues-and-deviations.md`; research updated. _(No new issues — audit was clean; nothing to log beyond DEV-6/7/8 already recorded.)_
+- [x] All samples build/run; mission + combined e2e specs green. _(MissionAgent Phase-6 seam collapse verified clean; combined sample-app suite 15 passed + 1 pre-existing skip, two consecutive CI runs.)_
+
+---
+
+## Phase 9 — Docs & code-snippet consistency audit (subagent)
+
+**Goal:** Validate that all docs and embedded code snippets — especially GuidedTour
+snippets and SampleApp walkthroughs — use the new API and read cleanly. A dedicated
+subagent surfaces inconsistencies; findings are adjudicated and remediated.
+
+**Spec:** as cited in Phases 1–6 (the audit confirms docs match those citations).
+
+### Approach
+
+- **Subagent sweep.** Launch an exploration subagent scoped to `docs/**` plus
+ GuidedTour/SampleApp snippet sources to flag old-API code blocks, stale prose,
+ broken cross-links, and readability issues.
+- **Adjudicate + remediate.** Update docs/snippets to the finalized surface; verify
+ code blocks compile against it. Log SDK gaps/deviations in
+ [issues-and-deviations.md](issues-and-deviations.md).
+
+### Definition of Done
+
+- [x] Subagent report captured; each finding marked fixed / deferred / not-an-issue. _(One file flagged — `docs/reference/dependency-injection.md`: stale "no dedicated DI extension" prose, a `BuildGovernance()` snippet missing `.WithPersonServer(...)` (would throw at runtime), and prose omitting the PS requirement. All three FIXED. All other mission/governance/clarification/call-chain docs verified clean against the GuidedTour `CodeSnippets.cs` ground truth.)_
+- [x] All mission docs + GuidedTour/SampleApp snippets reflect the new API. _(GuidedTour `CodeSnippets.cs` already compiles against the surface and was used as ground truth; docs now match it.)_
+- [x] Code blocks compile against the surface; cross-links resolve. _(`dependency-injection.md` now documents `AddAAuthGovernanceClient(...)` + PS-bound `BuildGovernance()`; cross-links/anchors `token-issuance.md#mission-claims`, `error-handling.md#mission-termination`, `challenge-middleware.md#mission-aware-resources` and the `MissionCallChain.razor` sample path all resolve.)_
+- [x] Significant issues logged in `issues-and-deviations.md`; research updated. _(Doc-only fix, no SDK gap — nothing new to log beyond DEV-6/7/8.)_
+
+---
+
+## Phase 10 — Independent spec-compliance review (subagent)
+
+**Goal:** A separate reviewer subagent independently validates **each change** in
+this initiative against the AAuth spec to confirm 100% compliance. Where the SDK is
+found non-compliant, fix it (DC6 still holds — fixes must not break existing flows).
+
+**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (all cited sections);
+`aauth-spec/upcoming-changes-02.md` for F1/F5/F6 context.
+
+### Approach
+
+- **Independent review.** Launch a reviewer subagent that walks the diff of this
+ initiative phase by phase and checks every behavior against the governing spec
+ section, with no assumption that prior phases were correct.
+- **Adjudicate against spec text.** Each flagged item is confirmed against the spec
+ before any change; genuine non-compliance is fixed in the SDK, deviations that are
+ intentional or pending draft-02 are documented.
+- **Final ledger.** All significant findings recorded in
+ [issues-and-deviations.md](issues-and-deviations.md); research updated with the
+ closing compliance summary.
+
+### Definition of Done
+
+- [x] Reviewer subagent report captured; every finding adjudicated against spec text. _(Reviewer walked all six areas — `AAuth-Mission` header + signed-component coverage, mission claim shape + verbatim-bytes hash, the four endpoint request/response shapes, the deferred-consent 202 flow incl. the DEV-6 bug fixes, clarification round-trip + limits, `mission_terminated` surfacing, and originator-vs-intermediary header rules — each cited to a spec section. One genuine non-compliance (NC-1) found; everything else COMPLIANT; DEV-5 reconfirmed intentional.)_
+- [x] SDK non-compliance fixed without breaking existing flows (DC6). _(NC-1 → DEV-9: `interaction`/`payment` now honor `InteractionRelayResult.Pending` by parking on the deferred-consent store and answering `202` + poll `Location`, degrading to a synchronous `200` when no store is registered. Agent side already polled correctly; no client change. DEV-10 (completion synchronous review) adjudicated as an intentional, spec-tolerable simplification.)_
+- [x] `dotnet build AAuth.slnx` 0/0; unit + conformance + mission/combined e2e green. _(SDK build 0/0; `GovernanceDeferredConsentMapperTests` 12/12 incl. 4 new NC-1 cases — full suites run below.)_
+- [x] `issues-and-deviations.md` finalized; research updated; plan DoD ticked. _(DEV-9 + DEV-10 logged.)_
+- [ ] Major open decisions surfaced to the user for input.
+
+---
+
+## Phase 11 — Post-commit review remediation (two subagents)
+
+**Goal:** After the initiative was committed, run two independent review subagents —
+one grounding **every SDK change against the spec**, the other grounding **all docs
+and sample snippets against the SDK/repo** — then remediate the findings. Each fix is
+spec- or source-grounded; major cross-cutting decisions are surfaced, not rushed.
+
+**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (§Polling Error Codes,
+§Error Responses, §Token Endpoint Error Codes, §Interaction Response); SDK source of
+truth `src/AAuth/`.
+
+### Approach
+
+- **SDK-vs-spec reviewer.** Walked the `origin/main..HEAD` diff under `src/AAuth/`,
+ grounding each mission/governance/clarification/interaction/call-chaining behavior
+ in a cited spec section. Verdict: **COMPLIANT-WITH-FINDINGS** — no CRITICAL/HIGH;
+ four findings (S-1 `access_denied`→`denied`, S-2 carrier-token 401 shape, S-3
+ `user_unreachable` forward-looking, S-4 = the already-adjudicated DEV-10).
+- **Docs/samples-vs-SDK reviewer.** Read all 12 changed docs + sample snippets and
+ verified every referenced symbol against `src/AAuth/`. Verdict:
+ **GROUNDED-WITH-FINDINGS** — seven drift items (D-1..D-7), the samples themselves
+ build 0/0 (grounded by construction; the docs had diverged from the SDK).
+- **Remediate.** Doc drift fixed against the SDK (the source of truth). SDK findings
+ adjudicated against spec text: S-1 surfaced as a decision (cross-cutting, spans the
+ out-of-scope AccessServer path), S-2/S-3/S-4 documented as intentional.
+
+### Definition of Done
+
+- [x] Two reviewer subagents run; both reports captured and every finding adjudicated.
+- [x] Doc/snippet drift fixed and grounded against `src/AAuth/` (DEV-11): `MissionAction`
+ bare-string snippets, `MyPermissionDecider` comparison, `AAuthAgentOptions`
+ examples + tables (dependency-injection + configuration), `TokenErrorCode` listing,
+ the `/mission-interaction` path comment, and the no-op-seam prose.
+- [x] SDK findings adjudicated against spec: DEV-12 (S-1) surfaced as needs-decision;
+ DEV-13 (S-2) and DEV-14 (S-3) logged intentional; S-4 already DEV-10.
+- [x] `issues-and-deviations.md` updated (DEV-11..DEV-14); `dotnet build AAuth.slnx`
+ 0/0 (only `.md` files changed — no code touched).
+- [x] DEV-12 (`access_denied`→`denied`) full rename executed (user-approved, no alias):
+ all SDK emits/classifiers, AS path, sample mocks, SampleApp/GuidedTour narration,
+ conformance/integration tests, Playwright specs, and the docs snippet now use
+ `denied`; `dotnet build AAuth.slnx` 0/0.
+
+---
+
+## Phase 12 — Post-rename SDK hardening: PS role + four spec-shape fixes
+
+**Goal:** Land the five-item improvement backlog from the 2026-06-07 deep review
+([research.md](research.md) Part H): promote the **Person Server** into a first-class
+one-call SDK role and tighten four spec-shape gaps in the governance/interaction
+surface. Every item is grounded in a cited spec section and the current SDK state.
+All five are in scope; one sub-task (the F5 PS emit) is intentionally gated on
+draft-02 and split from its unblocked agent-side half.
+
+**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` (§PS-asserted access /
+§Incremental adoption L162, §Auth Token Delivery, §Interaction Response L1212,
+§Error Responses L1998 + L2108, §Interaction Endpoint); `aauth-spec/upcoming-changes-02.md`
+§2 (F5).
+
+> **Correction folded in.** The Access Server is **already** a first-class SDK role
+> (`MapAAuthAccessServer` in `src/AAuth/Access/AAuthAccessServerEndpoints.cs`); there
+> is **no "Mission Manager" party** in the spec or code. The genuine additive gap is
+> the **Person Server**, which is the only server role without a one-call mapper.
+
+### Implementation Decisions
+
+- **W1 seam shape.** Add `IIdentityClaimsAsserter` mirroring `IAccessPolicy`
+ (directed `sub` + asserted claims + silent/consent/deny), plus a PS-side
+ pending/consent store mirroring `IAccessPendingStore`. The SDK keeps all crypto
+ (resource-token verification, AS federation via `AccessServerClient`, the §Auth
+ Token Delivery 7-step check, and the auth-token mint via `AuthTokenBuilder`).
+- **W1 scope guard (extends DEV-4; revised 2026-06-07 per user direction).**
+ `MapAAuthPersonServer` packages **both** the three-party collapsed mint and the
+ four-party federation branch (keyed off the resource-token `aud`), **and** the
+ mission three-gate *token-issuance mechanics*: gate-1 terminated rejection
+ (`IMissionStore`), gate-2a/2b silent grant (in-approved-intent / prior-consent via
+ the asserter + `IMissionLog`), and gate-3 park-and-prompt (`202 requirement=interaction`
+ + a PS pending entry). The mission scope/consent *policy* decision is the
+ `IIdentityClaimsAsserter`'s job; the SDK keeps the `IMissionStore`/`IMissionLog`
+ mechanics and the mission-bound mint. **Still host-mapped (not in the SDK):** the
+ interactive consent / clarification UI page itself (the `MissionConsentScript`
+ scripted chat is test scaffolding) and the pending-verdict resolution — exactly how
+ `MapAAuthAccessServer` delegates its `InteractionLoginPath` page to the host.
+ `MockPersonServer`'s existing hand-wired interactive path stays as-is (DC6, no
+ regressions); the mapper is the additive one-call alternative.
+- **W2 split.** Ship the **agent-side** typed-exception classification now (replace
+ the generic `HttpRequestException` in `DeferredExchange` with a terminal typed
+ exception). The **PS emit** of `400 user_unreachable` stays gated on draft-02
+ (emitting today diverges from the authoritative L1213 `interaction_required`
+ wording) — DEV-14.
+- **W3.** Reuse the DEV-9 park-and-poll machinery for the `completion` arm; keep the
+ synchronous 200 fallback when no `IDeferredConsentStore` is registered.
+- **W4 decision = Option B.** Return **403** `{error:"invalid_carrier_token"}` for
+ the mission carrier-type mismatch (authz refusal, not a 401 signature failure), per
+ the H.4 analysis. Update the two pinning tests (`GovernanceDeferredConsentMapperTests`,
+ `MockPersonServerTests`) to match — DEV-13.
+- **W5.** Add a `DelegateInteractionRelay` (lambda relay) alongside the no-op
+ `DefaultInteractionRelay`; pure ergonomics, no spec change — DEV-3.
+
+### Work items
+
+- **W1 — `MapAAuthPersonServer(...)` (additive, largest).** New mapper + options +
+ `IIdentityClaimsAsserter` seam + PS pending store, wrapping the existing
+ `AuthTokenBuilder` / `AuthTokenResponseValidator` / `AccessServerClient` /
+ `TokenVerifier`. Mirrors `MapAAuthAccessServer`. Closes the PS role-symmetry gap;
+ unblocks external adopters running a real PS in one call.
+- **W2 — `user_unreachable` agent classification (DEV-14, partial).** Throw a typed
+ terminal exception (not `HttpRequestException`) from the no-callback deferred path
+ in `src/AAuth/Agent/DeferredExchange.cs`. PS emit deferred to draft-02.
+- **W3 — deferred `completion` review (DEV-10).** Honor `InteractionRelayResult.Pending`
+ in the `Completion` arm of `HandleInteractionAsync`; park + 202 + poll when a store
+ exists, synchronous 200 otherwise.
+- **W4 — mission carrier-type 401→403 (DEV-13).** Change `HandleMissionAsync`'s
+ carrier guard to 403; update the two pinning tests.
+- **W5 — `DelegateInteractionRelay` (DEV-3).** Lambda-friendly relay so a PS supplies
+ a user channel without a full class.
+
+- **W6 — docs / samples / snippets sync (grounded against the SDK).** Every W1–W5
+ change that is observable to an adopter is reflected in the docs, sample READMEs,
+ and GuidedTour/SampleApp narration, then a docs-vs-SDK grounding pass (mirroring
+ Phase 11 / DEV-11) confirms no snippet drift. Impacted surfaces (from a workspace
+ scan — confirm and extend during the phase):
+ - **W1 (new public API):** add a PS token-issuance page covering `MapAAuthPersonServer`
+ + `AAuthPersonServerOptions` + `IIdentityClaimsAsserter` — [docs/server/token-issuance.md](../../../docs/server/token-issuance.md),
+ cross-linked from [docs/workflows/ps-asserted-access.md](../../../docs/workflows/ps-asserted-access.md)
+ and [docs/workflows/federated-access.md](../../../docs/workflows/federated-access.md)
+ (it sits beside `MapAAuthAccessServer` at L117/L135); register the new seam in
+ [docs/reference/dependency-injection.md](../../../docs/reference/dependency-injection.md)
+ and [docs/reference/configuration.md](../../../docs/reference/configuration.md).
+ Optionally migrate `MockPersonServer`'s non-interactive path + README to the mapper
+ (interactive consent page stays hand-wired per DEV-4).
+ - **W2:** `TokenErrorCode.UserUnreachable` / terminal-vs-non-terminal classification —
+ [docs/advanced/error-handling.md](../../../docs/advanced/error-handling.md) (still
+ flagged forward-looking until draft-02, DEV-14).
+ - **W3:** completion now returns a deferred `202`/poll when the relay is pending —
+ [docs/server/mission-governance.md](../../../docs/server/mission-governance.md)
+ (`InteractionRelayResult` table L247) and [docs/workflows/mission-governed-access.md](../../../docs/workflows/mission-governed-access.md)
+ (the propose-completion step L131/L135).
+ - **W4:** mission carrier-type mismatch now `403` (not `401`) — any error-shape table
+ in [docs/server/mission-governance.md](../../../docs/server/mission-governance.md)
+ / [docs/server/authn-authz.md](../../../docs/server/authn-authz.md).
+ - **W5:** `DelegateInteractionRelay` as the lambda alternative to a full
+ `IInteractionRelay` class — [docs/server/mission-governance.md](../../../docs/server/mission-governance.md)
+ (L36/L46/L251) and [docs/reference/dependency-injection.md](../../../docs/reference/dependency-injection.md)
+ (L412/L420).
+
+### Spec validation (verbatim quotes)
+
+Each work item is validated against the authoritative spec text below
+(`aauth-spec/draft-hardt-oauth-aauth-protocol.md` unless noted). Quotes are verbatim;
+line numbers are current as of 2026-06-07.
+
+**W1 — `MapAAuthPersonServer`.** The PS role this mapper packages is exactly the
+PS-asserted (three-party) and federated (four-party) issuer the spec defines.
+
+- §Overview L162: *"Issuing resource tokens to the agent's person server enables
+ PS-asserted access (three-party): the PS asserts identity claims about the user
+ (`sub`, optionally `email`, `tenant`, `groups`, `roles`) and confirms user consent
+ for the scope the resource requested; the resource applies its own policy on the
+ resulting claims."* → drives the `IIdentityClaimsAsserter` seam (directed `sub` +
+ optional claims + consent decision).
+- §PS-AS Federation L1466: *"The PS is the only entity that calls AS token endpoints…
+ If `aud` matches the PS's own identifier, the PS issues an auth token asserting
+ identity and consent for the requested scope (three-party). If `aud` identifies a
+ different server (an AS)… the PS… calls the AS's `token_endpoint` (four-party)."*
+ → the mapper's two branches (collapsed mint via `AuthTokenBuilder` vs. federation
+ via `AccessServerClient`) are spec-mandated, keyed off the resource token `aud`.
+- §Auth Token Delivery L1439 (the 7-step check the SDK keeps, not the PS): *"When the
+ AS issues an auth token (`200` response), the PS MUST verify the auth token before
+ returning it to the agent: 1. Verify the auth token JWT signature… 2. Verify `iss`…
+ 3. Verify `aud`… 4. Verify `agent`… 5. Verify `cnf.jwk`… 6. Verify `act`… 7. Verify
+ `scope` is consistent with what was requested — not broader than the scope in the
+ resource token."* → `AuthTokenResponseValidator.ValidateAsync` already implements
+ all seven; the mapper wires it into the federation branch.
+- §Claims Required L1450: *"A server MUST use `requirement=claims` with a `202 Accepted`
+ response when it needs identity claims… The recipient MUST provide the requested
+ claims (including a directed user identifier as `sub`)…"* → the AS-side `OnClaimsRequired`
+ callback the PS mapper surfaces. **Verdict: COMPLIANT — the mapper packages existing
+ spec-conformant primitives; no new wire behavior.**
+- §Agent Token Request L812 (mission gating, folded into the mapper per the revised
+ scope guard): *"the PS evaluates the request against mission scope, handles user
+ consent if needed, and uses the same requirement response patterns."* and §Resource
+ Tokens L784: *"The PS SHOULD remember prior consent decisions within a mission so the
+ user is not re-prompted when the agent resubmits a request for the same resource and
+ scope."* → the mapper's gate-2a (in-approved-intent) and gate-2b (prior consent via
+ `IMissionLog`) silent grants, gate-1 terminated rejection, and gate-3 park-and-prompt
+ (`202 requirement=interaction`) are spec-mandated; the interactive consent page that
+ resolves gate-3 stays host-mapped. **Verdict: COMPLIANT — the mapper packages the
+ three-gate model over existing `IMissionStore`/`IMissionLog` primitives.**
+
+**W2 — `user_unreachable` (agent classification now; PS emit gated).** This code is
+**not yet** in the authoritative draft.
+
+- Authoritative §Interaction Response L1213 (today): *"If the PS cannot reach the user
+ and the agent does not have the `interaction` capability, the PS returns
+ `interaction_required`."* — so emitting `user_unreachable` now would **contradict**
+ the authoritative text.
+- `aauth-spec/upcoming-changes-02.md` §2: *"Add `user_unreachable` as a distinct
+ terminal error… `user_unreachable` | 400 | Terminal | PS has no channel to the user
+ AND the agent didn't declare `interaction` capability."* and *"Error classification
+ (Gap E) should treat `user_unreachable` as a terminal, non-retryable error distinct
+ from `interaction_required`."* **Verdict: agent-side terminal classification is
+ COMPLIANT with the agreed draft-02 direction and changes no wire output; the PS
+ emit stays DEFERRED until draft-02 lands (DEV-14) to avoid contradicting L1213.**
+
+**W3 — deferred `completion` review.**
+
+- §Interaction Response L1212: *"For `completion` type, the PS presents the summary to
+ the user. The user either accepts — the PS terminates the mission and returns
+ `200 OK` — or responds with follow-up questions via clarification, keeping the
+ mission active. **The PS returns a deferred response while the user reviews.**"*
+- §Interaction Response L1199 (the parallel the interaction arm already follows): *"For
+ `interaction` and `payment` types, the PS relays the interaction to the user and
+ returns a deferred response. The agent polls until the user completes the interaction."*
+ **Verdict: today's synchronous `Completion` arm is spec-tolerable but not spec-shaped;
+ honoring `Pending` (park + 202 + poll) makes it match the highlighted sentence. The
+ 202/poll mechanics reuse §Deferred Responses, already implemented for DEV-9.**
+
+**W4 — mission carrier-type guard (401→403).**
+
+- §Error Responses / Authentication Errors L1998: *"A `401` response from any AAuth
+ endpoint uses the `Signature-Error` header."*
+- §Verification (Server) L2108: *"When a server receives a signed request, it MUST
+ perform the following steps. Any failure MUST result in a `401` response with the
+ appropriate `Signature-Error` header."* — the carrier-type check is **not** one of
+ these signature-verification steps (the signature already verified), so a bare
+ `401 {error:"invalid_carrier_token"}` JSON response sits outside the spec's 401
+ contract. **Verdict: returning `403` (an authorization refusal, not a signature
+ failure) is the spec-correct shape — Option B — keeping every actual `401` bound to
+ the `Signature-Error` header per L1998/L2108.**
+
+**W5 — `DelegateInteractionRelay`.**
+
+- §Interaction Endpoint L1131: *"The interaction endpoint enables the agent to reach
+ the user through the PS… The agent uses this endpoint to forward interaction
+ requirements from resources that it cannot handle directly, to ask the user
+ questions, to relay payment approvals, or to propose mission completion."* — the
+ spec defines the endpoint behavior; **how** the PS reaches the user is an
+ implementation concern. **Verdict: COMPLIANT — adding a lambda-based relay alongside
+ the no-op default is a pure ergonomic SDK seam with no protocol effect.**
+
+### Definition of Done
+
+- [x] **W1:** `MapAAuthPersonServer` + `AAuthPersonServerOptions` + `IIdentityClaimsAsserter`
+ + PS pending store land; covered by conformance/integration tests; the three-party
+ collapsed mint and four-party federation branches both exercised. _(New SDK files
+ `src/AAuth/Person/{IIdentityClaimsAsserter,IPersonPendingStore,AAuthPersonServerEndpoints}.cs`;
+ 8 conformance tests in `tests/AAuth.Conformance/Person/PersonServerMapperTests.cs` —
+ three-party silent mint + agent-key binding, carrier-type 403, missing-resource-token 400,
+ deny→403, NeedsConsent→202→poll→mint, mission terminated→403, mission in-scope mint+grant-logged,
+ and the four-party untrusted-AS routing guard. Mapper packages both branches + the mission
+ three-gate token-issuance mechanics per the 2026-06-07 user-directed scope revision.)_
+- [x] **W1:** `MockPersonServer` interactive flows remain hand-wired and e2e-green
+ (no DEV-4 regression). _(MockPersonServer endpoints untouched by W1; the README now points
+ to the SDK one-call helper as the non-interactive alternative while the sample keeps its
+ interactive consent/mission screens hand-wired. MockPersonServer integration tests green in
+ the 387 unit baseline.)_
+- [x] **W2:** agent no-callback deferred path throws a typed terminal exception; PS
+ emit remains gated on draft-02 (DEV-14 note updated, not closed). _(DeferredExchange throws
+ `AAuthTokenExchangeException(UserUnreachable, statusCode:400, isTerminal:true)`; documented in
+ `docs/advanced/error-handling.md` with the forward-looking draft-02 PS-emit note.)_
+- [x] **W3:** `Completion` arm defers via the store (202 + poll) and degrades to
+ synchronous 200; new mapper test covers both; DEV-10 status flipped to fixed. _(GovernanceDeferredConsentMapperTests
+ 16/16; documented in `docs/workflows/mission-governed-access.md` propose-completion step.)_
+- [x] **W4:** mission carrier-type mismatch returns 403; the two pinning tests
+ updated; DEV-13 status flipped to fixed. _(Documented in `docs/server/mission-governance.md`
+ carrier-type guard note; MockPersonServer 4× 401→403; MockPersonServerTests 7/7.)_
+- [x] **W5:** `DelegateInteractionRelay` added with a test; DEV-3 status flipped to fixed.
+ _(`AddAAuthInteractionRelay(...)` documented in `docs/server/mission-governance.md` +
+ `docs/reference/dependency-injection.md`.)_
+- [x] **W6:** docs, sample READMEs, and GuidedTour/SampleApp narration updated for every
+ observable W1–W5 change; a docs-vs-SDK grounding pass (Phase 11 / DEV-11 style)
+ confirms every snippet compiles against `src/AAuth/` with no drift. _(token-issuance.md
+ one-call PS section + AAuthPersonServerOptions/IIdentityClaimsAsserter tables; configuration.md +
+ dependency-injection.md seam registration; ps-asserted-access.md + federated-access.md cross-links;
+ MockPersonServer README note. Every new snippet hand-verified against the SDK surface read this
+ phase — no automated snippet harness exists; GuidedTour `CodeSnippets.cs` unaffected.)_
+- [x] `dotnet build AAuth.slnx` 0/0; unit + conformance + relevant e2e green. _(Solution 0/0;
+ unit 387, conformance 480 — both green. e2e not re-run: no e2e-observable behavior changed
+ (W4 status codes have integration coverage; W1 is additive SDK surface).)_
+- [x] `issues-and-deviations.md` updated (DEV-3/10/13/14 dispositions; any new W1 DEVs);
+ research Part H cross-checked. _(DEV-3→fixed (W5), DEV-10→fixed (W3), DEV-13→fixed (W4),
+ DEV-14 stays forward-looking with the W2 agent-side note; new DEV-15 records the
+ user-directed W1 scope expansion.)_
+
+---
+
+## Phase 13 — GuidedTour combined "Mission Call Chain" flow
+
+**Goal:** Add a new `TourMode.MissionCallChain` flow to the GuidedTour sample so the
+step-by-step raw-HTTP walkthrough demonstrates the same combined use case the
+SampleApp `MissionCallChain.razor` page already shows: **one human-approved mission
+governs (a) a clarification round on an out-of-mission elevated scope and (b) a
+mission-forwarded delegated call chain**, then surfaces the PS-held mission log. The
+GuidedTour today has *separate* Mission (`TourMode.Mission`) and Call-Chain
+(`TourMode.CallChain`) flows but no combined one; this closes that gap and gives the
+tour parity with the SampleApp's `/mission-call-chain` page. Ships with a guided-tour
+Playwright spec mirroring `samples/SampleApp/playwright-tests/mission-call-chain.spec.ts`.
+
+**Spec:** `aauth-spec/draft-hardt-oauth-aauth-protocol.md` §Clarification Chat
+(out-of-mission scope triggers a PS question before the prompt), §Mission Context at
+Resources + §Call Chaining (the `AAuth-Mission` header is forwarded hop-to-hop so the
+mission governs every hop), §Mission Log (the PS holds the ordered governed trail).
+No SDK or spec change — this is an additive **sample** flow over the existing engine.
+
+### Implementation Decisions
+
+- **D1 — Additive over the existing engine.** Reuse the modular `TourSession` mode
+ machinery (one enum value + `IsXxxMode` property + `TotalSteps`/`Plan` cases + a
+ step switch + `PrepareConsentStateAsync` branch + a Tour.razor picker option + a
+ sequence-diagram lane set). No existing flow is changed (DC6, no regressions).
+- **D2 — Mirror the SampleApp three-pillar shape.** The combined flow demonstrates
+ three pillars under one mission: (1) mission creation (PROMPT), (2) an elevated
+ out-of-mission scope that triggers a **clarification round** (§Clarification Chat)
+ before the user prompt, and (3) a **mission-forwarded call chain** (the
+ `AAuth-Mission` header carried to the Orchestrator and forwarded to its WhoAmI hop)
+ that resolves **silently** because both chain scopes are seeded in-scope — then the
+ mission log. Rendered as raw-HTTP micro-steps (the tour's idiom), not three macro
+ cards (the SampleApp's idiom).
+- **D3 — Clarification is new to the tour engine.** The existing `TourMode.Mission`
+ flow has no clarification round; the combined flow adds the raw-HTTP clarification
+ exchange (the PS answers the out-of-mission token request with a clarification
+ challenge, the agent posts an answer, then the normal 202 + interaction prompt
+ runs). Scripted via the existing MockPersonServer `requireClarification` /
+ `clarificationQuestion` mission-script fields (already used by the SampleApp page;
+ `MissionGovernance.cs` already models `ClarificationQuestion` + `SeedInScope`).
+- **D4 — Reuse the MockPersonServer admin scripting verbatim.** `PrepareConsentStateAsync`
+ for the new mode posts `/admin/reset` + `/admin/mission-script` with
+ `requireClarification=true`, a `clarificationQuestion`, and **both** chain scopes
+ seeded in-scope (`{WhoAmIUrl, whoami}` and `{OrchestratorUrl, orchestrate}`), so the
+ chain hops resolve silently. The mission log is read from `/admin/mission-log/{s256}`.
+ No new MockPersonServer endpoints — the SampleApp page already exercises all of them.
+- **D5 — e2e parity.** Add `samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts`
+ driving the new flow end-to-end (propose → approve, elevated clarification + approve,
+ silent forwarded chain, mission-log assertions), reusing the shared e2e helpers
+ (`fixtures`, `blazor`, `consent`). The Playwright `webServer` array already boots PS +
+ AP + Orchestrator + WhoAmI for the existing guided-tour mission/call-chain specs.
+
+### Work items
+
+- **W1 — Engine: `TourMode.MissionCallChain`.** Add the enum value; `IsMissionCallChainMode`;
+ `TotalSteps` + `Plan` cases; the `MissionCallChainPlan` step array; the step-dispatch
+ switch in `RunNextAsync`; the clarification-round raw-HTTP helper; the mission-forwarded
+ chain step(s); the mission-log fetch/render step; `PrepareConsentStateAsync` branch.
+- **W2 — UI: Tour.razor.** Add the picker `
@@ -186,8 +215,8 @@
Lanes="@ActiveLanes"
IsPolling="@Session.IsPolling"
PollCount="@Session.PollCount"
- LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)"
- LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending) ? Session.PollStepNumber : 0)"
+ LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode || Session.IsMissionCallChainMode) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)"
+ LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode || Session.IsMissionCallChainMode) ? Session.PollStepNumber : 0)"
CompletedLoopLabel="@CompletedLoopLabel()"
LoopCompletedKind="@CompletedLoopKind()" />
@@ -238,11 +267,28 @@
new("Access Server", "as", Actor.AccessServer),
};
+ private static readonly SequenceDiagram.LaneDefinition[] MissionLanes =
+ {
+ new("Agent", "agent", Actor.Agent),
+ new("Resource", "resource", Actor.Resource),
+ new("Person Server", "ps", Actor.PersonServer),
+ };
+
+ private static readonly SequenceDiagram.LaneDefinition[] MissionCallChainLanes =
+ {
+ new("Agent", "agent", Actor.Agent),
+ new("Resource", "resource", Actor.Resource),
+ new("Orchestrator", "resource2", Actor.Orchestrator),
+ new("Person Server", "ps", Actor.PersonServer),
+ };
+
private SequenceDiagram.LaneDefinition[]? ActiveLanes =>
Session.IsBootstrapMode ? BootstrapLanes :
Session.IsIdentityMode ? IdentityLanes :
+ Session.IsMissionCallChainMode ? MissionCallChainLanes :
Session.IsCallChainMode ? CallChainLanes :
- Session.IsFederatedMode ? FederatedLanes : null;
+ Session.IsFederatedMode ? FederatedLanes :
+ Session.IsMissionMode ? MissionLanes : null;
protected override async Task OnInitializedAsync()
{
diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md
index 4da5347..637c7b1 100644
--- a/samples/GuidedTour/README.md
+++ b/samples/GuidedTour/README.md
@@ -13,7 +13,7 @@ hop.
A swim-lane sequence diagram across up to four actors — **Agent**,
**Orchestrator**, **Resource**, **Person Server** — with a payload
inspector on the right that decodes each JWT and shows the canonical
-RFC 9421 signature base for every signed request. Six flows are
+RFC 9421 signature base for every signed request. Eight flows are
available, switchable at runtime from the topbar **Mode** picker:
* **Bootstrap** (2–3 steps) — generate the agent's signing key and build
@@ -37,6 +37,20 @@ available, switchable at runtime from the topbar **Mode** picker:
Keycloak login URL. Requires an Access Server URL (`AccessServerUrl`);
run it with `make demo-tour-keycloak` (Keycloak) or `make demo-tour`
(stub AS, no Docker).
+* **Mission (PS-Governed)** (20 steps; three prompts) — the optional,
+ orthogonal **agent governance** layer (§Agent Governance). The agent
+ proposes a human-approved mission, then asks the PS for permission on
+ each action, records audit, and relays interactions — the PS is the
+ contextual policy point. A mission-aware Resource copies the
+ `AAuth-Mission` claim into its resource token. Requires a Person Server
+ URL; drive the same flow from the CLI with `make demo-mission`.
+* **Mission + Call Chain** (14 steps; two prompts) — one durable mission
+ governs two very different kinds of access. An out-of-mission elevated
+ scope first triggers a **clarification chat** (the PS asks *why*, the
+ agent answers) before the user approves it; then a **mission-forwarded
+ call chain** (Agent → Orchestrator → WhoAmI) flows **silently** because
+ both hops are in the mission's scope. The PS's mission log records the
+ whole trail. Requires a Person Server and an Orchestrator URL.
When `PersonServerUrl` is empty in `appsettings.json`, the picker locks
to Identity-based (the three-party options are disabled). You can also set
@@ -108,7 +122,7 @@ Steps 1–4 are the same as **Direct Grant**. From step 5 onward:
sequence diagram shows a loop box with a live spinner and poll count.
The loop resolves in one of three ways:
* **Approve** → 200 + `auth_token`; the loop box turns solid green.
- * **Deny** → 403 + `{"error":"access_denied"}` → SDK throws
+ * **Deny** → 403 + `{"error":"denied"}` → SDK throws
`AAuthInteractionDeniedException`; the loop box turns red.
* **Polling budget expires** (5 minutes by default) → SDK throws
`AAuthInteractionTimeoutException`; the loop box turns amber.
@@ -188,5 +202,5 @@ with **Run step**).
| `GuidedTour:PersonServerUrl` | `http://localhost:5100` | PS base URL. Set empty to lock the picker to identity-based mode. |
| `GuidedTour:AgentProviderUrl` | `http://localhost:5301` | AP base URL. When set, bootstrap enrols with the real AP instead of self-signing. |
| `GuidedTour:AgentId` | `aauth:tour-agent@ap.example` | Value placed in the agent token's `sub`. |
-| `GuidedTour:Mode` | `Bootstrap` | Default flow on startup. `Bootstrap`, `Identity`, `Autonomous` (Direct Grant), `Deferred`, or `CallChain`. The topbar picker overrides this at runtime. |
+| `GuidedTour:Mode` | `Bootstrap` | Default flow on startup. `Bootstrap`, `Identity`, `Autonomous` (Direct Grant), `Deferred`, `CallChain`, `Federated`, or `Mission`. The topbar picker overrides this at runtime. |
diff --git a/samples/GuidedTour/TourOptions.cs b/samples/GuidedTour/TourOptions.cs
index 1ec7bb6..69ee843 100644
--- a/samples/GuidedTour/TourOptions.cs
+++ b/samples/GuidedTour/TourOptions.cs
@@ -19,6 +19,8 @@ public enum TourMode
Deferred,
CallChain,
Federated,
+ Mission,
+ MissionCallChain,
}
///
diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs
index 5d26592..dbe67cd 100644
--- a/samples/GuidedTour/TourSession.cs
+++ b/samples/GuidedTour/TourSession.cs
@@ -7,6 +7,7 @@
using AAuth.Agent;
using AAuth.Crypto;
using AAuth.Discovery;
+using AAuth.Errors;
using AAuth.Headers;
using AAuth.HttpSig;
using AAuth.Tokens;
@@ -49,6 +50,26 @@ public sealed class TourSession : IAsyncDisposable
private bool _aborted;
private TourMode _mode;
+ // Mission-governed flow state (§Missions). Captured from the mission
+ // approval blob returned by the mission-create poll (step 5) so later
+ // steps can bind the AAuth-Mission header + show the mission identity.
+ private string? _missionApprover;
+ private string? _missionS256;
+ private string? _missionDescription;
+ private int _missionApprovedToolCount;
+ private string? _missionResponseBody;
+ private string? _missionEndpoint;
+ private string? _permissionEndpoint;
+
+ // Combined mission + call-chain flow state (§Missions, §Clarification Chat,
+ // §Call Chaining). The clarification round on the elevated-scope token gate
+ // captures the PS's question + the agent's answer + the mission-pending id
+ // the user-approval and poll steps drive; the forwarded-chain step captures
+ // the combined Orchestrator → WhoAmI mission-governed result.
+ private string? _missionPendingId;
+ private string? _clarificationQuestion;
+ private string? _missionChainResponseBody;
+
// Background polling state (deferred mode, poll step). Mutated from
// the polling task; the UI listens to StateChanged and re-renders.
private CancellationTokenSource? _pollingCts;
@@ -129,6 +150,30 @@ public SigningMode SigningMode
_ => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt",
};
+ ///
+ /// The mission-aware resource endpoint (§Missions). Distinct from
+ /// : the mission flow targets the
+ /// resource's mission-aware path so the 401 challenge copies the
+ /// AAuth-Mission claim into the resource_token.
+ ///
+ private string MissionResourceUrl => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt/mission";
+
+ ///
+ /// The ELEVATED mission-aware resource endpoint (§Scopes). Requires
+ /// `whoami:elevated_scope`, which falls outside the seeded mission scope,
+ /// so its token exchange surfaces an out-of-mission consent prompt (gate 3).
+ ///
+ private string MissionElevatedResourceUrl => $"{_options.WhoAmIUrl.TrimEnd('/')}/jwt/mission/elevated";
+
+ ///
+ /// The Orchestrator's mission-governed chain endpoint (§Call Chaining,
+ /// §Mission Context at Resources). The agent advertises its mission here;
+ /// the Orchestrator copies it into its resource_token and — once the
+ /// in-scope token is minted — forwards the AAuth-Mission header downstream
+ /// to WhoAmI's mission-aware path, so one mission governs the whole chain.
+ ///
+ private string MissionChainTargetUrl => $"{_options.OrchestratorUrl!.TrimEnd('/')}/mission";
+
///
/// True when the current flow is the identity-based path. Forced on
/// when no PS URL is configured, regardless of .
@@ -152,6 +197,18 @@ public SigningMode SigningMode
/// True when the current flow is the four-party federated path.
public bool IsFederatedMode => HasPersonServer && _mode == TourMode.Federated && HasAccessServer;
+ /// True when the current flow is the mission-governed (PS-as-policy) path.
+ public bool IsMissionMode => HasPersonServer && _mode == TourMode.Mission;
+
+ ///
+ /// True when the current flow is the combined mission + call-chain path
+ /// (§Missions, §Call Chaining): a durable mission governs an elevated-scope
+ /// clarification round and then a mission-forwarded Agent → Orchestrator →
+ /// WhoAmI chain. Requires both a Person Server and an Orchestrator.
+ ///
+ public bool IsMissionCallChainMode =>
+ HasPersonServer && _mode == TourMode.MissionCallChain && HasOrchestrator;
+
///
/// True when the call-chain flow has entered its multi-hop consent path:
/// the agent's exchange 202'd (no standing consent), so the flow surfaces
@@ -176,6 +233,8 @@ public int TotalSteps
{
if (IsBootstrapMode) return HasAgentProvider ? 3 : 2;
if (IsIdentityMode) return 2;
+ if (IsMissionCallChainMode) return 14;
+ if (IsMissionMode) return 20;
if (IsCallChainMode) return _callChainPending ? 13 : 7;
if (IsFederatedMode) return _federatedPending ? 10 : 7;
return IsDeferredMode ? 9 : 6;
@@ -194,6 +253,8 @@ public IReadOnlyList Plan
{
if (IsBootstrapMode) return HasAgentProvider ? ApBootstrapPlan : LocalBootstrapPlan;
if (IsIdentityMode) return IdentityPlan;
+ if (IsMissionCallChainMode) return MissionCallChainPlan;
+ if (IsMissionMode) return MissionPlan;
if (IsCallChainMode) return _callChainPending ? CallChainConsentPlan : CallChainPlan;
if (IsFederatedMode) return _federatedPending ? FederatedConsentPlan : FederatedPlan;
return IsDeferredMode ? DeferredPlan : AutonomousPlan;
@@ -276,6 +337,63 @@ public IReadOnlyList Plan
new(13, "Inspect multi-agent result", "Review the combined response showing the full Agent → Orchestrator → WhoAmI chain.", Actor.Agent, Actor.Agent),
};
+ // The mission-governed flow (§Missions, §PS Governance Endpoints). The PS
+ // is the policy-enforcement point: the agent proposes a durable mission
+ // (PROMPT), then every later request is checked against it — an in-scope
+ // resource token is minted SILENTLY (gate 2), a pre-approved tool is
+ // resolved locally with no PS call (gate 3), and an out-of-scope action
+ // (delete_inbox) is PROMPTED again (gate 4). Mirrors the SampleApp Mission
+ // page's four-gate use case as a step-by-step raw-HTTP walkthrough.
+ private static readonly TourPlanStep[] MissionPlan =
+ {
+ new(1, "Discover Person Server metadata", "Unsigned GET /.well-known/aauth-person.json for mission_endpoint, token_endpoint + permission_endpoint.", Actor.Agent, Actor.PersonServer),
+ new(2, "Propose mission → 202 (PROMPT)", "Signed POST /mission {description, tools}; the PS parks the proposal and returns 202 + interaction URL + single-use code.", Actor.Agent, Actor.PersonServer),
+ new(3, "Direct user to mission approval", "Agent surfaces the {url}?code={code} link for the user to approve the durable mission + its tools.", Actor.Agent, Actor.Agent),
+ new(4, "User approves the mission at the PS", "User opens the PS consent page and approves the mission; the PS records the approved mission + tools.", Actor.PersonServer, Actor.PersonServer),
+ new(5, "Poll → 200 mission approval blob", "Signed GETs to /mission-create-pending/{id} until the PS returns the verbatim approval blob + AAuth-Mission header (s256).", Actor.Agent, Actor.PersonServer),
+ new(6, "Signed GET /jwt/mission → 401", "Signed request carries AAuth-Mission; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource),
+ new(7, "Exchange → 200 auth_token (SILENT)", "Signed POST /token; the (resource, whoami) pair is in the mission scope, so the PS mints the auth_token with no prompt (gate 2).", Actor.Agent, Actor.PersonServer),
+ new(8, "Replay GET /jwt/mission → 200", "Signed retry with the auth_token returns the protected claims with the mission binding round-tripped.", Actor.Agent, Actor.Resource),
+ new(9, "Signed GET /jwt/mission/elevated → 401", "Signed request for the ELEVATED whoami:elevated_scope; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource),
+ new(10, "Exchange → 202 (PROMPT, out of mission)", "Signed POST /token; whoami:elevated_scope is OUTSIDE the mission's intent, so the PS cannot grant silently — it parks the request and returns 202 + interaction URL (gate 3).", Actor.Agent, Actor.PersonServer),
+ new(11, "Direct user to scope approval", "Agent relays the interaction URL for the user to approve the out-of-mission elevated scope.", Actor.Agent, Actor.Agent),
+ new(12, "User approves the elevated scope at the PS", "User approves whoami:elevated_scope at the PS; the consent accrues to the mission for later requests.", Actor.PersonServer, Actor.PersonServer),
+ new(13, "Poll → 200 auth_token (elevated)", "Signed GETs to the token-pending URL until the PS returns the elevated auth_token.", Actor.Agent, Actor.PersonServer),
+ new(14, "Replay GET /jwt/mission/elevated → 200", "Signed retry with the elevated auth_token returns the protected claims.", Actor.Agent, Actor.Resource),
+ new(15, "Permission: send_email (SILENT, local)", "send_email is a pre-approved mission tool, so the agent resolves it locally — no PS round-trip (gate 4).", Actor.Agent, Actor.Agent),
+ new(16, "Permission: delete_inbox → 202 (PROMPT)", "delete_inbox is NOT a pre-approved tool; signed POST /permission parks the request and returns 202 + interaction URL (gate 5).", Actor.Agent, Actor.PersonServer),
+ new(17, "Direct user to action approval", "Agent relays the permission interaction URL for the user to approve the out-of-scope delete_inbox action.", Actor.Agent, Actor.Agent),
+ new(18, "User approves the action at the PS", "User approves delete_inbox at the PS; the PS records the decision against the mission log.", Actor.PersonServer, Actor.PersonServer),
+ new(19, "Poll → 200 permission granted", "Signed GETs to /permission-pending/{id} until the PS returns {permission: granted}.", Actor.Agent, Actor.PersonServer),
+ new(20, "Inspect mission result", "Review the full governed flow: one mission, one silent token, one prompted scope, one local tool, one prompted action.", Actor.Agent, Actor.Agent),
+ };
+
+ // The combined mission + call-chain flow (§Missions, §Clarification Chat,
+ // §Call Chaining). One durable mission governs two distinct kinds of access:
+ // an out-of-mission ELEVATED scope that triggers a clarification round before
+ // the user approves (cycle 2), and a mission-FORWARDED call chain that flows
+ // silently through the Orchestrator to WhoAmI because both hops are in scope.
+ // Mirrors the SampleApp MissionCallChain page as a step-by-step raw-HTTP
+ // walkthrough: two prompts (mission creation, elevated scope) frame an
+ // otherwise-silent multi-agent chain, and the PS's mission log records it all.
+ private static readonly TourPlanStep[] MissionCallChainPlan =
+ {
+ new(1, "Discover Person Server metadata", "Unsigned GET /.well-known/aauth-person.json for mission_endpoint + token_endpoint.", Actor.Agent, Actor.PersonServer),
+ new(2, "Propose mission → 202 (PROMPT)", "Signed POST /mission {description, tools}; the PS parks the proposal and returns 202 + interaction URL + single-use code.", Actor.Agent, Actor.PersonServer),
+ new(3, "Direct user to mission approval", "Agent surfaces the {url}?code={code} link for the user to approve the durable mission.", Actor.Agent, Actor.Agent),
+ new(4, "User approves the mission at the PS", "User opens the PS consent page and approves the mission; the PS records the approved mission + tools.", Actor.PersonServer, Actor.PersonServer),
+ new(5, "Poll → 200 mission approval blob", "Signed GETs to the mission-pending URL until the PS returns the verbatim approval blob + AAuth-Mission header (s256).", Actor.Agent, Actor.PersonServer),
+ new(6, "Signed GET /jwt/mission/elevated → 401", "Signed request for the ELEVATED whoami:elevated_scope advertises AAuth-Mission; the resource copies the mission into a resource_token and challenges with 401.", Actor.Agent, Actor.Resource),
+ new(7, "Exchange → 202 clarification (PS asks)", "Signed POST /token; the elevated scope is out of mission, so before any decision the PS opens a clarification chat — 202 + requirement=clarification + the question.", Actor.Agent, Actor.PersonServer),
+ new(8, "Answer the clarification → 204", "The agent POSTs {clarification_response} to the mission-pending URL; the PS records the answer and readies the user's decision.", Actor.Agent, Actor.PersonServer),
+ new(9, "Direct user to scope approval", "Agent relays the interaction URL for the user to approve the now-clarified out-of-mission elevated scope.", Actor.Agent, Actor.Agent),
+ new(10, "User approves the elevated scope at the PS", "User approves whoami:elevated_scope at the PS; the consent accrues to the mission.", Actor.PersonServer, Actor.PersonServer),
+ new(11, "Poll → 200 auth_token (elevated)", "Signed GETs to the mission-pending URL until the PS returns the elevated auth_token.", Actor.Agent, Actor.PersonServer),
+ new(12, "Replay GET /jwt/mission/elevated → 200", "Signed retry with the elevated auth_token returns the protected claims.", Actor.Agent, Actor.Resource),
+ new(13, "Mission-forwarded call chain → 200 (SILENT)", "Signed GET the Orchestrator's /mission carrying AAuth-Mission; both hops (Agent → Orchestrator, Orchestrator → WhoAmI) are in mission scope, so the whole chain resolves with NO prompt. The internal hops are shown as grouped sub-steps.", Actor.Agent, Actor.Orchestrator),
+ new(14, "Inspect the mission log", "Signed GET /admin/mission-log/{s256}; review the ordered, auditable trail the PS recorded for the mission — the clarification, the token grants, and the chained access.", Actor.Agent, Actor.PersonServer),
+ };
+
private static readonly TourPlanStep[] FederatedPlan =
{
new(1, "Discover resource metadata", "Unsigned GET /federated/.well-known/aauth-resource.json.", Actor.Agent, Actor.Resource),
@@ -320,13 +438,27 @@ public IReadOnlyList Plan
/// The step number at which user approval occurs in deferred mode.
public int UserApprovalStepNumber =>
- IsCallChainPending
+ IsMissionCallChainMode
+ ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreateApprovalStep
+ : MissionChainElevatedApprovalStep)
+ : IsMissionMode
+ ? (Steps.Count <= MissionHop1PollStep ? MissionHop1ApprovalStep
+ : Steps.Count <= MissionHop2PollStep ? MissionHop2ApprovalStep
+ : MissionHop3ApprovalStep)
+ : IsCallChainPending
? (Steps.Count <= CallChainHop1PollStep ? CallChainHop1ApprovalStep : CallChainHop2ApprovalStep)
: 7;
/// The step number at which polling occurs in deferred mode.
public int PollStepNumber =>
- IsCallChainPending
+ IsMissionCallChainMode
+ ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreatePollStep
+ : MissionChainElevatedPollStep)
+ : IsMissionMode
+ ? (Steps.Count <= MissionHop1PollStep ? MissionHop1PollStep
+ : Steps.Count <= MissionHop2PollStep ? MissionHop2PollStep
+ : MissionHop3PollStep)
+ : IsCallChainPending
? (Steps.Count <= CallChainHop1PollStep ? CallChainHop1PollStep : CallChainHop2PollStep)
: 8;
@@ -337,6 +469,25 @@ public IReadOnlyList Plan
private const int CallChainHop2ApprovalStep = 11;
private const int CallChainHop2PollStep = 12;
+ // Mission consent path step numbers: cycle 1 (mission creation, steps 4/5),
+ // cycle 2 (out-of-mission elevated scope token, steps 12/13), and cycle 3
+ // (out-of-scope delete_inbox permission, steps 18/19).
+ private const int MissionHop1ApprovalStep = 4;
+ private const int MissionHop1PollStep = 5;
+ private const int MissionHop2ApprovalStep = 12;
+ private const int MissionHop2PollStep = 13;
+ private const int MissionHop3ApprovalStep = 18;
+ private const int MissionHop3PollStep = 19;
+
+ // Combined mission + call-chain consent path step numbers: cycle 1 (mission
+ // creation, steps 4/5) and cycle 2 (the out-of-mission elevated scope token
+ // with its clarification round, steps 10/11). The forwarded chain (step 13)
+ // is silent — no approval cycle.
+ private const int MissionChainCreateApprovalStep = 4;
+ private const int MissionChainCreatePollStep = 5;
+ private const int MissionChainElevatedApprovalStep = 10;
+ private const int MissionChainElevatedPollStep = 11;
+
///
/// The actor the current poll loop targets: the Person Server for the
/// three-party / federated / call-chain hop-1 polls, or the Orchestrator
@@ -352,7 +503,7 @@ public IReadOnlyList Plan
/// and the UI should expose the "Approve as user" action button.
///
public bool AwaitingUserApproval =>
- (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending)
+ (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode)
&& Steps.Count + 1 == UserApprovalStepNumber && !_userApproved;
/// The user-facing interaction URL captured during step 7 (deferred only).
@@ -431,6 +582,16 @@ private void ResetTimeline()
_federatedPending = false;
_callChainPending = false;
_aborted = false;
+ _missionApprover = null;
+ _missionS256 = null;
+ _missionDescription = null;
+ _missionApprovedToolCount = 0;
+ _missionResponseBody = null;
+ _missionEndpoint = null;
+ _permissionEndpoint = null;
+ _missionPendingId = null;
+ _clarificationQuestion = null;
+ _missionChainResponseBody = null;
}
///
@@ -627,6 +788,103 @@ public async Task RunNextAsync(CancellationToken ct = default)
case 7: StepFederatedInspectResult(); break;
}
}
+ else if (IsMissionCallChainMode)
+ {
+ switch (nextStep)
+ {
+ // Cycle 1 — mission creation (gate 1 PROMPT).
+ case 1: await StepMissionDiscoverPersonAsync(ct); break;
+ case 2: await StepMissionProposeAsync(ct); break;
+ case 3: StepDirectUserToInteraction(); break;
+ case 4: StepUserApprovesPlaceholder(); break;
+ case 5:
+ if (_pollingTask is { } mcCreate && !mcCreate.IsCompleted)
+ {
+ await mcCreate.ConfigureAwait(false);
+ }
+ else if (Steps.Count + 1 == PollStepNumber)
+ {
+ await StepMissionPollCreateAsync(ct);
+ }
+ break;
+ // Cycle 2 — out-of-mission elevated scope with a clarification round.
+ case 6: await StepMissionElevatedChallengeAsync(ct); break;
+ case 7: await StepMissionChainClarificationExchangeAsync(ct); break;
+ case 8: await StepMissionChainAnswerClarificationAsync(ct); break;
+ case 9: StepDirectUserToInteraction(); break;
+ case 10: StepUserApprovesPlaceholder(); break;
+ case 11:
+ if (_pollingTask is { } mcElev && !mcElev.IsCompleted)
+ {
+ await mcElev.ConfigureAwait(false);
+ }
+ else if (Steps.Count + 1 == PollStepNumber)
+ {
+ await StepMissionElevatedPollAsync(ct);
+ }
+ break;
+ case 12: await StepMissionElevatedReplayAsync(ct); break;
+ // Mission-forwarded call chain (SILENT) + the mission log.
+ case 13: await StepMissionChainForwardedAsync(ct); break;
+ case 14: await StepMissionChainLogAsync(ct); break;
+ }
+ }
+ else if (IsMissionMode)
+ {
+ switch (nextStep)
+ {
+ // Cycle 1 — mission creation (gate 1 PROMPT) → silent token (gate 2).
+ case 1: await StepMissionDiscoverPersonAsync(ct); break;
+ case 2: await StepMissionProposeAsync(ct); break;
+ case 3: StepDirectUserToInteraction(); break;
+ case 4: StepUserApprovesPlaceholder(); break;
+ case 5:
+ if (_pollingTask is { } mCreate && !mCreate.IsCompleted)
+ {
+ await mCreate.ConfigureAwait(false);
+ }
+ else if (Steps.Count + 1 == PollStepNumber)
+ {
+ await StepMissionPollCreateAsync(ct);
+ }
+ break;
+ case 6: await StepMissionResourceChallengeAsync(ct); break;
+ case 7: await StepMissionExchangeAsync(ct); break;
+ case 8: await StepMissionReplayAsync(ct); break;
+ // Cycle 2 — out-of-mission elevated scope (gate 3 PROMPT).
+ case 9: await StepMissionElevatedChallengeAsync(ct); break;
+ case 10: await StepMissionElevatedExchangeAsync(ct); break;
+ case 11: StepDirectUserToInteraction(); break;
+ case 12: StepUserApprovesPlaceholder(); break;
+ case 13:
+ if (_pollingTask is { } mElev && !mElev.IsCompleted)
+ {
+ await mElev.ConfigureAwait(false);
+ }
+ else if (Steps.Count + 1 == PollStepNumber)
+ {
+ await StepMissionElevatedPollAsync(ct);
+ }
+ break;
+ case 14: await StepMissionElevatedReplayAsync(ct); break;
+ // Cycle 3 — pre-approved tool (gate 4) → out-of-scope tool (gate 5 PROMPT).
+ case 15: StepMissionPreApprovedTool(); break;
+ case 16: await StepMissionPermissionPromptAsync(ct); break;
+ case 17: StepDirectUserToInteraction(); break;
+ case 18: StepUserApprovesPlaceholder(); break;
+ case 19:
+ if (_pollingTask is { } mPerm && !mPerm.IsCompleted)
+ {
+ await mPerm.ConfigureAwait(false);
+ }
+ else if (Steps.Count + 1 == PollStepNumber)
+ {
+ await StepMissionPollPermissionAsync(ct);
+ }
+ break;
+ case 20: StepMissionInspectResult(); break;
+ }
+ }
else
{
switch (nextStep)
@@ -685,6 +943,32 @@ await adminClient.PostAsJsonAsync(
}
}
+ ///
+ /// Re-mints with a fresh `jti` (the
+ /// generates a new token id on each
+ /// Build()). The resource server enforces replay detection per
+ /// signed request, so a single long-lived agent token cannot be reused
+ /// across two separate signed requests to the same resource. The mission
+ /// flow hits the resource twice (the `/jwt/mission` and
+ /// `/jwt/mission/elevated` challenges), so each challenge must present a
+ /// distinct agent token — exactly as a real agent would refresh its token
+ /// per access (spec §Agent Token, replay protection).
+ ///
+ private void RefreshAgentToken()
+ {
+ var personServer = IsIdentityMode || string.IsNullOrWhiteSpace(_options.PersonServerUrl)
+ ? null
+ : _options.PersonServerUrl;
+ _agentToken = new AgentTokenBuilder
+ {
+ Issuer = _selfIdentity.Issuer,
+ Subject = _options.AgentId,
+ KeyId = _selfIdentity.KeyId,
+ Key = _selfIdentity.Key,
+ PersonServer = personServer,
+ }.Build();
+ }
+
///
/// Records the user-approval step: the user opened the PS's interaction page in a
/// separate browser tab and (hopefully) clicked Approve. The Guided
@@ -694,7 +978,7 @@ await adminClient.PostAsJsonAsync(
///
public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending)) { return Task.CompletedTask; }
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode)) { return Task.CompletedTask; }
if (Steps.Count + 1 != UserApprovalStepNumber)
{
throw new InvalidOperationException(
@@ -734,6 +1018,98 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
return Task.CompletedTask;
}
+ if (IsMissionCallChainMode)
+ {
+ var hopStep = Steps.Count + 1;
+ var isCreation = hopStep == MissionChainCreateApprovalStep;
+ var title = isCreation
+ ? "User approves the mission at the PS"
+ : "User approves the elevated scope at the PS";
+ var narrative = isCreation
+ ? "The tour opened the PS's mission-approval page in a new browser tab. " +
+ "The Person Server rendered its consent screen showing the proposed " +
+ "**mission** description and the tools it may use. The user clicked " +
+ "**Approve**, and the PS recorded the durable mission via " +
+ "`POST /interaction/approve`. Every later request — including the " +
+ "forwarded call chain — is checked against this mission. The agent " +
+ "discovers the signed approval blob on its next poll."
+ : "The tour opened the PS's consent page in a new browser tab. After the " +
+ "clarification chat resolved, the Person Server showed that the agent is " +
+ "requesting the elevated **whoami:elevated_scope** \u2014 a scope that " +
+ "falls **outside** the mission's natural-language intent. The user clicked " +
+ "**Approve**, and the PS recorded the consent against the mission via " +
+ "`POST /interaction/approve`; the decision now accrues to the mission. " +
+ "The agent learns the verdict on its next poll. (A **Deny** here yields " +
+ "`denied`.)";
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = title,
+ From = Actor.PersonServer,
+ To = Actor.PersonServer,
+ Narrative = narrative,
+ ResponseBody = userUrl,
+ TokenDecoded =
+ $"Interaction URL opened in new tab:\n {userUrl}\n\n" +
+ "User performed (browser → PS):\n" +
+ $" GET /interaction?code={_interactionCode}\n" +
+ $" POST /interaction/approve (form: code={_interactionCode})",
+ });
+ return Task.CompletedTask;
+ }
+
+ if (IsMissionMode)
+ {
+ var hopStep = Steps.Count + 1;
+ var isCreation = hopStep == MissionHop1ApprovalStep;
+ var isElevated = hopStep == MissionHop2ApprovalStep;
+ var title = isCreation
+ ? "User approves the mission at the PS"
+ : isElevated
+ ? "User approves the elevated scope at the PS"
+ : "User approves delete_inbox at the PS";
+ var narrative = isCreation
+ ? "The tour opened the PS's mission-approval page in a new browser tab. " +
+ "The Person Server rendered its consent screen showing the proposed " +
+ "**mission** description and the tools it may use. The user clicked " +
+ "**Approve**, and the PS recorded the durable mission via " +
+ "`POST /interaction/approve`. This is the single most important " +
+ "consent in the model: every later request is checked against this " +
+ "mission. The agent discovers the signed approval blob on its next poll."
+ : isElevated
+ ? "The tour opened the PS's consent page in a new browser tab. The " +
+ "Person Server showed that the agent is requesting the elevated " +
+ "**whoami:elevated_scope** \u2014 a scope that falls **outside** the " +
+ "mission's natural-language intent, so it could not be granted " +
+ "silently. The user clicked **Approve**, and the PS recorded the " +
+ "consent against the mission via `POST /interaction/approve`; the " +
+ "decision now accrues to the mission, so the agent may reuse this " +
+ "scope for the rest of the session. The agent learns the verdict on " +
+ "its next poll. (A **Deny** here yields `denied`.)"
+ : "The tour opened the PS's permission page in a new browser tab. The " +
+ "Person Server showed that the agent wants to run **delete_inbox** \u2014 " +
+ "an action that is **not** among the mission's pre-approved tools \u2014 " +
+ "under the existing mission. The user clicked **Approve**, and the PS " +
+ "recorded the decision against the mission log via " +
+ "`POST /interaction/approve`. The agent learns the verdict on its next poll. " +
+ "Note: this returns a *decision*, not a token \u2014 the gate-2 auth token is unaffected.";
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = title,
+ From = Actor.PersonServer,
+ To = Actor.PersonServer,
+ Narrative = narrative,
+ ResponseBody = userUrl,
+ TokenDecoded =
+ $"Interaction URL opened in new tab:\n {userUrl}\n\n" +
+ "User performed (browser → PS):\n" +
+ $" GET /interaction?code={_interactionCode}\n" +
+ $" POST /interaction/approve (form: code={_interactionCode})",
+ });
+ return Task.CompletedTask;
+ }
+
Steps.Add(new StepRecord
{
Number = Steps.Count + 1,
@@ -791,6 +1167,67 @@ public async Task PrepareConsentStateAsync(CancellationToken ct = default)
return;
}
+ // Combined mission + call-chain mode: reset, script an interactive run,
+ // turn ON the clarification round for the out-of-mission elevated token
+ // gate, and seed BOTH in-scope pairs the forwarded chain rides on —
+ // (Orchestrator, orchestrate) and (WhoAmI, whoami) — so the multi-agent
+ // chain resolves silently while only the elevated scope prompts. Matches
+ // the SampleApp MissionCallChain page's ConfigurePersonServerAsync script.
+ if (IsMissionCallChainMode)
+ {
+ var ps = _options.PersonServerUrl!.TrimEnd('/');
+ try
+ {
+ await client.PostAsync($"{ps}/admin/reset", null, ct);
+ await client.PostAsJsonAsync($"{ps}/admin/mission-script", new
+ {
+ reset = true,
+ interactive = true,
+ approveMission = true,
+ approveToken = true,
+ approvePermission = true,
+ requireClarification = true,
+ clarificationQuestion =
+ "Why does this mission need elevated access to your full account history?",
+ inScope = new[]
+ {
+ new { resource = _options.OrchestratorUrl!.TrimEnd('/'), scope = "orchestrate" },
+ new { resource = _options.WhoAmIUrl.TrimEnd('/'), scope = "whoami" },
+ },
+ }, ct);
+ }
+ catch { /* /admin/* only exists on MockPersonServer — swallow. */ }
+ return;
+ }
+
+ // Mission mode: reset all PS state, then script the consent screen to
+ // be interactive (browser-driven) and seed the in-scope (resource,
+ // whoami) pair so gate 2 is silent. Mission creation + the out-of-scope
+ // delete_inbox both surface a real user approval (§Missions). Matches
+ // the SampleApp Mission page's ConfigurePersonServerAsync script.
+ if (IsMissionMode)
+ {
+ var ps = _options.PersonServerUrl!.TrimEnd('/');
+ try
+ {
+ await client.PostAsync($"{ps}/admin/reset", null, ct);
+ await client.PostAsJsonAsync($"{ps}/admin/mission-script", new
+ {
+ reset = true,
+ interactive = true,
+ approveMission = true,
+ approveToken = true,
+ approvePermission = true,
+ inScope = new[]
+ {
+ new { resource = _options.WhoAmIUrl.TrimEnd('/'), scope = "whoami" },
+ },
+ }, ct);
+ }
+ catch { /* /admin/* only exists on MockPersonServer — swallow. */ }
+ return;
+ }
+
var endpoint = IsDeferredMode ? "/admin/revoke" : "/admin/consent";
var url = $"{_options.PersonServerUrl!.TrimEnd('/')}{endpoint}";
try
@@ -1323,7 +1760,7 @@ private Task StepPollPendingAsync(CancellationToken ct) =>
"recorded the PS responds with `200 OK` and the long-awaited " +
"`aa-auth+jwt`, bound (via `cnf.jwk`) to the agent's signing key. " +
"If the user clicks **Deny** instead, this step records a " +
- "`403 access_denied` and the flow aborts.",
+ "`403 denied` and the flow aborts.",
RequestLine = $"{last.RequestLine} → {_pendingUrl}",
RequestHeaders = last.RequestHeaders,
SignatureBase = capturedBase,
@@ -1341,7 +1778,7 @@ private Task StepPollPendingAsync(CancellationToken ct) =>
/// Shared pending-URL poll loop. Signs with ,
/// long-polls , and on terminal success invokes
/// to add the flow-specific step record.
- /// Denial (403 access_denied) and timeout are recorded uniformly and abort
+ /// Denial (403 denied) and timeout are recorded uniformly and abort
/// the flow. / drive the actors
/// on the denied/timeout step records.
///
@@ -1402,13 +1839,13 @@ private async Task RunPendingPollAsync(
{
terminal = await poller.PollAsync(new Uri(_pendingUrl), ct);
- // 403 access_denied → user clicked Deny on the PS consent
+ // 403 denied → user clicked Deny on the PS consent
// page. Record a terminal "denied" step and abort the flow.
if (terminal.StatusCode == HttpStatusCode.Forbidden)
{
var deniedBody = await terminal.Content.ReadAsStringAsync(ct);
var deniedJson = JsonNode.Parse(deniedBody) as JsonObject;
- if ((string?)deniedJson?["error"] == "access_denied")
+ if ((string?)deniedJson?["error"] == "denied")
{
RecordDeniedStep(capture.Last!, capturedBase, deniedBody, from, to);
_aborted = true;
@@ -1418,6 +1855,15 @@ private async Task RunPendingPollAsync(
recordSuccess(capture.Last!, capturedBase);
}
+ catch (PollingErrorException pex) when (pex.ErrorCode == PollingErrorCode.Denied)
+ {
+ // §Polling Error Codes: `denied` (403) is the explicit-denial code —
+ // the SDK's DeferredPoller raises it as a typed PollingErrorException.
+ // Record the terminal "denied" step and abort the flow.
+ RecordDeniedStep(
+ capture.Last!, capturedBase, "{\"error\":\"denied\"}", from, to);
+ _aborted = true;
+ }
catch (TimeoutException tex)
{
// The user neither approved nor denied within the polling
@@ -1444,7 +1890,7 @@ private async Task RunPendingPollAsync(
///
public Task StartPendingPollAsync()
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending) || _pendingUrl is null)
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode) || _pendingUrl is null)
{
return Task.CompletedTask;
}
@@ -1460,6 +1906,19 @@ public Task StartPendingPollAsync()
// URL with the agent token.
var hop2 = IsCallChainPending && Steps.Count + 1 == CallChainHop2PollStep;
+ // Mission mode has three distinct poll cycles: cycle 1 returns the mission
+ // approval blob (step 5), cycle 2 returns the elevated auth_token (step 13),
+ // cycle 3 returns the permission decision (step 19).
+ var missionCreatePoll = IsMissionMode && Steps.Count + 1 == MissionHop1PollStep;
+ var missionElevatedPoll = IsMissionMode && Steps.Count + 1 == MissionHop2PollStep;
+ var missionPermissionPoll = IsMissionMode && Steps.Count + 1 == MissionHop3PollStep;
+
+ // Combined mission + call-chain mode has two poll cycles: cycle 1 returns
+ // the mission approval blob (step 5), cycle 2 returns the elevated
+ // auth_token after the clarification round (step 11).
+ var missionChainCreatePoll = IsMissionCallChainMode && Steps.Count + 1 == MissionChainCreatePollStep;
+ var missionChainElevatedPoll = IsMissionCallChainMode && Steps.Count + 1 == MissionChainElevatedPollStep;
+
// Serialize the check-then-assign so two near-simultaneous UI
// events (e.g. "Open consent" + "Simulate deny") can't both kick
// off a poll. Blazor Server's circuit context already serializes
@@ -1478,7 +1937,15 @@ public Task StartPendingPollAsync()
{
try
{
- await (hop2 ? StepCallChainPollHop2Async(ct) : StepPollPendingAsync(ct)).ConfigureAwait(false);
+ var poll =
+ missionCreatePoll ? StepMissionPollCreateAsync(ct)
+ : missionElevatedPoll ? StepMissionElevatedPollAsync(ct)
+ : missionPermissionPoll ? StepMissionPollPermissionAsync(ct)
+ : missionChainCreatePoll ? StepMissionPollCreateAsync(ct)
+ : missionChainElevatedPoll ? StepMissionElevatedPollAsync(ct)
+ : hop2 ? StepCallChainPollHop2Async(ct)
+ : StepPollPendingAsync(ct);
+ await poll.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -1507,13 +1974,13 @@ private void RecordDeniedStep(
Steps.Add(new StepRecord
{
Number = Steps.Count + 1,
- Title = "Poll pending URL → 403 access_denied (user denied)",
+ Title = "Poll pending URL → 403 denied (user denied)",
From = from,
To = to,
Narrative =
"The user clicked **Deny** on the PS's interaction page. The PS marked " +
"the pending entry as denied and the next poll receives " +
- "`403 Forbidden` with `error: \"access_denied\"`. The agent's SDK " +
+ "`403 Forbidden` with `error: \"denied\"`. The agent's SDK " +
"raises `AAuthInteractionDeniedException` so callers can distinguish " +
"denial from an unknown / expired pending id (which would be `404`). " +
"The tour is now in a terminal state — click **Reset** to start over.",
@@ -1598,7 +2065,7 @@ public async Task SimulateUserDenyAsync(CancellationToken ct = default)
"The tour simulated the user clicking **Deny** on the PS's consent " +
"page (`POST /interaction/deny` with the single-use code). The PS " +
"marks the pending entry as denied; the next poll iteration will see " +
- "`403 access_denied` and the flow will terminate.",
+ "`403 denied` and the flow will terminate.",
ResponseBody = denyUrl,
TokenDecoded =
$"Simulated POST /interaction/deny (form: code={_interactionCode})\n" +
@@ -2410,6 +2877,885 @@ private void StepFederatedInspectResult()
});
}
+ // -----------------------------------------------------------------
+ // Mission-governed (PS-as-policy) step implementations
+ // -----------------------------------------------------------------
+
+ private async Task StepMissionDiscoverPersonAsync(CancellationToken ct)
+ {
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ using var client = new HttpClient(capture);
+ var url = $"{_options.PersonServerUrl!.TrimEnd('/')}/.well-known/aauth-person.json";
+ await client.GetAsync(url, ct);
+ var ex = capture.Last!;
+
+ var meta = JsonNode.Parse(ex.ResponseBody);
+ _tokenEndpoint = (string?)meta?["token_endpoint"];
+ _missionEndpoint = (string?)meta?["mission_endpoint"]
+ ?? $"{_options.PersonServerUrl!.TrimEnd('/')}/mission";
+ _permissionEndpoint = (string?)meta?["permission_endpoint"]
+ ?? $"{_options.PersonServerUrl!.TrimEnd('/')}/permission";
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Discover Person Server metadata",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "Unsigned discovery to the Person Server announces its governance " +
+ "endpoints: the `mission_endpoint` the agent proposes the mission to, " +
+ "the `token_endpoint` for the in-scope token exchange, and the " +
+ "`permission_endpoint` for per-action checks. In the mission model the " +
+ "PS is the **policy-enforcement point** — every one of these endpoints " +
+ "is governed by the mission the user approves next.",
+ RequestLine = $"{ex.RequestLine} → {url}",
+ RequestHeaders = ex.RequestHeaders,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionDiscoverPs,
+ });
+ }
+
+ private async Task StepMissionProposeAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ // The proposal: a durable mission description + the tools the agent
+ // wants pre-approved. send_email is in the proposal (so gate 3 is
+ // silent later); delete_inbox is NOT (so gate 4 prompts). These are
+ // local tools — whoami is a resource scope and is handled separately
+ // by the in-scope token exchange at gate 2, not as a tool.
+ using var resp = await client.PostAsJsonAsync(_missionEndpoint!, new
+ {
+ description = "Triage the user's inbox: summarize unread mail and send routine replies.",
+ tools = new[]
+ {
+ new { name = "summarize", description = "Summarize an email thread." },
+ new { name = "send_email", description = "Send a routine reply on the user's behalf." },
+ },
+ }, ct);
+
+ var ex = capture.Last!;
+ CaptureInteractionFrom(resp, _missionEndpoint!);
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Propose mission → 202 (user approval required)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent signs a `POST /mission` with its agent token (`sig=jwt`, MUST " +
+ "per spec) carrying the proposed mission description and the local tools it " +
+ "wants pre-approved (`summarize`, `send_email`). Mission approval is the " +
+ "**most important consent in the model**, so this PS parks the proposal " +
+ "and returns `202 Accepted` + a `Location` (the mission-pending URL) and " +
+ "an `AAuth-Requirement: requirement=interaction` header pointing the user " +
+ "at the consent screen. `delete_inbox` is deliberately **not** proposed — " +
+ "you will see it prompt separately at gate 4.",
+ RequestLine = $"{ex.RequestLine} → {_missionEndpoint}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionPropose,
+ });
+ }
+
+ private Task StepMissionPollCreateAsync(CancellationToken ct) =>
+ RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) =>
+ {
+ // The mission-create poll returns the verbatim approval blob bytes
+ // (not an auth_token). Parse it to surface the mission identity.
+ _missionResponseBody = last.ResponseBody;
+ try
+ {
+ var mission = Mission.FromApprovalBytes(
+ System.Text.Encoding.UTF8.GetBytes(last.ResponseBody));
+ _missionApprover = mission.Approver;
+ _missionS256 = mission.S256;
+ _missionDescription = mission.Description;
+ _missionApprovedToolCount = mission.ApprovedTools.Count;
+ }
+ catch { /* malformed blob — leave fields null, step still shows raw body */ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Poll → 200 mission approval blob",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "While the user approves on the PS screen, the agent polls the " +
+ "mission-pending URL with a signed `GET`. Once the mission is " +
+ "approved the PS returns `200 OK` with the **verbatim approval blob** " +
+ "(stored byte-for-byte) plus an `AAuth-Mission` header carrying the " +
+ "`s256` thumbprint. The agent verifies `s256 == base64url(SHA-256(" +
+ "blob))` and now holds a durable mission it can bind to later requests. " +
+ "If the user clicks **Deny**, this step records `403 denied`.",
+ RequestLine = $"{last.RequestLine} → {_pendingUrl}",
+ RequestHeaders = last.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = last.StatusLine,
+ ResponseHeaders = last.ResponseHeaders,
+ ResponseBody = PrettyJson(last.ResponseBody),
+ TokenDecoded = _missionS256 is null
+ ? null
+ : $"Mission identity:\n approver: {_missionApprover}\n s256: {_missionS256}\n" +
+ $" tools: {_missionApprovedToolCount} pre-approved",
+ CodeSnippet = CodeSnippets.MissionPollCreate,
+ });
+ });
+
+ private async Task StepMissionResourceChallengeAsync(CancellationToken ct)
+ {
+ // Fresh agent token (new jti) so the resource's replay detection does
+ // not reject this signed request if the agent token was used earlier.
+ RefreshAgentToken();
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, MissionResourceUrl);
+ // The agent advertises the mission it is acting under so the resource
+ // copies the {approver, s256} claim into the resource_token it mints.
+ if (_missionApprover is not null && _missionS256 is not null)
+ {
+ req.Headers.TryAddWithoutValidation(
+ AAuthMissionHeader.Name,
+ AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256));
+ }
+ using var resp = await client.SendAsync(req, ct);
+ var ex = capture.Last!;
+
+ if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals))
+ {
+ foreach (var raw in reqVals)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) { continue; }
+ try
+ {
+ _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken;
+ if (_resourceToken is not null) { break; }
+ }
+ catch (FormatException) { /* try the next header value */ }
+ }
+ }
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Signed GET /jwt/mission → 401",
+ From = Actor.Agent,
+ To = Actor.Resource,
+ Narrative =
+ "The agent makes its first signed request to the resource's " +
+ "mission-aware endpoint, advertising the mission it acts under via the " +
+ "`AAuth-Mission` header (`approver` + `s256`). The resource verifies the " +
+ "signature, then mints a `resource_token` that **copies the mission " +
+ "claim into it**, and challenges with `401` + `AAuth-Requirement`. The " +
+ "mission now travels with the token to the PS — the resource itself " +
+ "stays oblivious to the user's policy.",
+ RequestLine = $"{ex.RequestLine} → {MissionResourceUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ TokenJwt = _resourceToken,
+ TokenHeader = DecodeJwt(_resourceToken)?.Header,
+ TokenPayload = DecodeJwt(_resourceToken)?.Payload,
+ CodeSnippet = CodeSnippets.MissionChallenge,
+ });
+ }
+
+ private async Task StepMissionExchangeAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new
+ {
+ resource_token = _resourceToken,
+ }, ct);
+
+ var ex = capture.Last!;
+ var body = JsonNode.Parse(ex.ResponseBody);
+ _authToken = (string?)body?["auth_token"];
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Exchange → 200 auth_token (SILENT, in-scope)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent POSTs the `resource_token` to the `token_endpoint`. Because " +
+ "the token carries the mission claim, the PS evaluates it as " +
+ "**gate 2**: the requested `(resource, whoami)` pair is within the " +
+ "mission's approved scope, so the PS mints the `auth_token` **silently** " +
+ "— no user prompt. This is the heart of the mission model: the up-front " +
+ "mission approval lets in-scope work proceed without interrupting the " +
+ "user. The token records `mission.{approver, s256}` for audit, but " +
+ "never the tool list.",
+ RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ TokenJwt = _authToken,
+ TokenHeader = DecodeJwt(_authToken)?.Header,
+ TokenPayload = DecodeJwt(_authToken)?.Payload,
+ CodeSnippet = CodeSnippets.MissionExchange,
+ });
+ }
+
+ private async Task StepMissionReplayAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _authToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ await client.GetAsync(MissionResourceUrl, ct);
+ var ex = capture.Last!;
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Replay GET /jwt/mission → 200",
+ From = Actor.Agent,
+ To = Actor.Resource,
+ Narrative =
+ "The agent replays the request, now carrying the `auth_token` in the " +
+ "`Signature-Key` header. The resource verifies it, confirms `cnf.jwk` " +
+ "matches the signer, and returns `200` with the protected claims — and " +
+ "the `mission` binding round-tripped, proving the access was governed.",
+ RequestLine = $"{ex.RequestLine} → {MissionResourceUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionReplay,
+ });
+ }
+
+ private async Task StepMissionElevatedChallengeAsync(CancellationToken ct)
+ {
+ // Fresh agent token (new jti): the resource already recorded the agent
+ // token used for the /jwt/mission challenge, so reusing it here would
+ // trip replay detection and return a bare 401 (no resource_token).
+ RefreshAgentToken();
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var req = new HttpRequestMessage(HttpMethod.Get, MissionElevatedResourceUrl);
+ if (_missionApprover is not null && _missionS256 is not null)
+ {
+ req.Headers.TryAddWithoutValidation(
+ AAuthMissionHeader.Name,
+ AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256));
+ }
+ using var resp = await client.SendAsync(req, ct);
+ var ex = capture.Last!;
+
+ if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals))
+ {
+ foreach (var raw in reqVals)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) { continue; }
+ try
+ {
+ _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken;
+ if (_resourceToken is not null) { break; }
+ }
+ catch (FormatException) { /* try the next header value */ }
+ }
+ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Signed GET /jwt/mission/elevated → 401",
+ From = Actor.Agent,
+ To = Actor.Resource,
+ Narrative =
+ "The agent now needs more than basic profile data — it requests the " +
+ "resource's **elevated** endpoint, which is protected by " +
+ "`whoami:elevated_scope`. As before it advertises the mission via the " +
+ "`AAuth-Mission` header; the resource copies the mission claim into a " +
+ "fresh `resource_token` and challenges with `401`. The resource does not " +
+ "judge the scope against the mission — that is the PS's job at the next step.",
+ RequestLine = $"{ex.RequestLine} → {MissionElevatedResourceUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ TokenJwt = _resourceToken,
+ TokenHeader = DecodeJwt(_resourceToken)?.Header,
+ TokenPayload = DecodeJwt(_resourceToken)?.Payload,
+ CodeSnippet = CodeSnippets.MissionElevatedChallenge,
+ });
+ }
+
+ private async Task StepMissionElevatedExchangeAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new
+ {
+ resource_token = _resourceToken,
+ }, ct);
+
+ var ex = capture.Last!;
+ if (resp.StatusCode == HttpStatusCode.Accepted)
+ {
+ _userApproved = false; // a fresh user approval is required for this gate
+ CaptureInteractionFrom(resp, _tokenEndpoint!);
+ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Exchange → 202 (PROMPT, out of mission scope)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent POSTs the elevated `resource_token` to the `token_endpoint`. " +
+ "The PS evaluates the requested `whoami:elevated_scope` against the " +
+ "mission's natural-language intent (\"triage the inbox\") — it does **not** " +
+ "fit. Unlike gate 2, the PS cannot mint silently: out-of-mission scopes " +
+ "are **not** auto-denied, so it parks the request and returns `202` + an " +
+ "interaction URL for the user to decide (gate 3). Only an explicit user " +
+ "**Deny** would yield `denied`.",
+ RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionElevatedExchange,
+ });
+ }
+
+ private Task StepMissionElevatedPollAsync(CancellationToken ct) =>
+ RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) =>
+ {
+ var body = JsonNode.Parse(last.ResponseBody);
+ _authToken = (string?)body?["auth_token"];
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Poll → 200 auth_token (elevated)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent polls the token-pending URL with a signed `GET`. Once the " +
+ "user approves the elevated scope, the PS returns `200` with the " +
+ "`auth_token` carrying `whoami:elevated_scope`, bound to the agent's " +
+ "signing key. The consent now accrues to the mission, so a later " +
+ "elevated request would be silent. A **Deny** here records " +
+ "`403 denied`.",
+ RequestLine = $"{last.RequestLine} → {_pendingUrl}",
+ RequestHeaders = last.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = last.StatusLine,
+ ResponseHeaders = last.ResponseHeaders,
+ ResponseBody = PrettyJson(last.ResponseBody),
+ TokenJwt = _authToken,
+ TokenHeader = DecodeJwt(_authToken)?.Header,
+ TokenPayload = DecodeJwt(_authToken)?.Payload,
+ CodeSnippet = CodeSnippets.MissionElevatedPoll,
+ });
+ });
+
+ private async Task StepMissionElevatedReplayAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _authToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ await client.GetAsync(MissionElevatedResourceUrl, ct);
+ var ex = capture.Last!;
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Replay GET /jwt/mission/elevated → 200",
+ From = Actor.Agent,
+ To = Actor.Resource,
+ Narrative =
+ "The agent replays the elevated request, now carrying the elevated " +
+ "`auth_token`. The resource verifies it, confirms `whoami:elevated_scope`, " +
+ "and returns `200` with the protected claims — the out-of-mission scope " +
+ "is now governed by the consent the user just gave.",
+ RequestLine = $"{ex.RequestLine} → {MissionElevatedResourceUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionElevatedReplay,
+ });
+ }
+
+ private void StepMissionPreApprovedTool()
+ {
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Permission: send_email (SILENT — resolved locally)",
+ From = Actor.Agent,
+ To = Actor.Agent,
+ Narrative =
+ "Before running a tool the agent checks it against the mission. " +
+ "`send_email` **is** one of the mission's pre-approved tools, so the " +
+ "agent's `PermissionClient` short-circuits to *granted* **without any " +
+ "PS round-trip** (gate 4). Pre-approving routine tools at mission " +
+ "creation is exactly what keeps the agent fast: only out-of-scope " +
+ "actions reach the PS. (The agent still SHOULD report the action to the " +
+ "`audit_endpoint` afterwards, but that is fire-and-forget, not a gate.)",
+ TokenDecoded =
+ "// sendEmailTool kept from the mission proposal above\n" +
+ "session.RequestPermissionAsync(sendEmailTool.ToAction())\n" +
+ " → mission.ApprovedTools contains \"send_email\"\n" +
+ " → PermissionResult { Grant = Granted } (no HTTP)",
+ CodeSnippet = CodeSnippets.MissionPreApproved,
+ });
+ }
+
+ private async Task StepMissionPermissionPromptAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var resp = await client.PostAsJsonAsync(_permissionEndpoint!, new
+ {
+ action = "delete_inbox",
+ mission = new { approver = _missionApprover, s256 = _missionS256 },
+ }, ct);
+
+ var ex = capture.Last!;
+ if (resp.StatusCode == HttpStatusCode.Accepted)
+ {
+ _userApproved = false; // a fresh user approval is required for this gate
+ CaptureInteractionFrom(resp, _permissionEndpoint!);
+ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Permission: delete_inbox → 202 (user approval required)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent now wants to run `delete_inbox` — a destructive action that " +
+ "was **not** pre-approved at mission creation. It signs a " +
+ "`POST /permission` with `{ action, mission }`. The PS evaluates " +
+ "**gate 5**: the action is out of scope, so it parks the request and " +
+ "returns `202` + an interaction URL for the user to decide. Crucially " +
+ "this endpoint returns a **decision**, not a token — whatever the user " +
+ "chooses, the gate-2 `auth_token` is unaffected.",
+ RequestLine = $"{ex.RequestLine} → {_permissionEndpoint}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionPermissionPrompt,
+ });
+ }
+
+ private Task StepMissionPollPermissionAsync(CancellationToken ct) =>
+ RunPendingPollAsync(ct, () => _agentToken!, Actor.Agent, Actor.PersonServer, (last, capturedBase) =>
+ {
+ var body = JsonNode.Parse(last.ResponseBody) as JsonObject;
+ var permission = (string?)body?["permission"];
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = $"Poll → 200 permission {permission ?? "decided"}",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent polls the permission-pending URL with a signed `GET`. " +
+ "Once the user decides, the PS returns `200` with " +
+ "`{ permission: \"granted\" | \"denied\" }` and records the outcome " +
+ "in the mission log for audit. A **decision**, not a credential: the " +
+ "agent already holds its in-scope token; this only governs whether it " +
+ "may take the out-of-scope action. A **Deny** here surfaces as " +
+ "`{ permission: \"denied\" }` (the SDK raises " +
+ "`AAuthInteractionDeniedException`), and the gate-2 token still works " +
+ "for in-scope reads.",
+ RequestLine = $"{last.RequestLine} → {_pendingUrl}",
+ RequestHeaders = last.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = last.StatusLine,
+ ResponseHeaders = last.ResponseHeaders,
+ ResponseBody = PrettyJson(last.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionPollPermission,
+ });
+ });
+
+ private void StepMissionInspectResult()
+ {
+ var summary = new System.Text.StringBuilder();
+ summary.AppendLine("═══ Mission-governed Summary ═══");
+ summary.AppendLine();
+ summary.AppendLine($" Mission: {_missionDescription}");
+ summary.AppendLine($" approver: {_missionApprover}");
+ summary.AppendLine($" s256: {_missionS256}");
+ summary.AppendLine($" tools: {_missionApprovedToolCount} pre-approved (summarize, send_email)");
+ summary.AppendLine();
+ summary.AppendLine(" Gate 1 — mission creation .... PROMPT (durable consent)");
+ summary.AppendLine(" Gate 2 — whoami token ........ SILENT (in mission scope)");
+ summary.AppendLine(" Gate 3 — elevated scope ...... PROMPT (out of mission scope)");
+ summary.AppendLine(" Gate 4 — send_email tool ..... SILENT (pre-approved, local)");
+ summary.AppendLine(" Gate 5 — delete_inbox action . PROMPT (out of scope)");
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Inspect mission result",
+ From = Actor.Agent,
+ To = Actor.Agent,
+ Narrative =
+ "One durable mission approval governed the whole session. The PS acted " +
+ "as the **policy-enforcement point**: it prompted only when a request " +
+ "fell outside the mission (creating the mission, the elevated scope, and " +
+ "the out-of-scope `delete_inbox`), and stayed silent for the in-scope " +
+ "token and the pre-approved tool. This is the mission " +
+ "model's promise — front-load the user's consent into a single " +
+ "reviewable mission, then let in-scope work flow without friction while " +
+ "still gating anything outside it (§Missions, §Scopes, §Permission Endpoint).",
+ TokenDecoded = summary.ToString(),
+ CodeSnippet = CodeSnippets.MissionInspect,
+ });
+ }
+
+ private async Task StepMissionChainClarificationExchangeAsync(CancellationToken ct)
+ {
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var resp = await client.PostAsJsonAsync(_tokenEndpoint!, new
+ {
+ resource_token = _resourceToken,
+ }, ct);
+
+ var ex = capture.Last!;
+ if (resp.StatusCode == HttpStatusCode.Accepted)
+ {
+ // A fresh user approval is required for the elevated-scope gate, but
+ // first the PS runs a clarification chat: it parks the request and
+ // asks WHY the mission needs this out-of-scope access. The 202 carries
+ // the mission-pending URL (Location) + requirement=clarification + the
+ // question body — but NO interaction URL yet (that comes after we
+ // answer). Capture the pending URL + id + question for the next steps.
+ _userApproved = false;
+ var location = resp.Headers.Location?.ToString();
+ if (location is not null)
+ {
+ _pendingUrl = location.StartsWith("http", StringComparison.OrdinalIgnoreCase)
+ ? location
+ : $"{_options.PersonServerUrl!.TrimEnd('/')}{location}";
+ _missionPendingId = location.TrimEnd('/').Split('/').LastOrDefault();
+ }
+ try
+ {
+ var body = JsonNode.Parse(ex.ResponseBody);
+ _clarificationQuestion = (string?)body?["clarification"];
+ }
+ catch (JsonException) { /* leave the question null — raw body still shows */ }
+ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Exchange → 202 clarification (the PS asks a question)",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent POSTs the elevated `resource_token` to the `token_endpoint`. " +
+ "`whoami:elevated_scope` falls **outside** the mission's intent, so before " +
+ "it asks the user to decide the PS opens a **clarification chat** " +
+ "(§Clarification Chat): it returns `202` with " +
+ "`AAuth-Requirement: requirement=clarification`, a `Location` (the " +
+ "mission-pending URL), and a question in the body. No interaction URL is " +
+ "issued yet — the agent must answer first.",
+ RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ TokenDecoded = _clarificationQuestion is null
+ ? null
+ : $"PS asked:\n {_clarificationQuestion}",
+ CodeSnippet = CodeSnippets.MissionChainClarify,
+ });
+ }
+
+ private async Task StepMissionChainAnswerClarificationAsync(CancellationToken ct)
+ {
+ const string answer =
+ "This mission needs the full account history to triage the inbox.";
+
+ string? capturedBase = null;
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var signing = BuildSigningHandler(
+ () => _agentToken!, capture, (_, b) => capturedBase = b);
+ using var client = new HttpClient(signing);
+
+ using var resp = await client.PostAsJsonAsync(_pendingUrl!, new
+ {
+ clarification_response = answer,
+ }, ct);
+
+ var ex = capture.Last!;
+
+ // The clarification is satisfied (204 No Content); the PS readies the
+ // user's decision. Now the agent can surface the interaction URL — the
+ // mission-pending id doubles as the single-use interaction code, and the
+ // PS's interaction page lives at {ps}/interaction.
+ if (_missionPendingId is not null)
+ {
+ _interactionUrl = $"{_options.PersonServerUrl!.TrimEnd('/')}/interaction";
+ _interactionCode = _missionPendingId;
+ }
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Answer the clarification → 204",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "The agent answers the PS's question with a signed " +
+ "`POST {mission-pending}` carrying `{ clarification_response }`. The PS " +
+ "records the answer in the mission log and transitions the parked request " +
+ "to *awaiting the user's decision* — it returns `204 No Content`. The " +
+ "agent now constructs the interaction URL (the mission-pending id is the " +
+ "single-use code) and is ready to direct the user to approve the scope.",
+ RequestLine = $"{ex.RequestLine} → {_pendingUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ RequestBody = PrettyJson(ex.RequestBody),
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = ex.ResponseBody,
+ TokenDecoded = $"Agent answered:\n {answer}",
+ CodeSnippet = CodeSnippets.MissionChainAnswer,
+ });
+ }
+
+ private async Task StepMissionChainForwardedAsync(CancellationToken ct)
+ {
+ // Fresh agent token (new jti) so the Orchestrator's replay detection
+ // does not reject the mission-aware challenge.
+ RefreshAgentToken();
+
+ // ── Hop A: challenge the Orchestrator's mission endpoint ─────────────
+ var challengeCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var challengeSigning = BuildSigningHandler(() => _agentToken!, challengeCapture);
+ using (var challengeClient = new HttpClient(challengeSigning))
+ {
+ using var challengeReq = new HttpRequestMessage(HttpMethod.Get, MissionChainTargetUrl);
+ if (_missionApprover is not null && _missionS256 is not null)
+ {
+ challengeReq.Headers.TryAddWithoutValidation(
+ AAuthMissionHeader.Name,
+ AAuthMissionHeader.FormatStructured(_missionApprover, _missionS256));
+ }
+ using var challengeResp = await challengeClient.SendAsync(challengeReq, ct);
+ if (challengeResp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals))
+ {
+ foreach (var raw in reqVals)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) { continue; }
+ try
+ {
+ _resourceToken = AAuthRequirementHeader.Parse(raw).ResourceToken;
+ if (_resourceToken is not null) { break; }
+ }
+ catch (FormatException) { /* try the next header value */ }
+ }
+ }
+ }
+
+ // ── Hop B: exchange the Orchestrator resource_token at the PS ────────
+ // The mission claim travels in the resource_token and (Orchestrator,
+ // orchestrate) is in mission scope, so the PS mints the auth_token
+ // SILENTLY — no prompt.
+ var exchangeCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var exchangeSigning = BuildSigningHandler(() => _agentToken!, exchangeCapture);
+ using (var exchangeClient = new HttpClient(exchangeSigning))
+ {
+ using var exchangeResp = await exchangeClient.PostAsJsonAsync(_tokenEndpoint!, new
+ {
+ resource_token = _resourceToken,
+ }, ct);
+ var exchangeBody = JsonNode.Parse(exchangeCapture.Last!.ResponseBody);
+ _authToken = (string?)exchangeBody?["auth_token"];
+ }
+
+ // ── Hop C: retry the Orchestrator with the auth_token ────────────────
+ // The Orchestrator validates it, forwards the mission downstream to
+ // WhoAmI's mission-aware path, and returns the combined chain result.
+ string? capturedBase = null;
+ var retryCapture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ var retrySigning = BuildSigningHandler(
+ () => _authToken!, retryCapture, (_, b) => capturedBase = b);
+ using var retryClient = new HttpClient(retrySigning);
+ await retryClient.GetAsync(MissionChainTargetUrl, ct);
+ var ex = retryCapture.Last!;
+ _missionChainResponseBody = ex.ResponseBody;
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Mission-forwarded call chain → 200 (SILENT)",
+ From = Actor.Agent,
+ To = Actor.Orchestrator,
+ Narrative =
+ "The agent now drives a **mission-governed call chain**. It advertises the " +
+ "same `AAuth-Mission` header to the Orchestrator's `/mission` endpoint; the " +
+ "Orchestrator copies the mission into a `resource_token`, the agent exchanges " +
+ "it at the PS — and because `(Orchestrator, orchestrate)` is in the mission " +
+ "scope, the PS mints the `auth_token` **silently**. On the retry the " +
+ "Orchestrator forwards the `AAuth-Mission` header **downstream** to WhoAmI's " +
+ "mission-aware path, where `(WhoAmI, whoami)` is **also** in scope — so the " +
+ "entire Agent → Orchestrator → WhoAmI chain resolves with **no prompt**. " +
+ "One mission governs every hop. The internal hops are shown as grouped " +
+ "sub-steps; the `downstream` object is WhoAmI's mission-bound result.",
+ RequestLine = $"{ex.RequestLine} → {MissionChainTargetUrl}",
+ RequestHeaders = ex.RequestHeaders,
+ SignatureBase = capturedBase,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionChainForward,
+ SubSteps = new SubStep[]
+ {
+ new("GET /mission + AAuth-Mission (agent token)", Actor.Agent, Actor.Orchestrator),
+ new("401 + resource_token (mission copied)", Actor.Orchestrator, Actor.Agent, IsResponse: true),
+ new("POST /token + resource_token", Actor.Agent, Actor.PersonServer),
+ new("200 + auth_token (SILENT — in scope)", Actor.PersonServer, Actor.Agent, IsResponse: true),
+ new("GET /mission (auth_token)", Actor.Agent, Actor.Orchestrator),
+ new("Orchestrator forwards AAuth-Mission → WhoAmI /jwt/mission", Actor.Orchestrator, Actor.Resource),
+ new("200 + combined chain result", Actor.Orchestrator, Actor.Agent, IsResponse: true),
+ },
+ });
+ }
+
+ private async Task StepMissionChainLogAsync(CancellationToken ct)
+ {
+ // The mission log is a DEMO-ONLY admin endpoint on the Mock Person
+ // Server — an unauthenticated read of the auditable trail the mission
+ // accrued. A real PS would gate this behind the user's own session.
+ var capture = new CapturingMessageHandler { InnerHandler = new HttpClientHandler() };
+ using var client = new HttpClient(capture);
+ var url = $"{_options.PersonServerUrl!.TrimEnd('/')}/admin/mission-log/{_missionS256}";
+ await client.GetAsync(url, ct);
+ var ex = capture.Last!;
+
+ Steps.Add(new StepRecord
+ {
+ Number = Steps.Count + 1,
+ Title = "Inspect the mission log",
+ From = Actor.Agent,
+ To = Actor.PersonServer,
+ Narrative =
+ "Finally the agent reads the **mission log** the PS kept — the " +
+ "authoritative, ordered record of every governed step under this mission " +
+ "(§Mission Log). It shows the **clarification** round (the question and " +
+ "the agent's answer), the elevated-scope token grant, and the in-scope " +
+ "token grants the forwarded chain rode on. One durable mission, one " +
+ "reviewable trail: the PS was the policy-enforcement point throughout, " +
+ "and the resources stayed oblivious to the user's policy.",
+ RequestLine = $"{ex.RequestLine} → {url}",
+ RequestHeaders = ex.RequestHeaders,
+ StatusLine = ex.StatusLine,
+ ResponseHeaders = ex.ResponseHeaders,
+ ResponseBody = PrettyJson(ex.ResponseBody),
+ CodeSnippet = CodeSnippets.MissionChainLog,
+ });
+ }
+
+ ///
+ /// Capture the pending URL + interaction (URL + single-use code) from a
+ /// mission/permission `202 Accepted` response so the user-approval and
+ /// poll steps can drive the deferred cycle. Shared by the mission-create
+ /// (step 2) and permission-prompt (step 10) gates.
+ ///
+ private void CaptureInteractionFrom(HttpResponseMessage resp, string baseUrl)
+ {
+ var location = resp.Headers.Location?.ToString();
+ if (location is not null)
+ {
+ _pendingUrl = location.StartsWith("http", StringComparison.OrdinalIgnoreCase)
+ ? location
+ : $"{_options.PersonServerUrl!.TrimEnd('/')}{location}";
+ }
+
+ if (resp.Headers.TryGetValues(AAuthRequirementHeader.Name, out var reqVals))
+ {
+ foreach (var raw in reqVals)
+ {
+ if (string.IsNullOrWhiteSpace(raw)) { continue; }
+ try
+ {
+ var parsed = AAuthRequirementHeader.Parse(raw);
+ var interaction = AAuth.Headers.Interaction.FromRequirement(parsed);
+ if (interaction is not null)
+ {
+ _interactionUrl = interaction.Url;
+ _interactionCode = interaction.Code;
+ break;
+ }
+ }
+ catch (FormatException) { /* try the next header value */ }
+ }
+ }
+ }
+
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
diff --git a/samples/GuidedTour/playwright-tests/deferred.spec.ts b/samples/GuidedTour/playwright-tests/deferred.spec.ts
index 869a47d..f97e536 100644
--- a/samples/GuidedTour/playwright-tests/deferred.spec.ts
+++ b/samples/GuidedTour/playwright-tests/deferred.spec.ts
@@ -84,7 +84,7 @@ test.describe('Deferred (Guided Tour)', () => {
await denyInPopup(popup);
// The flow aborts: the primary button locks to "Aborted" and the poll loop
- // records a terminal denied step (403 access_denied).
+ // records a terminal denied step (403 denied).
await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 });
await expect(doneSteps(page).last()).toContainText(/denied/i);
});
diff --git a/samples/GuidedTour/playwright-tests/federated.spec.ts b/samples/GuidedTour/playwright-tests/federated.spec.ts
index b734d1f..8425376 100644
--- a/samples/GuidedTour/playwright-tests/federated.spec.ts
+++ b/samples/GuidedTour/playwright-tests/federated.spec.ts
@@ -94,7 +94,7 @@ test.describe('Federated (Guided Tour)', () => {
await denyInPopup(popup);
// The flow aborts: the primary button locks to "Aborted" and the poll loop
- // records a terminal denied step (403 access_denied).
+ // records a terminal denied step (403 denied).
await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 });
await expect(doneSteps(page).last()).toContainText(/denied/i);
});
diff --git a/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts b/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts
new file mode 100644
index 0000000..39abeca
--- /dev/null
+++ b/samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts
@@ -0,0 +1,151 @@
+import { test, expect } from '../../../tests/e2e/helpers/fixtures';
+import {
+ openTour,
+ selectFlow,
+ runAll,
+ selectStep,
+ expectResponse,
+ readResponseJson,
+ doneSteps,
+ TourMode,
+} from '../../../tests/e2e/helpers/tour';
+import { approveInPopup, denyInPopup } from '../../../tests/e2e/helpers/consent';
+
+/**
+ * Mission + Call Chain — one durable, human-approved mission governs two very
+ * different kinds of access across 14 steps:
+ *
+ * 1. Mission creation (steps 4/5): the user approves the durable mission and
+ * its tools; the agent polls for the signed approval blob.
+ * 2. Clarified elevated scope (steps 7/8/10/11): requesting
+ * `whoami:elevated_scope` falls outside the mission's intent, so the PS
+ * first opens a CLARIFICATION CHAT — it asks WHY (step 7, 202), the agent
+ * answers (step 8, 204) — and only then prompts the user (step 10),
+ * issuing the elevated auth_token on the next poll (step 11).
+ * 3. Mission-forwarded call chain (step 13): the SAME mission drives an
+ * Agent → Orchestrator → WhoAmI chain. Both hops (`orchestrate`,
+ * `whoami`) are in mission scope, so the Orchestrator forwards the
+ * `AAuth-Mission` header downstream and the whole chain resolves SILENTLY.
+ *
+ * The PS's mission log (step 14) records it all — including the clarification
+ * round. Generous timeout covers two poll loops.
+ */
+test.describe('Mission + Call Chain (Guided Tour)', () => {
+ test.describe.configure({ timeout: 180_000 });
+
+ test('one mission governs a clarified elevated grant and a silent call chain', async ({
+ page,
+ context,
+ }) => {
+ await openTour(page);
+ await selectFlow(page, TourMode.MissionCallChain);
+
+ // ---- Cycle 1: mission creation (PROMPT) ------------------------------
+ await runAll(page);
+ // Parked on the mission-approval step (3 done: discover, propose, direct).
+ await expect(doneSteps(page)).toHaveCount(3);
+ const createLink = page.locator('a.primary.approve');
+ await expect(createLink).toBeVisible();
+ const [createPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ createLink.click(),
+ ]);
+ await approveInPopup(createPopup);
+ // user-approval + create poll resolve (5 of 14).
+ await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 });
+
+ // ---- Clarification chat + cycle 2: elevated scope (PROMPT) -----------
+ await runAll(page);
+ // Steps 6 (elevated challenge → 401), 7 (exchange → 202 clarification),
+ // 8 (answer → 204), 9 (direct-user) run, parking on the elevated-scope
+ // approval (9 done).
+ await expect(doneSteps(page)).toHaveCount(9, { timeout: 60_000 });
+ const elevatedLink = page.locator('a.primary.approve');
+ await expect(elevatedLink).toBeVisible();
+ const [elevatedPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ elevatedLink.click(),
+ ]);
+ await approveInPopup(elevatedPopup);
+ // user-approval + elevated poll resolve (11 of 14).
+ await expect(doneSteps(page)).toHaveCount(11, { timeout: 120_000 });
+
+ // ---- Silent replay + mission-forwarded call chain + log --------------
+ await runAll(page);
+ // Steps 12 (elevated replay → 200), 13 (forwarded chain → 200 SILENT),
+ // 14 (mission log) run with no further prompts (14 done).
+ await expect(doneSteps(page)).toHaveCount(14, { timeout: 60_000 });
+
+ // Step 7 ("Exchange → 202 clarification"): the PS asked WHY before consent.
+ await selectStep(page, 6);
+ await expectResponse(page, 202, ['clarification']);
+ const clarify = (await readResponseJson(page)) as Record;
+ expect(String(clarify.clarification)).toContain('elevated access');
+
+ // Step 8 ("Answer the clarification → 204"): the agent's answer is recorded.
+ await selectStep(page, 7);
+ await expectResponse(page, 204);
+ await expect(page.locator('section.payload')).toContainText('triage the inbox');
+
+ // Step 12 ("Replay GET /jwt/mission/elevated → 200"): the elevated result.
+ await selectStep(page, 11);
+ await expectResponse(page, 200, ['mission-elevated']);
+ const elevated = (await readResponseJson(page)) as Record;
+ expect(elevated.access).toBe('mission-elevated');
+ expect(elevated.scope).toEqual(['whoami:elevated_scope']);
+
+ // Step 13 ("Mission-forwarded call chain → 200 (SILENT)"): one mission
+ // governed every hop. The combined result nests WhoAmI's mission-bound
+ // downstream object reached via the Orchestrator.
+ await selectStep(page, 12);
+ await expectResponse(page, 200, ['downstream']);
+ const chain = (await readResponseJson(page)) as Record;
+ expect(String(chain.chain)).toContain('WhoAmI');
+ const downstream = chain.downstream as Record;
+ expect(downstream.mode).toBe('three-party');
+ expect(downstream.access).toBe('mission');
+ expect(downstream.scope).toEqual(['whoami']);
+ // The mission travelled all the way downstream (silent because in scope).
+ expect(downstream.mission).toBeTruthy();
+
+ // Step 14 ("Inspect the mission log"): the PS kept an auditable trail that
+ // includes the clarification round.
+ await selectStep(page, 13);
+ await expectResponse(page, 200, ['entries']);
+ const log = (await readResponseJson(page)) as { entries: Array> };
+ expect(Array.isArray(log.entries)).toBe(true);
+ expect(log.entries.some((e) => e.kind === 'clarification')).toBe(true);
+ });
+
+ test('deny at the clarified elevated-scope gate yields denied', async ({ page, context }) => {
+ await openTour(page);
+ await selectFlow(page, TourMode.MissionCallChain);
+
+ // Cycle 1: approve the mission.
+ await runAll(page);
+ const createLink = page.locator('a.primary.approve');
+ await expect(createLink).toBeVisible();
+ const [createPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ createLink.click(),
+ ]);
+ await approveInPopup(createPopup);
+ await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 });
+
+ // Advance through the clarification chat to the elevated-scope gate, DENY.
+ await runAll(page);
+ await expect(doneSteps(page)).toHaveCount(9, { timeout: 60_000 });
+ const elevatedLink = page.locator('a.primary.approve');
+ await expect(elevatedLink).toBeVisible();
+ const [elevatedPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ elevatedLink.click(),
+ ]);
+ await denyInPopup(elevatedPopup);
+
+ // The flow aborts: the primary button locks to "Aborted" and the poll loop
+ // records a terminal denied step.
+ await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 });
+ await expect(doneSteps(page).last()).toContainText(/denied/i);
+ });
+});
diff --git a/samples/GuidedTour/playwright-tests/mission.spec.ts b/samples/GuidedTour/playwright-tests/mission.spec.ts
new file mode 100644
index 0000000..49c3821
--- /dev/null
+++ b/samples/GuidedTour/playwright-tests/mission.spec.ts
@@ -0,0 +1,137 @@
+import { test, expect } from '../../../tests/e2e/helpers/fixtures';
+import {
+ openTour,
+ selectFlow,
+ runAll,
+ selectStep,
+ expectResponse,
+ readResponseJson,
+ doneSteps,
+ TourMode,
+} from '../../../tests/e2e/helpers/tour';
+import { approveInPopup, denyInPopup } from '../../../tests/e2e/helpers/consent';
+import { Agents, Urls } from '../../../tests/e2e/helpers/agents';
+
+/**
+ * Mission (PS-Governed) — the Person Server acts as the policy-enforcement
+ * point for a durable, human-approved mission, 20 steps across three consent
+ * cycles. On flow selection the tour seeds the PS for an interactive run with
+ * the `whoami` scope in-scope (so the first token gate is silent), while every
+ * out-of-mission request surfaces its own PS consent page:
+ *
+ * 1. Mission creation (steps 4/5): the user approves the durable mission +
+ * its tools; the agent polls for the signed approval blob.
+ * 2. Out-of-mission elevated scope (steps 12/13): requesting
+ * `whoami:elevated_scope` falls outside the mission's intent, so the PS
+ * prompts before issuing the elevated auth_token (gate 3).
+ * 3. Out-of-scope delete_inbox (steps 18/19): a tool that is NOT pre-approved
+ * prompts the user; the PS returns a decision, not a token.
+ *
+ * In between, the in-scope `whoami` token (gate 2) and the pre-approved
+ * send_email tool (gate 4) resolve silently. Generous timeout covers three
+ * poll loops.
+ */
+test.describe('Mission (Guided Tour)', () => {
+ test.describe.configure({ timeout: 240_000 });
+
+ test('three approvals govern the full mission lifecycle to a 200', async ({ page, context }) => {
+ await openTour(page);
+ await selectFlow(page, TourMode.Mission);
+
+ // ---- Cycle 1: mission creation (PROMPT) ------------------------------
+ await runAll(page);
+ // Parked on the mission-approval step (3 done: discover, propose, direct-user).
+ await expect(doneSteps(page)).toHaveCount(3);
+ const createLink = page.locator('a.primary.approve');
+ await expect(createLink).toBeVisible();
+ const [createPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ createLink.click(),
+ ]);
+ await approveInPopup(createPopup);
+ // user-approval + create poll resolve (5 of 20).
+ await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 });
+
+ // ---- Silent gate 2 token + cycle 2: elevated scope (PROMPT) ----------
+ await runAll(page);
+ // Steps 6 (challenge), 7 (exchange SILENT), 8 (replay), 9 (elevated
+ // challenge), 10 (elevated exchange → 202), 11 (direct-user) run, parking
+ // on the elevated-scope approval (11 done).
+ await expect(doneSteps(page)).toHaveCount(11, { timeout: 60_000 });
+ const elevatedLink = page.locator('a.primary.approve');
+ await expect(elevatedLink).toBeVisible();
+ const [elevatedPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ elevatedLink.click(),
+ ]);
+ await approveInPopup(elevatedPopup);
+ // user-approval + elevated poll resolve (13 of 20).
+ await expect(doneSteps(page)).toHaveCount(13, { timeout: 120_000 });
+
+ // ---- Silent gate 4 tool + cycle 3: delete_inbox (PROMPT) -------------
+ await runAll(page);
+ // Steps 14 (elevated replay), 15 (send_email SILENT), 16 (permission →
+ // 202), 17 (direct-user) run, parking on the delete_inbox approval (17).
+ await expect(doneSteps(page)).toHaveCount(17, { timeout: 60_000 });
+ const deleteLink = page.locator('a.primary.approve');
+ await expect(deleteLink).toBeVisible();
+ const [deletePopup] = await Promise.all([
+ context.waitForEvent('page'),
+ deleteLink.click(),
+ ]);
+ await approveInPopup(deletePopup);
+ // user-approval + permission poll resolve (19 of 20).
+ await expect(doneSteps(page)).toHaveCount(19, { timeout: 120_000 });
+
+ // ---- Final inspect step still needs an explicit "Run all" ------------
+ await runAll(page);
+ await expect(doneSteps(page)).toHaveCount(20, { timeout: 30_000 });
+
+ // Step 8 ("Replay GET /jwt/mission → 200") is the in-scope resource result.
+ await selectStep(page, 7);
+ await expectResponse(page, 200, ['mission']);
+ const inScope = (await readResponseJson(page)) as Record;
+ expect(inScope.access).toBe('mission');
+ expect(inScope.scope).toEqual(['whoami']);
+ expect(inScope.iss).toBe(Urls.personServer);
+ expect(inScope.agent).toBe(Agents.tour);
+
+ // Step 14 ("Replay GET /jwt/mission/elevated → 200") is the elevated result.
+ await selectStep(page, 13);
+ await expectResponse(page, 200, ['mission-elevated']);
+ const elevated = (await readResponseJson(page)) as Record;
+ expect(elevated.access).toBe('mission-elevated');
+ expect(elevated.scope).toEqual(['whoami:elevated_scope']);
+ });
+
+ test('deny at the elevated-scope gate yields denied', async ({ page, context }) => {
+ await openTour(page);
+ await selectFlow(page, TourMode.Mission);
+
+ // Cycle 1: approve the mission.
+ await runAll(page);
+ const createLink = page.locator('a.primary.approve');
+ await expect(createLink).toBeVisible();
+ const [createPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ createLink.click(),
+ ]);
+ await approveInPopup(createPopup);
+ await expect(doneSteps(page)).toHaveCount(5, { timeout: 120_000 });
+
+ // Advance to the elevated-scope gate and DENY it.
+ await runAll(page);
+ const elevatedLink = page.locator('a.primary.approve');
+ await expect(elevatedLink).toBeVisible();
+ const [elevatedPopup] = await Promise.all([
+ context.waitForEvent('page'),
+ elevatedLink.click(),
+ ]);
+ await denyInPopup(elevatedPopup);
+
+ // The flow aborts: the primary button locks to "Aborted" and the poll loop
+ // records a terminal denied step (403 denied).
+ await expect(page.locator('button.primary')).toHaveText('Aborted', { timeout: 120_000 });
+ await expect(doneSteps(page).last()).toContainText(/denied/i);
+ });
+});
diff --git a/samples/GuidedTour/playwright-tests/picker.spec.ts b/samples/GuidedTour/playwright-tests/picker.spec.ts
index 8d6efd9..d0a6fd9 100644
--- a/samples/GuidedTour/playwright-tests/picker.spec.ts
+++ b/samples/GuidedTour/playwright-tests/picker.spec.ts
@@ -2,16 +2,16 @@ import { test, expect } from '../../../tests/e2e/helpers/fixtures';
import { openTour } from '../../../tests/e2e/helpers/tour';
/**
- * Flow picker structure: all six flows are offered, the signing-mode picker is
+ * Flow picker structure: all eight flows are offered, the signing-mode picker is
* Identity-only, and the description text reacts to the selected flow. This is a
* UI-structure spec (no protocol result), guarding the entry point every other
* spec depends on.
*/
-test('flow picker offers all six flows and reacts to selection', async ({ page }) => {
+test('flow picker offers all eight flows and reacts to selection', async ({ page }) => {
await openTour(page);
const flow = page.locator('select#flow-select');
- await expect(flow.locator('option')).toHaveCount(6);
+ await expect(flow.locator('option')).toHaveCount(8);
await expect(flow.locator('option')).toContainText([
'Bootstrap',
'Identity-based',
@@ -19,16 +19,25 @@ test('flow picker offers all six flows and reacts to selection', async ({ page }
'PS-Asserted (Deferred)',
'Call Chain',
'Federated (Four-Party)',
+ 'Mission (PS-Governed)',
+ 'Mission + Call Chain',
]);
// Signing-mode picker only appears for the Identity flow.
await expect(page.locator('select#signing-mode-select')).toHaveCount(0);
- await flow.selectOption('Identity');
- await expect(page.locator('select#signing-mode-select')).toBeVisible();
+ // The
public Func? OnInteractionRequired { get; set; }
+ ///
+ /// Optional callback invoked when the PS returns 202 + requirement=clarification
+ /// during token exchange (§Clarification Chat). The callback receives the parsed
+ /// question and returns the agent's chosen
+ /// (respond / update / cancel), which the exchange applies before resuming polling.
+ /// When set, the agent declares the clarification capability to the PS; when
+ /// and the PS asks for clarification, the exchange throws.
+ ///
+ public Func>? OnClarificationRequired { get; set; }
+
+ ///
+ /// Maximum number of clarification rounds the agent will engage in before
+ /// giving up (§Clarification Chat). Default: 5.
+ ///
+ public int MaxClarificationRounds { get; set; } = ClarificationExchange.DefaultMaxRounds;
+
///
/// Maximum time to poll a deferred PS response before timing out.
/// Default: 5 minutes.
diff --git a/src/AAuth/Person/AAuthPersonServerEndpoints.cs b/src/AAuth/Person/AAuthPersonServerEndpoints.cs
new file mode 100644
index 0000000..d541658
--- /dev/null
+++ b/src/AAuth/Person/AAuthPersonServerEndpoints.cs
@@ -0,0 +1,706 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AAuth.Access;
+using AAuth.Agent;
+using AAuth.Crypto;
+using AAuth.Discovery;
+using AAuth.Errors;
+using AAuth.Headers;
+using AAuth.HttpSig;
+using AAuth.Server.Governance;
+using AAuth.Server.Metadata;
+using AAuth.Server.Verification;
+using AAuth.Tokens;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace AAuth.Person;
+
+///
+/// Configuration for .
+///
+public sealed class AAuthPersonServerOptions
+{
+ /// HTTPS URL of this Person Server (iss of minted auth tokens).
+ public required string Issuer { get; init; }
+
+ ///
+ /// The PS signing keys, keyed by kid. Published at the JWKS and used
+ /// to sign minted auth tokens (the first entry signs).
+ ///
+ public required IReadOnlyDictionary SigningKeys { get; init; }
+
+ /// The token endpoint path. Default /token.
+ public string TokenPath { get; init; } = "/token";
+
+ /// The pending (poll) path prefix. Default /pending.
+ public string PendingPathPrefix { get; init; } = "/pending";
+
+ ///
+ /// The fallback scope when the resource token carries none. Default
+ /// whoami.
+ ///
+ public string DefaultScope { get; init; } = "whoami";
+
+ ///
+ /// The PS-hosted interaction/consent path advertised on
+ /// requirement=interaction. Default /interaction. The caller
+ /// maps this endpoint and resolves the verdict against the shared
+ /// .
+ ///
+ public string InteractionPath { get; init; } = "/interaction";
+
+ ///
+ /// Access Server URLs this PS will federate to (four-party). When a resource
+ /// token's aud identifies one of these, the PS forwards a signed
+ /// PS→AS request via instead of minting
+ /// itself. Empty disables the four-party branch (every request must be
+ /// audienced to this PS).
+ ///
+ public IReadOnlyCollection? TrustedAccessServers { get; init; }
+}
+
+///
+/// Maps the Person Server token endpoint, pending poll endpoint, and well-known
+/// metadata in one call — the three-/four-party counterpart to
+/// MapAAuthAccessServer. The AAuth crypto (signature verification,
+/// resource-token verification, the auth-token mint, the §Auth Token Delivery
+/// check, and PS→AS federation) lives here; only the identity + consent
+/// decision is delegated to the DI-registered
+/// . When a request carries a mission
+/// claim, the host packages the mission three-gate model (terminated rejection,
+/// prior-consent silent grant, and park-and-prompt) over the
+/// / primitives.
+///
+public static class AAuthPersonServerEndpoints
+{
+ ///
+ /// Configure the PS pipeline: publish /.well-known/aauth-person.json
+ /// + JWKS, add the request-signature verification middleware (excluding the
+ /// well-known and interaction paths), and map the token + pending endpoints.
+ /// Resolves , ,
+ /// , , and
+ /// from DI. The mission gate additionally
+ /// resolves and ;
+ /// call-chaining resolves ; the
+ /// four-party branch resolves .
+ ///
+ public static WebApplication MapAAuthPersonServer(
+ this WebApplication app,
+ AAuthPersonServerOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.SigningKeys.Count == 0)
+ {
+ throw new InvalidOperationException("AAuthPersonServerOptions.SigningKeys must contain at least one key.");
+ }
+
+ string signingKid = string.Empty;
+ AAuthKey signingKey = null!;
+ foreach (var (kid, key) in options.SigningKeys)
+ {
+ signingKid = kid;
+ signingKey = key;
+ break;
+ }
+
+ var issuer = options.Issuer.TrimEnd('/');
+ var interactionPath = "/" + options.InteractionPath.Trim('/');
+ var interactionPrefix = interactionPath.Split('/', StringSplitOptions.RemoveEmptyEntries) is { Length: > 0 } seg
+ ? "/" + seg[0]
+ : interactionPath;
+ var interactionUrl = $"{issuer}{interactionPath}";
+
+ var trustedAccessServers = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var asUrl in options.TrustedAccessServers ?? Array.Empty())
+ {
+ trustedAccessServers.Add(asUrl.TrimEnd('/'));
+ }
+
+ // 1. Well-known metadata + JWKS (reachable without a signature).
+ WellKnownEndpoints.MapAAuthPersonServerWellKnown(app, new AAuthPersonServerMetadataOptions
+ {
+ Issuer = options.Issuer,
+ TokenEndpoint = $"{issuer}{options.TokenPath}",
+ SigningKeys = new Dictionary(options.SigningKeys),
+ InteractionEndpoint = interactionUrl,
+ });
+
+ // 2. Verification middleware. The agent signs with the jwt scheme
+ // (RequireIssuerVerification=false); the browser-facing interaction
+ // endpoint carries no signature, so exclude it.
+ app.UseWhen(
+ ctx => !ctx.Request.Path.StartsWithSegments("/.well-known")
+ && !ctx.Request.Path.StartsWithSegments(interactionPrefix),
+ branch => branch.UseAAuthVerification(new AAuthVerificationOptions
+ {
+ RequireIssuerVerification = false,
+ }));
+
+ var tokenVerifier = app.Services.GetRequiredService();
+ var metadataClient = app.Services.GetRequiredService();
+ var jwksClient = app.Services.GetRequiredService();
+ var asserter = app.Services.GetRequiredService();
+ var pending = app.Services.GetRequiredService();
+ var logger = app.Services.GetRequiredService().CreateLogger("AAuth.PersonServer");
+
+ string MintEntry(PersonPendingEntry entry) => Mint(
+ entry.ResourceUrl, entry.AgentId, entry.Scope, entry.AgentConfirmationKey!,
+ entry.Subject ?? "pairwise-sub", entry.Tenant, entry.Roles, entry.Groups,
+ entry.AdditionalClaims, entry.UpstreamAct, entry.Mission);
+
+ string Mint(
+ string resourceUrl, string agentId, string scope, IAAuthKey confirmationKey,
+ string subject, string? tenant, IReadOnlyList? roles, IReadOnlyList? groups,
+ IReadOnlyDictionary? additionalClaims, JsonObject? upstreamAct, MissionClaim? mission) =>
+ new AuthTokenBuilder
+ {
+ Issuer = options.Issuer,
+ Audience = resourceUrl,
+ Agent = agentId,
+ AgentConfirmationKey = confirmationKey,
+ Key = signingKey,
+ KeyId = signingKid,
+ Subject = subject,
+ Scope = scope,
+ Tenant = tenant,
+ Roles = roles,
+ Groups = groups,
+ AdditionalClaims = additionalClaims,
+ UpstreamAct = upstreamAct,
+ Mission = mission,
+ }.Build();
+
+ // -------------------------------------------------------------------
+ // POST {TokenPath} — the PS token endpoint (§Agent Token Request).
+ // -------------------------------------------------------------------
+ app.MapPost(options.TokenPath, async (HttpContext ctx) =>
+ {
+ var parsed = ctx.GetAAuthParsedKey()!;
+
+ // Only an agent token may exchange — a signature-verified carrier of
+ // the wrong type is an authorization refusal (403), not a 401
+ // signature failure (§Error Responses reserves 401 + Signature-Error
+ // for the §Verification steps, which already passed).
+ if (ctx.GetAAuthTokenType() != AAuthTokenType.AgentToken)
+ {
+ return Results.Json(
+ new { error = "invalid_carrier_token", detail = $"expected {AAuthConstants.TokenTypes.AgentToken}, got {ctx.GetAAuthTokenType()}" },
+ statusCode: StatusCodes.Status403Forbidden);
+ }
+
+ var agentId = (string?)parsed.Payload?["sub"];
+ if (string.IsNullOrEmpty(agentId))
+ {
+ return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" },
+ statusCode: StatusCodes.Status403Forbidden);
+ }
+
+ JsonObject? body;
+ try
+ {
+ body = await ctx.Request.ReadFromJsonAsync();
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return Results.Json(new { error = "invalid_request", detail = "body is not valid JSON" },
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ var resourceTokenJwt = (string?)body?["resource_token"];
+ if (string.IsNullOrEmpty(resourceTokenJwt))
+ {
+ return Results.Json(new { error = "invalid_request", detail = "missing resource_token" },
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ var upstreamTokenJwt = (string?)body?["upstream_token"];
+
+ // Route on the resource token's `aud` (peeked, not trusted; both
+ // branches fully verify the token afterwards). `aud == this PS` →
+ // three-party collapsed mint; `aud == an AS` → four-party federation.
+ var resourceAudience = PeekJwtAudience(resourceTokenJwt);
+ if (resourceAudience is not null
+ && !string.Equals(resourceAudience.TrimEnd('/'), issuer, StringComparison.OrdinalIgnoreCase))
+ {
+ return await HandleFederatedAsync(
+ ctx, parsed, agentId, resourceTokenJwt, upstreamTokenJwt, resourceAudience);
+ }
+
+ return await HandleThreePartyAsync(
+ ctx, parsed, agentId, resourceTokenJwt, upstreamTokenJwt);
+ });
+
+ // -------------------------------------------------------------------
+ // GET {PendingPathPrefix}/{id} — the agent polls the deferred verdict.
+ // -------------------------------------------------------------------
+ app.MapGet($"{options.PendingPathPrefix}/{{id}}", async (HttpContext ctx, string id) =>
+ {
+ var entry = pending.Get(id);
+ if (entry is null)
+ {
+ return Results.NotFound(new { error = "unknown_interaction" });
+ }
+
+ // Four-party entries resolve via the background federation task.
+ if (entry.AgentConfirmationKey is null)
+ {
+ if (entry.Status == PersonPendingStatus.Allowed && entry.AuthToken is not null)
+ {
+ return Results.Ok(new { auth_token = entry.AuthToken });
+ }
+ if (entry.Status == PersonPendingStatus.Denied)
+ {
+ if (!string.IsNullOrEmpty(entry.ErrorLocation))
+ {
+ ctx.Response.Headers.Location = entry.ErrorLocation;
+ }
+ return Results.Json(
+ new { error = entry.Error ?? "denied" },
+ statusCode: entry.ErrorStatus ?? StatusCodes.Status403Forbidden);
+ }
+ return Pending202(ctx, entry, options, interactionUrl);
+ }
+
+ // Three-party entries resolve when the host's interaction page marks
+ // the verdict against the shared store.
+ switch (entry.Status)
+ {
+ case PersonPendingStatus.Allowed:
+ if (entry.Mission is not null)
+ {
+ await AppendMissionGrantAsync(app, entry, "Consent");
+ }
+ return Results.Ok(new { auth_token = MintEntry(entry) });
+ case PersonPendingStatus.Denied:
+ return Results.Json(
+ new { error = "denied", detail = entry.DenyReason },
+ statusCode: StatusCodes.Status403Forbidden);
+ case PersonPendingStatus.Pending:
+ default:
+ return Pending202(ctx, entry, options, interactionUrl);
+ }
+ });
+
+ return app;
+
+ // ---- three-party (PS-asserted) handler -----------------------------
+ async Task HandleThreePartyAsync(
+ HttpContext ctx, SignatureKeyParser.ParsedSignatureKeyInfo parsed,
+ string agentId, string resourceTokenJwt, string? upstreamTokenJwt)
+ {
+ // Call-chaining: validate upstream_token (§Upstream Token Verification).
+ JsonObject? upstreamAct = null;
+ if (!string.IsNullOrEmpty(upstreamTokenJwt))
+ {
+ var validator = app.Services.GetRequiredService();
+ var intermediaryResourceUrl = (string?)parsed.Payload?["iss"]
+ ?? throw new InvalidOperationException("Agent token missing 'iss' claim.");
+ var result = await validator.ValidateAsync(
+ upstreamTokenJwt,
+ expectedAudience: intermediaryResourceUrl,
+ new HashSet { issuer });
+ if (!result.IsValid)
+ {
+ return Results.Json(new { error = "invalid_upstream_token", detail = result.Error },
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+ upstreamAct = result.UpstreamAct;
+ }
+
+ // Verify the resource token (§Resource Token Verification). `iss`
+ // becomes the auth token's `aud`; `scope` is echoed; `mission` (if
+ // present) governs the request.
+ string audience;
+ var requestedScope = options.DefaultScope;
+ MissionClaim? missionClaim;
+ try
+ {
+ var verified = await tokenVerifier.VerifyResourceTokenAsync(
+ resourceTokenJwt,
+ expectedAudience: options.Issuer,
+ expectedAgentId: agentId,
+ expectedAgentJkt: parsed.ConfirmationKey!.ComputeJwkThumbprint(),
+ metadataClient, jwksClient);
+
+ audience = (string?)verified.Payload["iss"]
+ ?? throw new TokenVerificationException("resource_token missing iss");
+ var scopeClaim = (string?)verified.Payload["scope"];
+ if (!string.IsNullOrWhiteSpace(scopeClaim))
+ {
+ requestedScope = scopeClaim;
+ }
+ missionClaim = MissionClaim.FromPayload(verified.Payload);
+ }
+ catch (TokenVerificationException ex)
+ {
+ var expired = ex.Message.Contains("expired", StringComparison.OrdinalIgnoreCase);
+ // §Token Endpoint Error Codes: invalid_resource_token / expired_resource_token
+ // are 400 (a bad token parameter in the body), not 401 — 401 is reserved for
+ // request-signature failures carrying a Signature-Error header (§Authentication
+ // Errors). The request itself was correctly signed; the resource_token is invalid.
+ return Results.Json(
+ new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message },
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ // Mission gate (§Agent Token Request, three-gate model).
+ if (missionClaim is not null)
+ {
+ var missionStore = app.Services.GetRequiredService();
+ var missionLog = app.Services.GetRequiredService();
+ var s256 = missionClaim.S256;
+
+ // Gate 1: a terminated mission is rejected outright.
+ var stored = await missionStore.GetAsync(s256);
+ if (stored is { State: MissionState.Terminated })
+ {
+ return GovernanceEndpoints.MissionTerminated();
+ }
+
+ // Gate 2b: prior consent for this (resource, scope) → silent grant.
+ if (await missionLog.HasPriorConsentAsync(s256, audience, requestedScope))
+ {
+ await AppendMissionTokenAsync(missionLog, s256, audience, requestedScope, "PriorConsent");
+ var asserted = await asserter.AssertAsync(new IdentityAssertionRequest
+ {
+ ResourceUrl = audience,
+ Scope = requestedScope,
+ AgentId = agentId,
+ Mission = missionClaim,
+ });
+ return MintFromAssertion(
+ asserted, audience, agentId, requestedScope,
+ parsed.ConfirmationKey!, upstreamAct, missionClaim)
+ ?? Results.Json(new { error = "denied", detail = asserted.Reason },
+ statusCode: StatusCodes.Status403Forbidden);
+ }
+
+ // Gate 2a / 3: the asserter decides in-scope (silent) vs prompt.
+ var decision = await asserter.AssertAsync(new IdentityAssertionRequest
+ {
+ ResourceUrl = audience,
+ Scope = requestedScope,
+ AgentId = agentId,
+ Mission = missionClaim,
+ });
+ switch (decision.Kind)
+ {
+ case IdentityAssertionKind.Assert:
+ await AppendMissionTokenAsync(missionLog, s256, audience, requestedScope, "InScope");
+ return Results.Ok(new
+ {
+ auth_token = Mint(
+ audience, agentId, requestedScope, parsed.ConfirmationKey!,
+ decision.Subject ?? "pairwise-sub", decision.Tenant, decision.Roles,
+ decision.Groups, decision.AdditionalClaims, upstreamAct, missionClaim),
+ });
+ case IdentityAssertionKind.Deny:
+ return Results.Json(new { error = "denied", detail = decision.Reason },
+ statusCode: StatusCodes.Status403Forbidden);
+ case IdentityAssertionKind.NeedsConsent:
+ default:
+ var missionEntry = pending.Add(
+ audience, requestedScope, agentId, parsed.ConfirmationKey, upstreamAct, missionClaim);
+ return Pending202(ctx, missionEntry, options, interactionUrl);
+ }
+ }
+
+ // Non-mission three-party path.
+ var assertion = await asserter.AssertAsync(new IdentityAssertionRequest
+ {
+ ResourceUrl = audience,
+ Scope = requestedScope,
+ AgentId = agentId,
+ });
+ switch (assertion.Kind)
+ {
+ case IdentityAssertionKind.Assert:
+ return Results.Ok(new
+ {
+ auth_token = Mint(
+ audience, agentId, requestedScope, parsed.ConfirmationKey!,
+ assertion.Subject ?? "pairwise-sub", assertion.Tenant, assertion.Roles,
+ assertion.Groups, assertion.AdditionalClaims, upstreamAct, mission: null),
+ });
+ case IdentityAssertionKind.Deny:
+ return Results.Json(new { error = "denied", detail = assertion.Reason },
+ statusCode: StatusCodes.Status403Forbidden);
+ case IdentityAssertionKind.NeedsConsent:
+ default:
+ var entry = pending.Add(audience, requestedScope, agentId, parsed.ConfirmationKey, upstreamAct);
+ return Pending202(ctx, entry, options, interactionUrl);
+ }
+ }
+
+ // ---- four-party (federated) handler --------------------------------
+ async Task HandleFederatedAsync(
+ HttpContext ctx, SignatureKeyParser.ParsedSignatureKeyInfo parsed,
+ string agentId, string resourceTokenJwt, string? upstreamTokenJwt, string resourceAudience)
+ {
+ if (trustedAccessServers.Count == 0
+ || !trustedAccessServers.Contains(resourceAudience.TrimEnd('/')))
+ {
+ return Results.Json(
+ new { error = "untrusted_access_server", detail = $"'{resourceAudience}' is not a trusted Access Server." },
+ statusCode: StatusCodes.Status403Forbidden);
+ }
+
+ // Verify the resource token's agent binding before forwarding it.
+ string resourceUrl;
+ var federatedScope = options.DefaultScope;
+ try
+ {
+ var verified = await tokenVerifier.VerifyResourceTokenAsync(
+ resourceTokenJwt,
+ expectedAudience: resourceAudience,
+ expectedAgentId: agentId,
+ expectedAgentJkt: parsed.ConfirmationKey!.ComputeJwkThumbprint(),
+ metadataClient, jwksClient);
+
+ resourceUrl = (string?)verified.Payload["iss"]
+ ?? throw new TokenVerificationException("resource_token missing iss");
+ var scopeClaim = (string?)verified.Payload["scope"];
+ if (!string.IsNullOrWhiteSpace(scopeClaim))
+ {
+ federatedScope = scopeClaim;
+ }
+ }
+ catch (TokenVerificationException ex)
+ {
+ var expired = ex.Message.Contains("expired", StringComparison.OrdinalIgnoreCase);
+ // §Token Endpoint Error Codes: invalid_resource_token / expired_resource_token
+ // are 400 (a bad token parameter in the body), not 401 — 401 is reserved for
+ // request-signature failures carrying a Signature-Error header (§Authentication
+ // Errors). The request itself was correctly signed; the resource_token is invalid.
+ return Results.Json(
+ new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message },
+ statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ var federation = app.Services.GetRequiredService();
+ var entry = pending.Add(resourceUrl, federatedScope, agentId, agentConfirmationKey: null);
+
+ var agentTokenJwt = parsed.Jwt
+ ?? throw new InvalidOperationException("Agent token JWT unavailable on the verified request.");
+ var agentConfirmationKey = parsed.ConfirmationKey!;
+ var fedRequest = new AccessServerRequest
+ {
+ ResourceToken = resourceTokenJwt,
+ AgentToken = agentTokenJwt,
+ UpstreamToken = upstreamTokenJwt,
+ ExpectedAudience = resourceUrl,
+ ExpectedAgentId = agentId,
+ AgentKey = agentConfirmationKey,
+ RequestedScope = federatedScope,
+ OnInteractionRequired = (interaction, _) =>
+ {
+ entry.InteractionUrl = interaction.Url;
+ entry.InteractionCode = interaction.Code;
+ entry.FirstAnswer.TrySetResult();
+ return Task.CompletedTask;
+ },
+ // The AS needs identity claims (§Claims Required) for its policy
+ // decision. The PS is the identity authority — answer via the
+ // same asserter, mapping its Assert into the directed claims push.
+ OnClaimsRequired = async (claimsRequirement, ct) =>
+ {
+ var asserted = await asserter.AssertAsync(new IdentityAssertionRequest
+ {
+ ResourceUrl = resourceUrl,
+ Scope = federatedScope,
+ AgentId = agentId,
+ RequiredClaims = claimsRequirement.RequiredClaims,
+ }, ct);
+ return new ClaimsResponse
+ {
+ Subject = asserted.Subject ?? "pairwise-sub",
+ Claims = ProjectClaims(asserted, claimsRequirement.RequiredClaims),
+ };
+ },
+ };
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ var token = await federation.FederateAsync(resourceAudience, fedRequest);
+ entry.AuthToken = token;
+ entry.Status = PersonPendingStatus.Allowed;
+ }
+ catch (AAuthInteractionDeniedException)
+ {
+ entry.Error = "denied";
+ entry.ErrorStatus = StatusCodes.Status403Forbidden;
+ entry.Status = PersonPendingStatus.Denied;
+ }
+ catch (AAuthTokenExchangeException ex)
+ {
+ entry.Error = ex.ErrorCode;
+ entry.ErrorStatus = ex.StatusCode;
+ entry.Status = PersonPendingStatus.Denied;
+ }
+ catch (AAuthPaymentRequiredException ex)
+ {
+ entry.Error = "payment_required";
+ entry.ErrorStatus = StatusCodes.Status402PaymentRequired;
+ entry.ErrorLocation = ex.Location;
+ entry.Status = PersonPendingStatus.Denied;
+ }
+ catch (Exception ex)
+ {
+ entry.Error = "federation_failed";
+ entry.ErrorStatus = StatusCodes.Status502BadGateway;
+ logger.LogWarning(ex, "Four-party federation to {AccessServer} failed.", resourceAudience);
+ entry.Status = PersonPendingStatus.Denied;
+ }
+ finally
+ {
+ entry.FirstAnswer.TrySetResult();
+ }
+ });
+
+ await entry.FirstAnswer.Task;
+
+ if (entry.InteractionUrl is not null)
+ {
+ ctx.Response.Headers.Location = $"{options.PendingPathPrefix}/{entry.Id}";
+ ctx.Response.Headers["Retry-After"] = "1";
+ ctx.Response.Headers["Cache-Control"] = "no-store";
+ ctx.Response.Headers[AAuthRequirementHeader.Name] =
+ Interaction.Format(entry.InteractionUrl, entry.InteractionCode!);
+ return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted);
+ }
+
+ if (entry.Status == PersonPendingStatus.Allowed)
+ {
+ return Results.Ok(new { auth_token = entry.AuthToken });
+ }
+
+ if (!string.IsNullOrEmpty(entry.ErrorLocation))
+ {
+ ctx.Response.Headers.Location = entry.ErrorLocation;
+ }
+ return Results.Json(
+ new { error = entry.Error ?? "denied" },
+ statusCode: entry.ErrorStatus ?? StatusCodes.Status403Forbidden);
+ }
+
+ IResult? MintFromAssertion(
+ IdentityAssertion asserted, string audience, string agentId, string scope,
+ IAAuthKey confirmationKey, JsonObject? upstreamAct, MissionClaim? mission)
+ {
+ if (asserted.Kind != IdentityAssertionKind.Assert)
+ {
+ return null;
+ }
+ return Results.Ok(new
+ {
+ auth_token = Mint(
+ audience, agentId, scope, confirmationKey,
+ asserted.Subject ?? "pairwise-sub", asserted.Tenant, asserted.Roles,
+ asserted.Groups, asserted.AdditionalClaims, upstreamAct, mission),
+ });
+ }
+ }
+
+ private static IResult Pending202(
+ HttpContext ctx, PersonPendingEntry entry, AAuthPersonServerOptions options, string interactionUrl)
+ {
+ ctx.Response.Headers.Location = $"{options.PendingPathPrefix}/{entry.Id}";
+ ctx.Response.Headers["Retry-After"] = "0";
+ ctx.Response.Headers["Cache-Control"] = "no-store";
+ ctx.Response.Headers[AAuthRequirementHeader.Name] = Interaction.Format(interactionUrl, entry.Id);
+ return Results.Json(new { status = "pending" }, statusCode: StatusCodes.Status202Accepted);
+ }
+
+ private static async Task AppendMissionGrantAsync(WebApplication app, PersonPendingEntry entry, string detail)
+ {
+ var missionLog = app.Services.GetRequiredService();
+ await AppendMissionTokenAsync(missionLog, entry.Mission!.S256, entry.ResourceUrl, entry.Scope, detail);
+ }
+
+ private static Task AppendMissionTokenAsync(
+ IMissionLog missionLog, string s256, string resource, string scope, string detail)
+ => missionLog.AppendAsync(new MissionLogEntry(s256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow)
+ {
+ Resource = resource,
+ Scope = scope,
+ Granted = true,
+ Detail = detail,
+ });
+
+ // Project the asserter's claims (tenant/roles/groups/additional) into the
+ // §Claims Required push payload, limited to the names the AS requested.
+ private static IReadOnlyDictionary ProjectClaims(
+ IdentityAssertion asserted, IReadOnlyList requiredClaims)
+ {
+ var result = new Dictionary(StringComparer.Ordinal);
+ foreach (var name in requiredClaims)
+ {
+ switch (name)
+ {
+ case "tenant" when asserted.Tenant is not null:
+ result["tenant"] = asserted.Tenant;
+ break;
+ case "roles" when asserted.Roles is not null:
+ result["roles"] = new JsonArray(System.Linq.Enumerable.ToArray(
+ System.Linq.Enumerable.Select(asserted.Roles, r => (JsonNode?)r)));
+ break;
+ case "groups" when asserted.Groups is not null:
+ result["groups"] = new JsonArray(System.Linq.Enumerable.ToArray(
+ System.Linq.Enumerable.Select(asserted.Groups, g => (JsonNode?)g)));
+ break;
+ default:
+ if (asserted.AdditionalClaims is not null
+ && asserted.AdditionalClaims.TryGetValue(name, out var value))
+ {
+ result[name] = value?.DeepClone();
+ }
+ break;
+ }
+ }
+ return result;
+ }
+
+ // Peek the `aud` claim of a (possibly unverified) compact JWT without
+ // checking its signature — used only to ROUTE the request (three- vs
+ // four-party). Both branches fully verify the token afterwards.
+ private static string? PeekJwtAudience(string jwt)
+ {
+ var parts = jwt.Split('.');
+ if (parts.Length < 2)
+ {
+ return null;
+ }
+ JsonObject? payload;
+ try
+ {
+ payload = JsonNode.Parse(Base64UrlDecode(parts[1])) as JsonObject;
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return null;
+ }
+ return payload?["aud"] switch
+ {
+ JsonValue v => v.GetValue(),
+ JsonArray { Count: > 0 } a => (string?)a[0],
+ _ => null,
+ };
+ }
+
+ private static string Base64UrlDecode(string segment)
+ {
+ var s = segment.Replace('-', '+').Replace('_', '/');
+ s += (s.Length % 4) switch { 2 => "==", 3 => "=", _ => string.Empty };
+ return System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(s));
+ }
+}
diff --git a/src/AAuth/Person/IIdentityClaimsAsserter.cs b/src/AAuth/Person/IIdentityClaimsAsserter.cs
new file mode 100644
index 0000000..8eb3182
--- /dev/null
+++ b/src/AAuth/Person/IIdentityClaimsAsserter.cs
@@ -0,0 +1,171 @@
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Tokens;
+
+namespace AAuth.Person;
+
+///
+/// Pluggable Person Server identity/consent seam — the PS counterpart to
+/// AAuth.Access.IAccessPolicy. Given the verified token-request context
+/// it returns an the
+/// MapAAuthPersonServer host helper turns into the spec-mandated wire
+/// response: mint the auth token (asserting a directed sub + identity
+/// claims and confirming consent), 403 denied, or
+/// 202 requirement=interaction while the user reviews. AAuth crypto
+/// (resource-token verification, the auth-token mint, the §Auth Token Delivery
+/// check, AS federation) stays in the host; the asserter only decides.
+///
+///
+/// In a three-party (PS-asserted) exchange the asserter supplies the identity
+/// and consent decision directly. In a four-party (federated) exchange the
+/// same asserter answers the AS's §Claims Required push: the host maps an
+/// into the directed sub + claims
+/// pushed to the AS. The host packages the mission three-gate model around the
+/// asserter (terminated rejection and prior-consent silent grant use the
+/// IMissionStore/IMissionLog primitives); the asserter owns the
+/// in-scope / prompt policy decision for a mission-bound request.
+///
+public interface IIdentityClaimsAsserter
+{
+ /// Decide identity + consent for the request and return an assertion.
+ Task AssertAsync(
+ IdentityAssertionRequest request, CancellationToken cancellationToken = default);
+}
+
+/// The verified context an decides on.
+public sealed class IdentityAssertionRequest
+{
+ /// The resource URL the auth token will be audienced to (the resource token's iss).
+ public required string ResourceUrl { get; init; }
+
+ /// The requested scope (from the resource token).
+ public required string Scope { get; init; }
+
+ /// The verified agent identifier (the agent token's sub).
+ public required string AgentId { get; init; }
+
+ ///
+ /// The claim names the recipient asked for. In a four-party exchange these
+ /// are the AS's §Claims Required names; in a three-party exchange this is
+ /// (the resource applies its own policy on whatever
+ /// the PS asserts).
+ ///
+ public IReadOnlyList? RequiredClaims { get; init; }
+
+ ///
+ /// The mission context (if any) the resource token carried. When set, the
+ /// request is governed by the mission; the asserter decides whether the
+ /// (resource, scope) is within the mission's approved intent (silent
+ /// ) or needs the user
+ /// ().
+ ///
+ public MissionClaim? Mission { get; init; }
+
+ /// The pending-entry id when the request resumes a parked consent.
+ public string? InteractionId { get; init; }
+}
+
+/// The kinds of decision an can return.
+public enum IdentityAssertionKind
+{
+ /// Assert identity + consent — mint the auth token (or push the claims).
+ Assert,
+
+ /// Deny the request — 403 denied.
+ Deny,
+
+ /// The user must review/consent first (§Interaction → 202).
+ NeedsConsent,
+}
+
+///
+/// The outcome of an evaluation. Use the
+/// static factory methods rather than the constructor so each decision kind
+/// carries only the fields that apply to it.
+///
+public sealed class IdentityAssertion
+{
+ private IdentityAssertion(
+ IdentityAssertionKind kind,
+ string? subject = null,
+ string? tenant = null,
+ IReadOnlyList? roles = null,
+ IReadOnlyList? groups = null,
+ IReadOnlyDictionary? additionalClaims = null,
+ string? reason = null)
+ {
+ Kind = kind;
+ Subject = subject;
+ Tenant = tenant;
+ Roles = roles;
+ Groups = groups;
+ AdditionalClaims = additionalClaims;
+ Reason = reason;
+ }
+
+ /// The decision kind.
+ public IdentityAssertionKind Kind { get; }
+
+ /// The directed (pairwise) user identifier — the auth token's sub.
+ public string? Subject { get; }
+
+ /// The asserted tenant claim, if any.
+ public string? Tenant { get; }
+
+ /// The asserted role claims, if any.
+ public IReadOnlyList? Roles { get; }
+
+ /// The asserted group claims, if any.
+ public IReadOnlyList? Groups { get; }
+
+ /// Any further asserted identity claims (e.g. email), if any.
+ public IReadOnlyDictionary? AdditionalClaims { get; }
+
+ /// Human-readable denial reason ().
+ public string? Reason { get; }
+
+ ///
+ /// Assert identity + consent. is the directed
+ /// sub; the remaining fields are optional asserted identity claims.
+ ///
+ public static IdentityAssertion Assert(
+ string subject,
+ string? tenant = null,
+ IReadOnlyList? roles = null,
+ IReadOnlyList? groups = null,
+ IReadOnlyDictionary? additionalClaims = null)
+ => new(IdentityAssertionKind.Assert, subject, tenant, roles, groups, additionalClaims);
+
+ /// Deny the request with a reason.
+ public static IdentityAssertion Deny(string reason)
+ => new(IdentityAssertionKind.Deny, reason: reason);
+
+ /// Require the user to review/consent before the request resolves.
+ public static IdentityAssertion NeedsConsent()
+ => new(IdentityAssertionKind.NeedsConsent);
+}
+
+///
+/// The default : asserts a fixed directed
+/// sub and no further claims, with no consent prompt. Suitable for a
+/// non-interactive demo PS; a production PS swaps in an implementation that
+/// derives the principal's directed identity and consent decision.
+///
+public sealed class DefaultIdentityClaimsAsserter : IIdentityClaimsAsserter
+{
+ private readonly string _subject;
+
+ /// Create the default asserter.
+ /// The directed sub to assert. Default pairwise-sub.
+ public DefaultIdentityClaimsAsserter(string subject = "pairwise-sub")
+ {
+ _subject = subject;
+ }
+
+ ///
+ public Task AssertAsync(
+ IdentityAssertionRequest request, CancellationToken cancellationToken = default)
+ => Task.FromResult(IdentityAssertion.Assert(_subject));
+}
diff --git a/src/AAuth/Person/IPersonPendingStore.cs b/src/AAuth/Person/IPersonPendingStore.cs
new file mode 100644
index 0000000..96e13a5
--- /dev/null
+++ b/src/AAuth/Person/IPersonPendingStore.cs
@@ -0,0 +1,235 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AAuth.Crypto;
+using AAuth.Tokens;
+
+namespace AAuth.Person;
+
+///
+/// Stores in-flight Person Server token decisions awaiting an interactive user
+/// review/consent round-trip (§Interaction), and — in the four-party
+/// (federated) flow — the background PS→AS federation result. The
+/// MapAAuthPersonServer host parks the mint inputs here when the asserter
+/// defers, and resumes (mint or deny) when the agent polls the pending URL. The
+/// PS counterpart to AAuth.Access.IAccessPendingStore.
+///
+public interface IPersonPendingStore
+{
+ /// Park a new pending decision and return the created entry.
+ PersonPendingEntry Add(
+ string resourceUrl,
+ string scope,
+ string agentId,
+ IAAuthKey? agentConfirmationKey,
+ JsonObject? upstreamAct = null,
+ MissionClaim? mission = null);
+
+ /// Look up a pending entry by id, or .
+ PersonPendingEntry? Get(string id);
+
+ ///
+ /// Mark the entry allowed with the asserted identity the next poll mints.
+ /// The host's interaction page calls this once the user has consented.
+ ///
+ void MarkAllowed(
+ string id,
+ string subject,
+ string? tenant = null,
+ IReadOnlyList? roles = null,
+ IReadOnlyList? groups = null,
+ IReadOnlyDictionary? additionalClaims = null);
+
+ /// Mark the entry denied with a reason.
+ void MarkDenied(string id, string reason);
+}
+
+/// The lifecycle state of a .
+public enum PersonPendingStatus
+{
+ /// Awaiting the user review/consent (or background federation).
+ Pending,
+
+ /// Approved — the next poll mints (or returns) the auth token.
+ Allowed,
+
+ /// Denied — the next poll returns 403 denied.
+ Denied,
+}
+
+/// A parked Person Server token decision.
+public sealed class PersonPendingEntry
+{
+ /// Opaque pending id (path segment of the Location URL).
+ public required string Id { get; init; }
+
+ /// The resource URL the auth token will be audienced to.
+ public required string ResourceUrl { get; init; }
+
+ /// The requested scope.
+ public required string Scope { get; init; }
+
+ /// The verified agent identifier.
+ public required string AgentId { get; init; }
+
+ ///
+ /// The agent's confirmation key (cnf.jwk binding) — set for the
+ /// three-party path where the PS mints. for the
+ /// four-party path where the AS mints and the PS only relays.
+ ///
+ public IAAuthKey? AgentConfirmationKey { get; init; }
+
+ /// Optional upstream act context for call chaining.
+ public JsonObject? UpstreamAct { get; init; }
+
+ /// The mission context governing the request, if any.
+ public MissionClaim? Mission { get; init; }
+
+ /// The entry's lifecycle state.
+ public PersonPendingStatus Status { get; set; }
+
+ /// The directed sub the asserter supplied on approval.
+ public string? Subject { get; set; }
+
+ /// The asserted tenant claim, if any.
+ public string? Tenant { get; set; }
+
+ /// The asserted role claims, if any.
+ public IReadOnlyList? Roles { get; set; }
+
+ /// The asserted group claims, if any.
+ public IReadOnlyList? Groups { get; set; }
+
+ /// Any further asserted identity claims, if any.
+ public IReadOnlyDictionary? AdditionalClaims { get; set; }
+
+ /// The denial reason when is Denied.
+ public string? DenyReason { get; set; }
+
+ ///
+ /// The AS-issued auth token, set when the four-party federation completes
+ /// successfully. When present, the next poll returns it verbatim (the AS
+ /// minted it; the PS does not re-mint).
+ ///
+ public string? AuthToken { get; set; }
+
+ /// The AS interaction URL to relay to the agent (four-party).
+ public string? InteractionUrl { get; set; }
+
+ /// The AS interaction code to relay to the agent (four-party).
+ public string? InteractionCode { get; set; }
+
+ /// An error code surfaced by a failed federation, if any.
+ public string? Error { get; set; }
+
+ /// The HTTP status to surface for , if any.
+ public int? ErrorStatus { get; set; }
+
+ /// A Location to surface alongside (e.g. payment), if any.
+ public string? ErrorLocation { get; set; }
+
+ ///
+ /// Completes when the four-party federation produces its first answer
+ /// (an AS interaction to relay, or a terminal result). Runtime-only.
+ ///
+ public TaskCompletionSource FirstAnswer { get; }
+ = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ /// When the entry was parked. Drives in-memory TTL eviction.
+ public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
+}
+
+///
+/// Process-wide in-memory . Suitable for a
+/// single-instance demo/sample; a production PS would persist entries with a
+/// TTL. Entries are evicted once they exceed (lazily, on each
+/// /) so the dictionary does not grow without
+/// bound.
+///
+public sealed class InMemoryPersonPendingStore : IPersonPendingStore
+{
+ /// How long a parked entry is retained before it is evicted.
+ public static readonly TimeSpan Ttl = TimeSpan.FromMinutes(10);
+
+ private readonly ConcurrentDictionary _entries = new();
+
+ ///
+ public PersonPendingEntry Add(
+ string resourceUrl,
+ string scope,
+ string agentId,
+ IAAuthKey? agentConfirmationKey,
+ JsonObject? upstreamAct = null,
+ MissionClaim? mission = null)
+ {
+ Sweep();
+ var entry = new PersonPendingEntry
+ {
+ Id = Guid.NewGuid().ToString("N"),
+ ResourceUrl = resourceUrl,
+ Scope = scope,
+ AgentId = agentId,
+ AgentConfirmationKey = agentConfirmationKey,
+ UpstreamAct = upstreamAct,
+ Mission = mission,
+ Status = PersonPendingStatus.Pending,
+ };
+ _entries[entry.Id] = entry;
+ return entry;
+ }
+
+ ///
+ public PersonPendingEntry? Get(string id)
+ {
+ Sweep();
+ return _entries.TryGetValue(id, out var entry) ? entry : null;
+ }
+
+ ///
+ public void MarkAllowed(
+ string id,
+ string subject,
+ string? tenant = null,
+ IReadOnlyList? roles = null,
+ IReadOnlyList? groups = null,
+ IReadOnlyDictionary? additionalClaims = null)
+ {
+ if (_entries.TryGetValue(id, out var entry))
+ {
+ entry.Subject = subject;
+ entry.Tenant = tenant;
+ entry.Roles = roles;
+ entry.Groups = groups;
+ entry.AdditionalClaims = additionalClaims;
+ entry.Status = PersonPendingStatus.Allowed;
+ }
+ }
+
+ ///
+ public void MarkDenied(string id, string reason)
+ {
+ if (_entries.TryGetValue(id, out var entry))
+ {
+ entry.Status = PersonPendingStatus.Denied;
+ entry.DenyReason = reason;
+ }
+ }
+
+ /// Remove all entries (test helper).
+ public void Clear() => _entries.Clear();
+
+ /// Evict entries older than .
+ private void Sweep()
+ {
+ var cutoff = DateTimeOffset.UtcNow - Ttl;
+ foreach (var kv in _entries)
+ {
+ if (kv.Value.CreatedAt < cutoff)
+ {
+ _entries.TryRemove(kv.Key, out _);
+ }
+ }
+ }
+}
diff --git a/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs b/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs
index 2bdb6f9..428f4b1 100644
--- a/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs
+++ b/src/AAuth/Server/Challenge/AAuthChallengeMiddleware.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
+using AAuth.Agent;
using AAuth.Crypto;
using AAuth.Headers;
using AAuth.HttpSig;
@@ -156,6 +157,19 @@ private Task IssueChallenge(
"ChallengeOptions.ResourceIdentifier must be set for RequireAuthToken mode.");
}
+ // §Terminology / §Mission Request Header: a mission-aware resource copies
+ // the mission object from a valid AAuth-Mission header (verified as a
+ // signed component upstream) into the resource token it issues, so the
+ // mission context (approver + s256) reaches the PS.
+ MissionClaim? mission = null;
+ if (_options.MissionAware
+ && AAuthMissionHeader.TryParseStructured(
+ context.Request.Headers[AAuthMissionHeader.Name],
+ out var missionApprover, out var missionS256))
+ {
+ mission = new MissionClaim(missionApprover!, missionS256!);
+ }
+
var resourceToken = new ResourceTokenBuilder
{
Issuer = _options.ResourceIdentifier,
@@ -165,6 +179,7 @@ private Task IssueChallenge(
Key = _options.ResourceSigningKey,
KeyId = _options.ResourceKeyId,
Scope = _options.DefaultScopes,
+ Mission = mission,
}.Build();
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
diff --git a/src/AAuth/Server/Challenge/ChallengeOptions.cs b/src/AAuth/Server/Challenge/ChallengeOptions.cs
index 88f12f2..5ea14bf 100644
--- a/src/AAuth/Server/Challenge/ChallengeOptions.cs
+++ b/src/AAuth/Server/Challenge/ChallengeOptions.cs
@@ -53,4 +53,15 @@ public sealed class ChallengeOptions
/// When null, all schemes are accepted.
///
public IReadOnlySet? AllowedSignatureKeySchemes { get; init; }
+
+ ///
+ /// When , the resource is mission-aware (§Terminology:
+ /// "a mission-aware resource includes the mission object from the
+ /// AAuth-Mission header in the resource tokens it issues"). If the
+ /// challenged request carries a valid AAuth-Mission header, the issued
+ /// resource token includes the mission object (approver + s256)
+ /// so the mission context flows to the PS. When
+ /// (default) the header is ignored.
+ ///
+ public bool MissionAware { get; init; }
}
diff --git a/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs
new file mode 100644
index 0000000..cc76c4f
--- /dev/null
+++ b/src/AAuth/Server/Governance/AAuthGovernancePipelineOptions.cs
@@ -0,0 +1,66 @@
+using System;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Options for the PS governance endpoint mapper (MapAAuthGovernance).
+/// Controls the route prefix and the per-endpoint paths so a PS can mount the
+/// permission / audit / interaction endpoints where its metadata advertises them
+/// (§Person Server Metadata, §PS Governance Endpoints).
+///
+public sealed class AAuthGovernancePipelineOptions
+{
+ ///
+ /// Route prefix prepended to each endpoint path (default empty). For example,
+ /// set "/governance" to mount at /governance/permission.
+ ///
+ public string RoutePrefix { get; set; } = string.Empty;
+
+ /// The permission endpoint path (§Permission Endpoint). Default /permission.
+ public string PermissionPath { get; set; } = "/permission";
+
+ /// The audit endpoint path (§Audit Endpoint). Default /audit.
+ public string AuditPath { get; set; } = "/audit";
+
+ /// The interaction endpoint path (§Interaction Endpoint). Default /mission-interaction.
+ public string InteractionPath { get; set; } = "/mission-interaction";
+
+ /// The mission-creation endpoint path (§Mission Creation). Default /mission.
+ public string MissionPath { get; set; } = "/mission";
+
+ ///
+ /// The deferred-consent poll endpoint path template (§Deferred Consent). The
+ /// mapper appends /{id}. Default /governance-pending; the
+ /// 202Location points the agent here.
+ ///
+ public string PendingPath { get; set; } = "/governance-pending";
+
+ ///
+ /// Optional user-facing interaction URL (§User Interaction). When set, the
+ /// 202 deferred-consent response includes an
+ /// AAuth-Requirement: requirement=interaction header pointing the
+ /// agent's user here (the PS's browser consent page). When null, the agent
+ /// relies on polling the Location alone.
+ ///
+ public string? InteractionUrl { get; set; }
+
+ ///
+ /// The PS's canonical approver URL written into mission approval blobs
+ /// (§Mission Approval). When null, the mapper derives it from the request
+ /// origin (scheme://host). Set this when the PS's advertised issuer
+ /// differs from the request origin (e.g. behind a proxy).
+ ///
+ public string? Approver { get; set; }
+
+ // Compose the prefix with a path, collapsing duplicate slashes at the seam.
+ internal string Resolve(string path)
+ {
+ if (string.IsNullOrEmpty(RoutePrefix))
+ {
+ return path;
+ }
+ var prefix = RoutePrefix.TrimEnd('/');
+ var suffix = path.StartsWith('/') ? path : "/" + path;
+ return prefix + suffix;
+ }
+}
diff --git a/src/AAuth/Server/Governance/DefaultAuditSink.cs b/src/AAuth/Server/Governance/DefaultAuditSink.cs
new file mode 100644
index 0000000..282bcf6
--- /dev/null
+++ b/src/AAuth/Server/Governance/DefaultAuditSink.cs
@@ -0,0 +1,34 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Default used when a PS registers
+/// AddAAuthGovernance without supplying its own sink. It appends the
+/// reported action to the mission log (§Audit Endpoint) so the trail is
+/// preserved. A PS that needs anomaly detection or alerting should override it.
+///
+public sealed class DefaultAuditSink : IAuditSink
+{
+ private readonly IMissionLog _log;
+
+ /// Create the default sink over the registered mission log.
+ public DefaultAuditSink(IMissionLog log)
+ => _log = log ?? throw new ArgumentNullException(nameof(log));
+
+ ///
+ public Task RecordAsync(AuditRecord record, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(record);
+ return _log.AppendAsync(
+ new MissionLogEntry(record.Mission.S256, MissionLogEntryKind.Audit, DateTimeOffset.UtcNow)
+ {
+ Action = record.Action.Name,
+ Detail = record.Description,
+ },
+ ct);
+ }
+}
diff --git a/src/AAuth/Server/Governance/DefaultInteractionRelay.cs b/src/AAuth/Server/Governance/DefaultInteractionRelay.cs
new file mode 100644
index 0000000..7db6521
--- /dev/null
+++ b/src/AAuth/Server/Governance/DefaultInteractionRelay.cs
@@ -0,0 +1,28 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Default used when a PS registers
+/// AddAAuthGovernance without supplying its own user channel. It has no way
+/// to reach the user, so it returns a benign, non-pending result: questions get an
+/// empty answer and completion proposals are treated as not accepted (the mission
+/// stays active). A PS that can reach the user MUST override this (§Interaction
+/// Endpoint).
+///
+public sealed class DefaultInteractionRelay : IInteractionRelay
+{
+ ///
+ public Task RelayAsync(InteractionRequest request, CancellationToken ct = default)
+ {
+ System.ArgumentNullException.ThrowIfNull(request);
+ return Task.FromResult(request.Type switch
+ {
+ InteractionType.Question => new InteractionRelayResult { Answer = string.Empty },
+ InteractionType.Completion => new InteractionRelayResult { Accepted = false },
+ _ => new InteractionRelayResult { Pending = false },
+ });
+ }
+}
diff --git a/src/AAuth/Server/Governance/DefaultMissionApprover.cs b/src/AAuth/Server/Governance/DefaultMissionApprover.cs
new file mode 100644
index 0000000..9e3db20
--- /dev/null
+++ b/src/AAuth/Server/Governance/DefaultMissionApprover.cs
@@ -0,0 +1,20 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Default used when a PS registers
+/// AddAAuthGovernance without supplying its own approver. It approves every
+/// proposed mission and every proposed tool (§Mission Creation). A real PS should
+/// override this to surface the proposal to the user and prune the approved tools.
+///
+public sealed class DefaultMissionApprover : IMissionApprover
+{
+ ///
+ public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default)
+ {
+ System.ArgumentNullException.ThrowIfNull(context);
+ return Task.FromResult(MissionApprovalDecision.Approve(context.Proposal.Tools));
+ }
+}
diff --git a/src/AAuth/Server/Governance/DefaultPermissionDecider.cs b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs
new file mode 100644
index 0000000..3dc7a34
--- /dev/null
+++ b/src/AAuth/Server/Governance/DefaultPermissionDecider.cs
@@ -0,0 +1,39 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Default used when a PS registers
+/// AddAAuthGovernance without supplying its own policy. It grants a
+/// request only when the action is a pre-approved tool on the bound mission
+/// (§Permission Endpoint — pre-approved tools resolve without prompting);
+/// every other action is left to the user via .
+/// A real PS should override this with its own policy.
+///
+public sealed class DefaultPermissionDecider : IPermissionDecider
+{
+ ///
+ public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default)
+ {
+ System.ArgumentNullException.ThrowIfNull(context);
+
+ if (context.Mission is not null)
+ {
+ var mission = Mission.FromApprovalBytes(context.Mission.Blob.Span);
+ foreach (var tool in mission.ApprovedTools)
+ {
+ if (string.Equals(tool.Name, context.Request.Action.Name, System.StringComparison.Ordinal))
+ {
+ return Task.FromResult(new PermissionDecision(
+ PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool));
+ }
+ }
+ }
+
+ return Task.FromResult(new PermissionDecision(
+ PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope));
+ }
+}
diff --git a/src/AAuth/Server/Governance/DelegateInteractionRelay.cs b/src/AAuth/Server/Governance/DelegateInteractionRelay.cs
new file mode 100644
index 0000000..ea1c00c
--- /dev/null
+++ b/src/AAuth/Server/Governance/DelegateInteractionRelay.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// An backed by a delegate, so a PS can supply a
+/// user channel with a lambda instead of a full class (§Interaction Endpoint). The
+/// delegate receives the parsed and returns the
+/// outcome.
+///
+public sealed class DelegateInteractionRelay : IInteractionRelay
+{
+ private readonly Func> _relay;
+
+ /// Create a relay from an async delegate.
+ public DelegateInteractionRelay(Func> relay)
+ {
+ _relay = relay ?? throw new ArgumentNullException(nameof(relay));
+ }
+
+ ///
+ public Task RelayAsync(InteractionRequest request, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return _relay(request, ct);
+ }
+}
diff --git a/src/AAuth/Server/Governance/GovernanceEndpoints.cs b/src/AAuth/Server/Governance/GovernanceEndpoints.cs
new file mode 100644
index 0000000..e6d44b2
--- /dev/null
+++ b/src/AAuth/Server/Governance/GovernanceEndpoints.cs
@@ -0,0 +1,146 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Nodes;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Errors;
+using AAuth.Tokens;
+using Microsoft.AspNetCore.Http;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Minimal server-side helpers for the PS governance endpoints
+/// (§PS Governance Endpoints): request-body parsers that map JSON to the shared
+/// DTOs, and the canonical mission_terminated response
+/// (§Mission Status Errors). Policy and the user channel stay in the PS; this
+/// type only removes hand-rolled parsing.
+///
+public static class GovernanceEndpoints
+{
+ /// HTTP status for a terminated mission (§Mission Status Errors).
+ public const int MissionTerminatedStatus = StatusCodes.Status403Forbidden;
+
+ ///
+ /// Parse a permission request body (§Permission Request) into a
+ /// .
+ ///
+ /// The required action is missing.
+ public static PermissionRequest ParsePermission(JsonObject body)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ var action = (string?)body["action"]
+ ?? throw new FormatException("Permission request is missing the required 'action'.");
+ return new PermissionRequest(new MissionAction(action))
+ {
+ Description = (string?)body["description"],
+ Parameters = body["parameters"] as JsonObject,
+ Mission = MissionClaim.FromPayload(body),
+ };
+ }
+
+ ///
+ /// Parse an audit request body (§Audit Request) into an .
+ ///
+ /// The required mission or action is missing.
+ public static AuditRecord ParseAudit(JsonObject body)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ var mission = MissionClaim.FromPayload(body)
+ ?? throw new FormatException("Audit request is missing the required 'mission'.");
+ var action = (string?)body["action"]
+ ?? throw new FormatException("Audit request is missing the required 'action'.");
+ return new AuditRecord(mission, new MissionAction(action))
+ {
+ Description = (string?)body["description"],
+ Parameters = body["parameters"] as JsonObject,
+ Result = body["result"] as JsonObject,
+ };
+ }
+
+ ///
+ /// Parse an interaction request body (§Interaction Request) into an
+ /// .
+ ///
+ /// The required type is missing or unknown.
+ public static InteractionRequest ParseInteraction(JsonObject body)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ var typeValue = (string?)body["type"]
+ ?? throw new FormatException("Interaction request is missing the required 'type'.");
+ var type = typeValue switch
+ {
+ "interaction" => InteractionType.Interaction,
+ "payment" => InteractionType.Payment,
+ "question" => InteractionType.Question,
+ "completion" => InteractionType.Completion,
+ _ => throw new FormatException($"Interaction request has an unknown 'type': {typeValue}"),
+ };
+ return new InteractionRequest(type)
+ {
+ Description = (string?)body["description"],
+ Url = (string?)body["url"],
+ Code = (string?)body["code"],
+ Question = (string?)body["question"],
+ Summary = (string?)body["summary"],
+ Mission = MissionClaim.FromPayload(body),
+ };
+ }
+
+ ///
+ /// Parse a mission proposal body (§Mission Creation) into a
+ /// .
+ ///
+ /// The required description is missing.
+ public static MissionProposal ParseMissionProposal(JsonObject body)
+ {
+ ArgumentNullException.ThrowIfNull(body);
+ var description = (string?)body["description"]
+ ?? throw new FormatException("Mission proposal is missing the required 'description'.");
+ return new MissionProposal(description)
+ {
+ Tools = ParseTools(body["tools"] as JsonArray),
+ };
+ }
+
+ ///
+ /// The canonical mission_terminated response body (§Mission Status
+ /// Errors): { "error": "mission_terminated", "mission_status": "..." }.
+ ///
+ public static JsonObject MissionTerminatedBody(string missionStatus = "terminated")
+ => new()
+ {
+ ["error"] = AAuthMissionTerminatedException.ErrorCode,
+ ["mission_status"] = missionStatus,
+ };
+
+ ///
+ /// An ASP.NET Core emitting the spec
+ /// 403 mission_terminated response (§Mission Status Errors).
+ ///
+ public static IResult MissionTerminated(string missionStatus = "terminated")
+ => Results.Json(MissionTerminatedBody(missionStatus), statusCode: MissionTerminatedStatus);
+
+ private static IReadOnlyList ParseTools(JsonArray? tools)
+ {
+ if (tools is null || tools.Count == 0)
+ {
+ return Array.Empty();
+ }
+ var result = new List(tools.Count);
+ foreach (var node in tools)
+ {
+ if (node is not JsonObject tool)
+ {
+ continue;
+ }
+ var name = (string?)tool["name"];
+ if (string.IsNullOrEmpty(name))
+ {
+ continue;
+ }
+ result.Add(new MissionTool(name, (string?)tool["description"]));
+ }
+ return result;
+ }
+}
diff --git a/src/AAuth/Server/Governance/IAuditSink.cs b/src/AAuth/Server/Governance/IAuditSink.cs
new file mode 100644
index 0000000..42a212e
--- /dev/null
+++ b/src/AAuth/Server/Governance/IAuditSink.cs
@@ -0,0 +1,17 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// PS-side sink for audit records (§Audit Endpoint). The PS records the entry in
+/// the mission log and MAY use it to detect anomalous behavior, alert the user,
+/// or revoke the mission. The SDK supplies the contract; the PS implements
+/// storage/alerting policy.
+///
+public interface IAuditSink
+{
+ /// Record an audit entry the agent reported within a mission context.
+ Task RecordAsync(AuditRecord record, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
new file mode 100644
index 0000000..9325a98
--- /dev/null
+++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
@@ -0,0 +1,92 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+/// The governance decision a deferred consent resolves (§Deferred Consent).
+public enum DeferredConsentKind
+{
+ /// A mission proposal awaiting the user's approval (§Mission Creation).
+ MissionCreation,
+
+ /// A permission request awaiting the user's decision (§Permission Endpoint).
+ Permission,
+
+ ///
+ /// An interaction / payment relay awaiting the user's completion
+ /// (§Interaction Response). The PS relays it to the user and the agent polls
+ /// until the user completes the interaction.
+ ///
+ Interaction,
+
+ ///
+ /// A completion summary the user is reviewing (§Interaction Response:
+ /// "The PS returns a deferred response while the user reviews."). The agent
+ /// polls until the user accepts (the PS terminates the mission) or responds
+ /// with follow-up questions (the mission stays active).
+ ///
+ Completion,
+}
+
+///
+/// A governance decision parked for the user (§Deferred Consent). When the PS
+/// cannot decide synchronously, MapAAuthGovernance parks the request here,
+/// answers 202 Accepted with a poll Location, and resolves the
+/// parked entry once the user decides (typically from the PS's browser consent
+/// page, which calls ).
+///
+public sealed class DeferredConsent
+{
+ /// The opaque pending id (assigned by the store on park).
+ public string Id { get; set; } = string.Empty;
+
+ /// Which governance decision this entry resolves.
+ public required DeferredConsentKind Kind { get; init; }
+
+ /// The agent the request was made by.
+ public string Agent { get; init; } = string.Empty;
+
+ /// HTTPS URL of the approver (the PS).
+ public string Approver { get; init; } = string.Empty;
+
+ /// The proposal (set when is ).
+ public MissionProposal? Proposal { get; init; }
+
+ /// The permission request (set when is ).
+ public PermissionRequest? Permission { get; init; }
+
+ /// The interaction request (set when is ).
+ public InteractionRequest? Interaction { get; init; }
+
+ ///
+ /// The user's decision: while pending,
+ /// on approval, on decline.
+ ///
+ public bool? Decision { get; set; }
+}
+
+///
+/// PS-side persistence seam for deferred (user-driven) governance consents
+/// (§Deferred Consent). The SDK supplies the contract and an in-memory default
+/// (); a production PS swaps in durable
+/// storage. Registering this seam (via AddAAuthDeferredConsent) opts the
+/// governance mapper into the 202 poll flow for Prompt outcomes.
+///
+public interface IDeferredConsentStore
+{
+ /// Park a pending consent, assigning and returning its .
+ Task ParkAsync(DeferredConsent consent, CancellationToken ct = default);
+
+ /// Look up a parked consent by id. Returns when absent.
+ Task GetAsync(string id, CancellationToken ct = default);
+
+ /// Record the user's decision on a parked consent. No-op when absent.
+ Task ResolveAsync(string id, bool approved, CancellationToken ct = default);
+
+ /// Remove a parked consent (after it has been resolved and consumed).
+ Task RemoveAsync(string id, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IInteractionRelay.cs b/src/AAuth/Server/Governance/IInteractionRelay.cs
new file mode 100644
index 0000000..0178b77
--- /dev/null
+++ b/src/AAuth/Server/Governance/IInteractionRelay.cs
@@ -0,0 +1,40 @@
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// The result of relaying an interaction to the user through the PS (§Interaction
+/// Response). Populated fields depend on the request .
+///
+public sealed record InteractionRelayResult
+{
+ /// The user's answer (for question).
+ public string? Answer { get; init; }
+
+ ///
+ /// Whether the user accepted mission completion (for completion). When
+ /// the PS terminates the mission; when
+ /// the mission remains active.
+ ///
+ public bool? Accepted { get; init; }
+
+ ///
+ /// Whether the relay is still pending — the PS should return a deferred
+ /// response and let the agent poll (for interaction / payment).
+ ///
+ public bool Pending { get; init; }
+}
+
+///
+/// PS-side relay seam for the interaction endpoint (§Interaction Endpoint): reach
+/// the user to relay an interaction/payment, ask a question, or present a
+/// completion summary. The SDK supplies the contract; the PS implements the
+/// user channel.
+///
+public interface IInteractionRelay
+{
+ /// Relay to the user and return the outcome.
+ Task RelayAsync(InteractionRequest request, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IMissionApprover.cs b/src/AAuth/Server/Governance/IMissionApprover.cs
new file mode 100644
index 0000000..54d60eb
--- /dev/null
+++ b/src/AAuth/Server/Governance/IMissionApprover.cs
@@ -0,0 +1,75 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+/// The PS's outcome for a mission proposal (§Mission Creation).
+public enum MissionApprovalOutcome
+{
+ /// Approve the mission without prompting the user.
+ Approved,
+
+ /// Decline the mission without prompting the user.
+ Declined,
+
+ ///
+ /// Defer — the PS must prompt the user before approving. The mapper parks the
+ /// proposal via and answers 202.
+ ///
+ Prompt,
+}
+
+///
+/// The inputs a PS evaluates when deciding a mission proposal: the proposing
+/// agent, the approving PS, and the proposal itself (§Mission Creation).
+///
+/// The agent identifier the mission would be approved for.
+/// HTTPS URL of the approver (the PS).
+/// The parsed mission proposal.
+public sealed record MissionApprovalContext(
+ string Agent,
+ string Approver,
+ MissionProposal Proposal);
+
+///
+/// A typed mission-approval decision carrying the outcome and — when approved —
+/// the subset of proposed tools the PS approved (§Mission Approval). A real PS
+/// may prune the proposed tools; the approved set is what gets written into the
+/// verbatim approval blob.
+///
+/// Approve, decline, or prompt.
+/// The approved tools (used only when is approved).
+/// Optional Markdown message for the user (e.g. a decline reason).
+public sealed record MissionApprovalDecision(
+ MissionApprovalOutcome Outcome,
+ IReadOnlyList ApprovedTools,
+ string? Message = null)
+{
+ /// Approve the mission with the given approved tool set.
+ public static MissionApprovalDecision Approve(IReadOnlyList approvedTools)
+ => new(MissionApprovalOutcome.Approved, approvedTools);
+
+ /// Decline the mission, optionally with a user-facing message.
+ public static MissionApprovalDecision Decline(string? message = null)
+ => new(MissionApprovalOutcome.Declined, System.Array.Empty(), message);
+
+ /// Defer to the user via the deferred-consent (202) flow.
+ public static MissionApprovalDecision Defer()
+ => new(MissionApprovalOutcome.Prompt, System.Array.Empty());
+}
+
+///
+/// PS-side policy seam for mission creation (§Mission Creation, §Mission
+/// Approval). The SDK supplies the proposal context and the approval-blob builder
+/// (); the PS decides whether to approve, and
+/// which proposed tools to approve. The default
+/// () approves every proposed tool.
+///
+public interface IMissionApprover
+{
+ /// Decide a mission proposal.
+ Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IMissionLog.cs b/src/AAuth/Server/Governance/IMissionLog.cs
new file mode 100644
index 0000000..e8bab67
--- /dev/null
+++ b/src/AAuth/Server/Governance/IMissionLog.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AAuth.Server.Governance;
+
+/// The kind of action recorded in a mission log entry (§Mission Log).
+public enum MissionLogEntryKind
+{
+ /// A token request (with justification) the agent made under the mission.
+ Token,
+
+ /// A permission request and its decision.
+ Permission,
+
+ /// An audit record the agent reported.
+ Audit,
+
+ /// An interaction relayed through the PS.
+ Interaction,
+
+ /// A clarification chat exchange during review.
+ Clarification,
+}
+
+///
+/// A single entry in the mission log (§Mission Log) — the ordered record of an
+/// agent's actions and the governance decisions made within a mission context.
+///
+/// The mission this entry belongs to.
+/// The kind of action.
+/// When the entry was recorded.
+public sealed record MissionLogEntry(string S256, MissionLogEntryKind Kind, DateTimeOffset Timestamp)
+{
+ /// The resource involved (for token entries) — used for prior-consent lookups.
+ public string? Resource { get; init; }
+
+ /// The scope involved (for token entries) — used for prior-consent lookups.
+ public string? Scope { get; init; }
+
+ /// The action name (for permission / audit entries).
+ public string? Action { get; init; }
+
+ /// Whether the governance decision granted the request (for token / permission entries).
+ public bool? Granted { get; init; }
+
+ /// Free-form detail (e.g. a token-request justification or clarification text).
+ public string? Detail { get; init; }
+}
+
+///
+/// PS-side mission log seam (§Mission Log). Entries are appended in order; the
+/// PS reads the log to evaluate whether each new request is consistent with the
+/// mission's intent. The SDK provides the contract and an in-memory default
+/// ().
+///
+public interface IMissionLog
+{
+ /// Append to the mission's ordered log.
+ Task AppendAsync(MissionLogEntry entry, CancellationToken ct = default);
+
+ /// Read the mission's entries in append order.
+ Task> ReadAsync(string s256, CancellationToken ct = default);
+
+ ///
+ /// Whether the mission already has a granted token entry for
+ /// and — the prior-consent
+ /// signal the PS uses to resolve a repeat request silently (§Agent Token
+ /// Request — prior-consent gate).
+ ///
+ Task HasPriorConsentAsync(string s256, string resource, string scope, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IMissionStore.cs b/src/AAuth/Server/Governance/IMissionStore.cs
new file mode 100644
index 0000000..bf7bbab
--- /dev/null
+++ b/src/AAuth/Server/Governance/IMissionStore.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+
+namespace AAuth.Server.Governance;
+
+///
+/// A mission as persisted by the PS: the verbatim approval blob bytes (so the
+/// s256 remains verifiable) plus its lifecycle state (§Mission Approval,
+/// §Mission Management).
+///
+/// The mission identity — base64url(SHA-256(blob)).
+/// HTTPS URL of the approver (the PS).
+/// The agent identifier the mission was approved for.
+/// The exact approval response body bytes, stored verbatim.
+public sealed record StoredMission(
+ string S256,
+ string Approver,
+ string Agent,
+ ReadOnlyMemory Blob)
+{
+ /// The mission lifecycle state (§Mission Management). Defaults to active.
+ public MissionState State { get; init; } = MissionState.Active;
+}
+
+///
+/// PS-side persistence seam for missions (§Mission Approval, §Mission
+/// Management). The SDK provides the contract and an in-memory default
+/// (); a production PS swaps in durable storage.
+///
+public interface IMissionStore
+{
+ /// Persist (or replace) a mission keyed by its s256.
+ Task SaveAsync(StoredMission mission, CancellationToken ct = default);
+
+ /// Look up a mission by its s256. Returns when absent.
+ Task GetAsync(string s256, CancellationToken ct = default);
+
+ ///
+ /// Transition a mission to (e.g. on completion or
+ /// revocation). No-op when the mission is absent.
+ ///
+ Task SetStateAsync(string s256, MissionState state, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/IPermissionDecider.cs b/src/AAuth/Server/Governance/IPermissionDecider.cs
new file mode 100644
index 0000000..99cb569
--- /dev/null
+++ b/src/AAuth/Server/Governance/IPermissionDecider.cs
@@ -0,0 +1,74 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+/// The PS's outcome for a permission request (§Permission Response).
+public enum PermissionOutcome
+{
+ /// Grant without prompting the user.
+ Granted,
+
+ /// Deny without prompting the user.
+ Denied,
+
+ /// Defer — the PS must prompt the user before deciding.
+ Prompt,
+}
+
+///
+/// Why the PS reached a permission decision. The SDK supplies the reason enum so
+/// a PS can surface it to UIs and the mission log; the PS owns the policy
+/// (§Agent Token Request, §Permission Endpoint).
+///
+public enum PermissionDecisionReason
+{
+ /// The action is within the mission's approved scope.
+ InScope,
+
+ /// The user previously consented to an equivalent action.
+ PriorConsent,
+
+ /// The action is a pre-approved tool on the mission (approved_tools).
+ ApprovedTool,
+
+ /// The action is outside known scope — the user must be prompted.
+ OutOfScope,
+}
+
+///
+/// The inputs a PS evaluates when deciding a permission request: the request
+/// itself, the mission it is bound to (if any), and the mission log history.
+///
+/// The parsed permission request.
+/// The bound mission, or when missionless.
+/// The mission log entries (empty when missionless).
+public sealed record PermissionDecisionContext(
+ PermissionRequest Request,
+ StoredMission? Mission,
+ IReadOnlyList Log);
+
+///
+/// A typed permission decision carrying both the outcome and the reason, so a PS
+/// can act on it and display it (§Permission Endpoint).
+///
+/// Grant, deny, or prompt.
+/// Why the decision was reached.
+/// Optional Markdown message for the user (e.g. a denial reason).
+public sealed record PermissionDecision(
+ PermissionOutcome Outcome,
+ PermissionDecisionReason Reason,
+ string? Message = null);
+
+///
+/// PS-side policy seam for the permission endpoint (§Permission Endpoint). The
+/// SDK supplies the inputs () and the
+/// reason enum; the PS implements the policy.
+///
+public interface IPermissionDecider
+{
+ /// Decide a permission request given its mission + log context.
+ Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default);
+}
diff --git a/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs b/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs
new file mode 100644
index 0000000..c1c1448
--- /dev/null
+++ b/src/AAuth/Server/Governance/InMemoryDeferredConsentStore.cs
@@ -0,0 +1,55 @@
+using System;
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AAuth.Server.Governance;
+
+///
+/// In-memory for development and samples
+/// (§Deferred Consent). Pending consents live only in process memory; a
+/// production PS swaps in durable, expiring storage.
+///
+public sealed class InMemoryDeferredConsentStore : IDeferredConsentStore
+{
+ private readonly ConcurrentDictionary _entries =
+ new(StringComparer.Ordinal);
+
+ ///
+ public Task ParkAsync(DeferredConsent consent, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(consent);
+ if (string.IsNullOrEmpty(consent.Id))
+ {
+ consent.Id = Guid.NewGuid().ToString("N");
+ }
+ _entries[consent.Id] = consent;
+ return Task.FromResult(consent);
+ }
+
+ ///
+ public Task GetAsync(string id, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+ return Task.FromResult(_entries.TryGetValue(id, out var entry) ? entry : null);
+ }
+
+ ///
+ public Task ResolveAsync(string id, bool approved, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+ if (_entries.TryGetValue(id, out var entry))
+ {
+ entry.Decision = approved;
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task RemoveAsync(string id, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(id);
+ _entries.TryRemove(id, out _);
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AAuth/Server/Governance/InMemoryMissionLog.cs b/src/AAuth/Server/Governance/InMemoryMissionLog.cs
new file mode 100644
index 0000000..c3105e1
--- /dev/null
+++ b/src/AAuth/Server/Governance/InMemoryMissionLog.cs
@@ -0,0 +1,71 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace AAuth.Server.Governance;
+
+///
+/// In-memory for development and testing. Preserves
+/// append order per mission. NOT production-grade — state is lost on restart.
+///
+public sealed class InMemoryMissionLog : IMissionLog
+{
+ private readonly object _gate = new();
+ private readonly Dictionary> _entries = new();
+
+ ///
+ public Task AppendAsync(MissionLogEntry entry, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(entry);
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue(entry.S256, out var list))
+ {
+ list = new List();
+ _entries[entry.S256] = list;
+ }
+ list.Add(entry);
+ }
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task> ReadAsync(string s256, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(s256);
+ lock (_gate)
+ {
+ IReadOnlyList snapshot = _entries.TryGetValue(s256, out var list)
+ ? list.ToArray()
+ : Array.Empty();
+ return Task.FromResult(snapshot);
+ }
+ }
+
+ ///
+ public Task HasPriorConsentAsync(string s256, string resource, string scope, CancellationToken ct = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(s256);
+ ArgumentException.ThrowIfNullOrEmpty(resource);
+ ArgumentException.ThrowIfNullOrEmpty(scope);
+ lock (_gate)
+ {
+ if (!_entries.TryGetValue(s256, out var list))
+ {
+ return Task.FromResult(false);
+ }
+ foreach (var entry in list)
+ {
+ if (entry.Kind == MissionLogEntryKind.Token
+ && entry.Granted == true
+ && string.Equals(entry.Resource, resource, StringComparison.Ordinal)
+ && string.Equals(entry.Scope, scope, StringComparison.Ordinal))
+ {
+ return Task.FromResult(true);
+ }
+ }
+ return Task.FromResult(false);
+ }
+ }
+}
diff --git a/src/AAuth/Server/Governance/InMemoryMissionStore.cs b/src/AAuth/Server/Governance/InMemoryMissionStore.cs
new file mode 100644
index 0000000..a44cd0d
--- /dev/null
+++ b/src/AAuth/Server/Governance/InMemoryMissionStore.cs
@@ -0,0 +1,43 @@
+using System.Collections.Concurrent;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+
+namespace AAuth.Server.Governance;
+
+///
+/// In-memory for development and testing. NOT
+/// production-grade — state is lost on restart and there is no distributed
+/// support.
+///
+public sealed class InMemoryMissionStore : IMissionStore
+{
+ private readonly ConcurrentDictionary _missions = new();
+
+ ///
+ public Task SaveAsync(StoredMission mission, CancellationToken ct = default)
+ {
+ System.ArgumentNullException.ThrowIfNull(mission);
+ _missions[mission.S256] = mission;
+ return Task.CompletedTask;
+ }
+
+ ///
+ public Task GetAsync(string s256, CancellationToken ct = default)
+ {
+ System.ArgumentException.ThrowIfNullOrEmpty(s256);
+ _missions.TryGetValue(s256, out var mission);
+ return Task.FromResult(mission);
+ }
+
+ ///
+ public Task SetStateAsync(string s256, MissionState state, CancellationToken ct = default)
+ {
+ System.ArgumentException.ThrowIfNullOrEmpty(s256);
+ if (_missions.TryGetValue(s256, out var existing))
+ {
+ _missions[s256] = existing with { State = state };
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AAuth/Server/Governance/MissionApprovalBuilder.cs b/src/AAuth/Server/Governance/MissionApprovalBuilder.cs
new file mode 100644
index 0000000..d71a74e
--- /dev/null
+++ b/src/AAuth/Server/Governance/MissionApprovalBuilder.cs
@@ -0,0 +1,64 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json.Nodes;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+
+namespace AAuth.Server.Governance;
+
+///
+/// Builds the verbatim mission approval blob (§Mission Approval). The bytes are
+/// returned exactly as they will be sent, so the s256 the PS advertises in
+/// the AAuth-Mission header matches what the agent computes over the same
+/// bytes. A PS that needs a different on-the-wire shape can build its own blob;
+/// this is the canonical default used by MapAAuthGovernance.
+///
+public static class MissionApprovalBuilder
+{
+ ///
+ /// Build the approval blob bytes and their s256 identity for an
+ /// approved mission.
+ ///
+ /// HTTPS URL of the approver (the PS).
+ /// The agent the mission is approved for.
+ /// The proposed mission (its description is copied verbatim).
+ /// The tools the PS approved (a subset of the proposed tools).
+ /// The approval timestamp.
+ /// The exact blob bytes and their base64url(SHA-256) identity.
+ public static (byte[] Blob, string S256) Build(
+ string approver,
+ string agent,
+ MissionProposal proposal,
+ IReadOnlyList approvedTools,
+ DateTimeOffset approvedAt)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(approver);
+ ArgumentException.ThrowIfNullOrEmpty(agent);
+ ArgumentNullException.ThrowIfNull(proposal);
+ ArgumentNullException.ThrowIfNull(approvedTools);
+
+ var tools = new JsonArray();
+ foreach (var tool in approvedTools)
+ {
+ var obj = new JsonObject { ["name"] = tool.Name };
+ if (!string.IsNullOrEmpty(tool.Description))
+ {
+ obj["description"] = tool.Description;
+ }
+ tools.Add(obj);
+ }
+
+ var blob = new JsonObject
+ {
+ ["approver"] = approver,
+ ["agent"] = agent,
+ ["approved_at"] = approvedAt.ToString("o"),
+ ["description"] = proposal.Description,
+ ["approved_tools"] = tools,
+ };
+
+ var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString());
+ return (bytes, Mission.ComputeS256(bytes));
+ }
+}
diff --git a/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs b/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs
index 2c5c0f2..bb77d4e 100644
--- a/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs
+++ b/src/AAuth/Server/Verification/AAuthVerificationMiddleware.cs
@@ -3,6 +3,7 @@
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
+using AAuth.Agent;
using AAuth.Crypto;
using AAuth.Discovery;
using AAuth.Errors;
@@ -120,7 +121,8 @@ public async Task InvokeAsync(HttpContext context)
signatureInput: signatureInput,
signatureHeader: signature,
publicKey: publicKey,
- authorization: req.Headers.Authorization.FirstOrDefault());
+ authorization: req.Headers.Authorization.FirstOrDefault(),
+ mission: req.Headers[AAuthMissionHeader.Name].FirstOrDefault());
}
catch (AAuthVerificationException ex)
{
diff --git a/src/AAuth/Tokens/AuthTokenBuilder.cs b/src/AAuth/Tokens/AuthTokenBuilder.cs
index d958a3a..9dc32ed 100644
--- a/src/AAuth/Tokens/AuthTokenBuilder.cs
+++ b/src/AAuth/Tokens/AuthTokenBuilder.cs
@@ -64,6 +64,13 @@ public sealed class AuthTokenBuilder
/// Pairwise pseudonymous user identifier.
public string? Subject { get; init; }
+ ///
+ /// Mission claim (mission) — present when the auth token was issued in
+ /// the context of a mission (§Auth Token Structure). Carries only approver
+ /// and s256; the mission content stays at the PS.
+ ///
+ public MissionClaim? Mission { get; init; }
+
///
/// Enterprise tenant claim (§Auth Token, OpenID Connect for
/// Enterprise) — identifies the principal's tenant/organization within the
@@ -191,6 +198,10 @@ public string Build()
{
payload["groups"] = ToJsonArray(Groups);
}
+ if (Mission is not null)
+ {
+ payload["mission"] = Mission.ToJsonObject();
+ }
if (AdditionalClaims is not null)
{
foreach (var (k, v) in AdditionalClaims)
diff --git a/src/AAuth/Tokens/MissionClaim.cs b/src/AAuth/Tokens/MissionClaim.cs
new file mode 100644
index 0000000..6d8f267
--- /dev/null
+++ b/src/AAuth/Tokens/MissionClaim.cs
@@ -0,0 +1,37 @@
+using System.Text.Json.Nodes;
+
+namespace AAuth.Tokens;
+
+///
+/// The mission claim carried in resource and auth tokens (§Resource Token
+/// Structure, §Auth Token Structure). Identifies the mission by its approver and
+/// s256 hash — the mission content itself never leaves the PS.
+///
+/// HTTPS URL of the entity that approved the mission.
+/// base64url(SHA-256) of the approved mission JSON.
+public sealed record MissionClaim(string Approver, string S256)
+{
+ /// Render the claim as the JSON object embedded in a token payload.
+ public JsonObject ToJsonObject() => new()
+ {
+ ["approver"] = Approver,
+ ["s256"] = S256,
+ };
+
+ ///
+ /// Parse a mission claim from a token payload object. Returns
+ /// when the claim is absent or malformed.
+ ///
+ public static MissionClaim? FromPayload(JsonObject? payload)
+ {
+ if (payload?["mission"] is not JsonObject mission)
+ return null;
+
+ var approver = (string?)mission["approver"];
+ var s256 = (string?)mission["s256"];
+ if (string.IsNullOrEmpty(approver) || string.IsNullOrEmpty(s256))
+ return null;
+
+ return new MissionClaim(approver, s256);
+ }
+}
diff --git a/src/AAuth/Tokens/ResourceTokenBuilder.cs b/src/AAuth/Tokens/ResourceTokenBuilder.cs
index 519c491..fdada25 100644
--- a/src/AAuth/Tokens/ResourceTokenBuilder.cs
+++ b/src/AAuth/Tokens/ResourceTokenBuilder.cs
@@ -47,6 +47,13 @@ public sealed class ResourceTokenBuilder
/// Requested scopes, space-separated (scope).
public string? Scope { get; init; }
+ ///
+ /// Mission claim (mission) — present when the resource is mission-aware
+ /// and the agent sent an AAuth-Mission header (§Resource Token Structure).
+ /// Carries only approver and s256; the mission content stays at the PS.
+ ///
+ public MissionClaim? Mission { get; init; }
+
/// Lifetime; spec says SHOULD NOT exceed 5 minutes. Default 5 minutes.
public TimeSpan Lifetime { get; init; } = TimeSpan.FromMinutes(5);
@@ -120,6 +127,11 @@ public string Build()
payload["scope"] = Scope;
}
+ if (Mission is not null)
+ {
+ payload["mission"] = Mission.ToJsonObject();
+ }
+
return JwtWriter.SignCompact(header, payload, Key);
}
diff --git a/src/AAuth/Tokens/TokenVerifier.cs b/src/AAuth/Tokens/TokenVerifier.cs
index 16d69f7..5ec9e2f 100644
--- a/src/AAuth/Tokens/TokenVerifier.cs
+++ b/src/AAuth/Tokens/TokenVerifier.cs
@@ -33,7 +33,14 @@ public sealed record VerifiedToken(
JsonObject Header,
JsonObject Payload,
string Issuer,
- string TokenType);
+ string TokenType)
+ {
+ ///
+ /// The mission claim ({approver, s256}) when present, otherwise
+ /// (§Resource Token, §Auth Token).
+ ///
+ public MissionClaim? Mission => MissionClaim.FromPayload(Payload);
+ }
///
/// Verify a token whose issuer's public key has already been resolved.
diff --git a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs
index 81b0f9c..9c45e65 100644
--- a/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs
+++ b/tests/AAuth.Conformance/HttpSignatures/CapabilitiesHeaderTests.cs
@@ -1,4 +1,5 @@
using System;
+using System.Linq;
using System.Threading.Tasks;
using AAuth.Agent;
using AAuth.Crypto;
@@ -8,18 +9,18 @@
namespace AAuth.Conformance.HttpSignatures;
///
-/// Conformance tests for AAuth-Capabilities header (§14.1).
+/// Conformance tests for AAuth-Capabilities header (§AAuth-Capabilities).
///
public class CapabilitiesHeaderTests
{
- [Fact(DisplayName = "§14.1 — AAuth-Capabilities header formats correctly")]
+ [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities header formats correctly")]
public void Format_MultipleCapabilities()
{
var value = AAuthCapabilitiesHeader.Format("interaction", "clarification", "payment");
Assert.Equal("interaction, clarification, payment", value);
}
- [Fact(DisplayName = "§14.1 — AAuth-Capabilities header parses correctly")]
+ [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities header parses correctly")]
public void Parse_MultipleCapabilities()
{
var caps = AAuthCapabilitiesHeader.Parse("interaction, clarification, payment");
@@ -29,14 +30,42 @@ public void Parse_MultipleCapabilities()
Assert.Contains("payment", caps);
}
- [Fact(DisplayName = "§14.1 — AAuth-Capabilities empty value parses to empty list")]
+ [Fact(DisplayName = "§AAuth-Capabilities — AAuth-Capabilities empty value parses to empty list")]
public void Parse_Empty()
{
var caps = AAuthCapabilitiesHeader.Parse("");
Assert.Empty(caps);
}
- [Fact(DisplayName = "§14.1 — AAuthSigningHandler emits Capabilities header when configured")]
+ [Fact(DisplayName = "§Mission Approval — Union merges mission and agent capabilities, mission first")]
+ public void Union_MergesMissionFirst()
+ {
+ var union = AAuthCapabilitiesHeader.Union(
+ missionCapabilities: new[] { "interaction", "payment" },
+ agentCapabilities: new[] { "clarification" });
+
+ Assert.Equal(new[] { "interaction", "payment", "clarification" }, union.ToArray());
+ }
+
+ [Fact(DisplayName = "§Mission Approval — Union deduplicates overlapping capabilities")]
+ public void Union_Deduplicates()
+ {
+ var union = AAuthCapabilitiesHeader.Union(
+ missionCapabilities: new[] { "interaction", "payment" },
+ agentCapabilities: new[] { "payment", "clarification" });
+
+ Assert.Equal(new[] { "interaction", "payment", "clarification" }, union.ToArray());
+ }
+
+ [Fact(DisplayName = "§Mission Approval — Union tolerates null sources")]
+ public void Union_ToleratesNulls()
+ {
+ Assert.Equal(new[] { "interaction" }, AAuthCapabilitiesHeader.Union(new[] { "interaction" }, null).ToArray());
+ Assert.Equal(new[] { "clarification" }, AAuthCapabilitiesHeader.Union(null, new[] { "clarification" }).ToArray());
+ Assert.Empty(AAuthCapabilitiesHeader.Union(null, null));
+ }
+
+ [Fact(DisplayName = "§AAuth-Capabilities — AAuthSigningHandler emits Capabilities header when configured")]
public void SigningHandler_EmitsCapabilities()
{
var key = AAuthKey.Generate();
@@ -66,7 +95,7 @@ public void SigningHandler_EmitsCapabilities()
Assert.Contains("mission", headerValue);
}
- [Fact(DisplayName = "§14.1 — AAuthSigningHandler does not emit Capabilities header when not configured")]
+ [Fact(DisplayName = "§AAuth-Capabilities — AAuthSigningHandler does not emit Capabilities header when not configured")]
public void SigningHandler_NoCapabilities_NoHeader()
{
var key = AAuthKey.Generate();
diff --git a/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs b/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs
index e1a3174..ab52afd 100644
--- a/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs
+++ b/tests/AAuth.Conformance/HttpSignatures/ChallengeMiddlewareTests.cs
@@ -7,6 +7,7 @@
using System.Threading.Tasks;
using AAuth.Crypto;
using AAuth;
+using AAuth.Agent;
using AAuth.Discovery;
using AAuth.Headers;
using AAuth.HttpSig;
@@ -389,4 +390,113 @@ private static byte[] Base64UrlDecode(string input)
}
return Convert.FromBase64String(padded);
}
+
+ // ── Mission-aware resource (§Terminology, §Missions) ─────────────────
+
+ private const string MissionApprover = PsIssuer;
+ private const string MissionS256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+ private async Task SendSignedWithMission(
+ IHost host, string token, string? missionHeader)
+ {
+ var capture = new CaptureHandler();
+ var provider = new JwtSignatureKeyProvider(() => token);
+ var handler = new AAuthSigningHandler(_agentKey, provider, () => FixedClock)
+ {
+ InnerHandler = capture,
+ };
+ using var client = new HttpClient(handler);
+ var outbound = new HttpRequestMessage(HttpMethod.Get, "http://localhost:5000/protected");
+ if (missionHeader is not null)
+ outbound.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, missionHeader);
+ await client.SendAsync(outbound);
+ var signed = capture.Captured!;
+
+ var relay = new HttpRequestMessage(HttpMethod.Get, "/protected");
+ foreach (var h in signed.Headers)
+ relay.Headers.TryAddWithoutValidation(h.Key, h.Value);
+ relay.Headers.Host = "localhost:5000";
+ return await host.GetTestClient().SendAsync(relay);
+ }
+
+ private static JsonObject DecodeResourceTokenPayload(HttpResponseMessage response)
+ {
+ var headerValue = string.Join(",", response.Headers.GetValues(AAuthRequirementHeader.Name));
+ var parsed = AAuthRequirementHeader.Parse(headerValue);
+ var parts = parsed.ResourceToken!.Split('.');
+ return JsonNode.Parse(Base64UrlDecode(parts[1]))!.AsObject();
+ }
+
+ [Fact(DisplayName = "§Missions — mission-aware resource copies AAuth-Mission into the resource token")]
+ public async Task MissionAwareResourceCopiesMissionClaim()
+ {
+ var host = await StartResourceServer(new ChallengeOptions
+ {
+ AccessMode = AAuthAccessMode.RequireAuthToken,
+ ResourceSigningKey = _resourceKey,
+ ResourceKeyId = ResourceKid,
+ ResourceIdentifier = ResourceId,
+ DefaultScopes = ResourceScope,
+ MissionAware = true,
+ });
+ try
+ {
+ var token = BuildAgentToken();
+ var missionHeader = AAuthMissionHeader.FormatStructured(MissionApprover, MissionS256);
+ var response = await SendSignedWithMission(host, token, missionHeader);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ var payload = DecodeResourceTokenPayload(response);
+ var mission = Assert.IsType(payload["mission"]);
+ Assert.Equal(MissionApprover, (string?)mission["approver"]);
+ Assert.Equal(MissionS256, (string?)mission["s256"]);
+ }
+ finally
+ {
+ await host.StopAsync();
+ host.Dispose();
+ }
+ }
+
+ [Fact(DisplayName = "§Missions — mission-aware resource omits the mission claim when no header is present")]
+ public async Task MissionAwareResourceOmitsMissionWhenHeaderAbsent()
+ {
+ var host = await StartResourceServer(new ChallengeOptions
+ {
+ AccessMode = AAuthAccessMode.RequireAuthToken,
+ ResourceSigningKey = _resourceKey,
+ ResourceKeyId = ResourceKid,
+ ResourceIdentifier = ResourceId,
+ DefaultScopes = ResourceScope,
+ MissionAware = true,
+ });
+ try
+ {
+ var token = BuildAgentToken();
+ var response = await SendSignedWithMission(host, token, missionHeader: null);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ var payload = DecodeResourceTokenPayload(response);
+ Assert.False(payload.ContainsKey("mission"));
+ }
+ finally
+ {
+ await host.StopAsync();
+ host.Dispose();
+ }
+ }
+
+ [Fact(DisplayName = "§Missions — non-mission-aware resource ignores the AAuth-Mission header")]
+ public async Task NonMissionAwareResourceIgnoresMissionHeader()
+ {
+ // _challengeHost is configured WITHOUT MissionAware — the mission header
+ // must be ignored (opt-in only), so no mission claim is emitted.
+ var token = BuildAgentToken();
+ var missionHeader = AAuthMissionHeader.FormatStructured(MissionApprover, MissionS256);
+ var response = await SendSignedWithMission(_challengeHost!, token, missionHeader);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ var payload = DecodeResourceTokenPayload(response);
+ Assert.False(payload.ContainsKey("mission"));
+ }
}
diff --git a/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs b/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs
new file mode 100644
index 0000000..b69a2ea
--- /dev/null
+++ b/tests/AAuth.Conformance/HttpSignatures/MissionSignedComponentTests.cs
@@ -0,0 +1,138 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Crypto;
+using AAuth.HttpSig;
+using Xunit;
+
+namespace AAuth.Conformance.HttpSignatures;
+
+///
+/// Conformance for covering the aauth-mission component when the agent
+/// operates in a mission context (§Authorization Endpoint Request — the agent
+/// includes the AAuth-Mission header and adds aauth-mission to the
+/// signed components).
+///
+public class MissionSignedComponentTests
+{
+ private const string Approver = "https://ps.example";
+ private const string S256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+ private sealed class CaptureHandler : HttpMessageHandler
+ {
+ public HttpRequestMessage? Captured { get; private set; }
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Captured = request;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+ }
+ }
+
+ private static async Task Sign(
+ AAuthKey key, string token, DateTimeOffset clock, string? missionHeader)
+ {
+ var capture = new CaptureHandler();
+ var pipeline = new AAuthSigningHandler(key, () => token, () => clock) { InnerHandler = capture };
+ using var client = new HttpClient(pipeline);
+ var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path");
+ if (missionHeader is not null)
+ {
+ request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, missionHeader);
+ }
+ await client.SendAsync(request);
+ return capture.Captured!;
+ }
+
+ [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission covered when AAuth-Mission present")]
+ public async Task SignatureInput_CoversMission_WhenHeaderPresent()
+ {
+ var key = AAuthKey.Generate();
+ var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
+ var mission = AAuthMissionHeader.FormatStructured(Approver, S256);
+
+ var req = await Sign(key, "a.b.c", clock, mission);
+ var input = req.Headers.GetValues("Signature-Input").Single();
+
+ Assert.Contains("\"aauth-mission\"", input);
+ // §spec example: aauth-mission is the last covered component, after signature-key.
+ Assert.EndsWith("\"signature-key\" \"aauth-mission\");created=" + clock.ToUnixTimeSeconds(), input);
+ }
+
+ [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission absent when no header")]
+ public async Task SignatureInput_OmitsMission_WhenHeaderAbsent()
+ {
+ var key = AAuthKey.Generate();
+ var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
+
+ var req = await Sign(key, "a.b.c", clock, missionHeader: null);
+ var input = req.Headers.GetValues("Signature-Input").Single();
+
+ Assert.DoesNotContain("aauth-mission", input);
+ }
+
+ [Fact(DisplayName = "§Authorization Endpoint Request — signed mission request round-trips through the verifier")]
+ public async Task SignedMissionRequest_VerifiesSuccessfully()
+ {
+ var key = AAuthKey.Generate();
+ var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
+ var mission = AAuthMissionHeader.FormatStructured(Approver, S256);
+
+ var req = await Sign(key, "a.b.c", clock, mission);
+
+ var verifier = new AAuthVerifier { Clock = () => clock };
+ verifier.Verify("GET", "r.example", "/path",
+ req.Headers.GetValues("Signature-Key").Single(),
+ req.Headers.GetValues("Signature-Input").Single(),
+ req.Headers.GetValues("Signature").Single(),
+ AAuthKey.FromJwk(key.ToPublicJwk()),
+ mission: req.Headers.GetValues(AAuthMissionHeader.Name).Single());
+ }
+
+ [Fact(DisplayName = "§Authorization Endpoint Request — verifier rejects when mission header present but uncovered")]
+ public async Task Verifier_Rejects_WhenMissionHeaderPresentButUncovered()
+ {
+ var key = AAuthKey.Generate();
+ var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
+
+ // Sign WITHOUT the mission header so aauth-mission is not covered...
+ var req = await Sign(key, "a.b.c", clock, missionHeader: null);
+
+ var verifier = new AAuthVerifier { Clock = () => clock };
+ // ...but present the mission header at verification time.
+ Assert.Throws(() =>
+ verifier.Verify("GET", "r.example", "/path",
+ req.Headers.GetValues("Signature-Key").Single(),
+ req.Headers.GetValues("Signature-Input").Single(),
+ req.Headers.GetValues("Signature").Single(),
+ AAuthKey.FromJwk(key.ToPublicJwk()),
+ mission: AAuthMissionHeader.FormatStructured(Approver, S256)));
+ }
+
+ [Fact(DisplayName = "§Authorization Endpoint Request — aauth-mission not double-covered when explicitly requested")]
+ public async Task SignatureInput_DoesNotDoubleCoverMission()
+ {
+ var key = AAuthKey.Generate();
+ var clock = new DateTimeOffset(2026, 5, 18, 12, 0, 0, TimeSpan.Zero);
+ var mission = AAuthMissionHeader.FormatStructured(Approver, S256);
+
+ var capture = new CaptureHandler();
+ var pipeline = new AAuthSigningHandler(key, () => "a.b.c", () => clock) { InnerHandler = capture };
+ using var client = new HttpClient(pipeline);
+ var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path");
+ request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, mission);
+ // Explicitly also request aauth-mission as an additional component.
+ request.Options.Set(
+ AAuthSigningHandler.AdditionalComponentsKey,
+ Array.AsReadOnly(new[] { "aauth-mission" }));
+ await client.SendAsync(request);
+
+ var input = capture.Captured!.Headers.GetValues("Signature-Input").Single();
+ var occurrences = input.Split("\"aauth-mission\"").Length - 1;
+ Assert.Equal(1, occurrences);
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs b/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs
new file mode 100644
index 0000000..763df14
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/ChallengeClarificationSeamTests.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Discovery;
+using AAuth.Headers;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the clarification seam on the embedded challenge exchange
+/// (, surfaced on the
+/// high-level builder as
+/// ).
+/// Per §Clarification Chat a PS MAY return 202 + requirement=clarification
+/// while resolving a resource-token exchange; an agent that wires the seam answers
+/// the question (respond / cancel) and the exchange resumes — all within the single
+/// signed request to the resource. This closes the gap where only the low-level
+/// could participate in clarification. The full
+/// builder path (WithChallengeHandling(o => o.OnClarificationRequired = ...))
+/// is exercised end-to-end by the SampleApp mission-call-chain Playwright spec.
+///
+public class ChallengeClarificationSeamTests
+{
+ private const string ResourceUrl = "https://r.example";
+ private const string Ps = "https://ps.example";
+
+ private static ChallengeHandler BuildChallengeHandler(
+ ClarifyingExchangeHandler exchangeHandler,
+ Func> onClarification,
+ Func? onInteraction = null)
+ {
+ var holder = new AAuthTokenHolder("initial-agent-token");
+ var metaClient = new MetadataClient(new HttpClient(exchangeHandler));
+ var exchangeClient = new TokenExchangeClient(new HttpClient(exchangeHandler), metaClient);
+
+ return new ChallengeHandler(
+ exchangeClient, holder,
+ personServer: Ps,
+ onInteractionRequired: onInteraction,
+ pollerOptions: null,
+ upstreamTokenProvider: null)
+ {
+ InnerHandler = new ChallengingResourceHandler(),
+ OnClarificationRequired = onClarification,
+ };
+ }
+
+ [Fact(DisplayName = "§Clarification Chat — the challenge seam answers a clarification then completes the exchange")]
+ public async Task ChallengeSeam_AnswersClarification_ThenRetriesTo200()
+ {
+ var exchangeHandler = new ClarifyingExchangeHandler();
+ ClarificationRequirement? seen = null;
+ var challenge = BuildChallengeHandler(exchangeHandler, (clarification, _) =>
+ {
+ seen = clarification;
+ return Task.FromResult(ClarificationResponse.Respond("Needed to summarize the inbox."));
+ });
+
+ using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) };
+ using var response = await client.GetAsync("/data");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.NotNull(seen);
+ Assert.Equal("Why does this mission need this access?", seen!.Clarification);
+ Assert.Equal("Needed to summarize the inbox.", exchangeHandler.LastClarificationResponse);
+ }
+
+ [Fact(DisplayName = "§Clarification Chat — the challenge seam surfaces a user interaction that follows the clarification")]
+ public async Task ChallengeSeam_ClarificationThenInteraction_SurfacesInteractionTo200()
+ {
+ var exchangeHandler = new ClarifyingExchangeHandler { EscalateToInteraction = true };
+ Interaction? surfaced = null;
+ var challenge = BuildChallengeHandler(
+ exchangeHandler,
+ (_, _) => Task.FromResult(ClarificationResponse.Respond("Needed to summarize the inbox.")),
+ (interaction, _) => { surfaced = interaction; return Task.CompletedTask; });
+
+ using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) };
+ using var response = await client.GetAsync("/data");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ // The clarification was answered AND the follow-on user-interaction gate
+ // was surfaced (a bare poll would have swallowed it).
+ Assert.Equal("Needed to summarize the inbox.", exchangeHandler.LastClarificationResponse);
+ Assert.NotNull(surfaced);
+ }
+
+ [Fact(DisplayName = "§Clarification Chat — the challenge seam declares the clarification capability")]
+ public async Task ChallengeSeam_DeclaresClarificationCapability()
+ {
+ var exchangeHandler = new ClarifyingExchangeHandler();
+ var challenge = BuildChallengeHandler(exchangeHandler, (_, _) =>
+ Task.FromResult(ClarificationResponse.Respond("ok")));
+
+ using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) };
+ using var response = await client.GetAsync("/data");
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Contains("clarification", exchangeHandler.DeclaredCapabilities);
+ }
+
+ [Fact(DisplayName = "§Cancel Request — the challenge seam can withdraw the request during clarification")]
+ public async Task ChallengeSeam_CancelDuringClarification_Throws()
+ {
+ var exchangeHandler = new ClarifyingExchangeHandler();
+ var challenge = BuildChallengeHandler(exchangeHandler, (_, _) =>
+ Task.FromResult(ClarificationResponse.Cancel()));
+
+ using var client = new HttpClient(challenge) { BaseAddress = new Uri(ResourceUrl) };
+
+ await Assert.ThrowsAsync(
+ () => client.GetAsync("/data"));
+ Assert.True(exchangeHandler.DeleteCalled);
+ }
+
+ /// Resource handler: 401 challenge first, 200 once an auth token is exchanged.
+ private sealed class ChallengingResourceHandler : HttpMessageHandler
+ {
+ private int _callCount;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ if (Interlocked.Increment(ref _callCount) == 1)
+ {
+ var challenge = new HttpResponseMessage(HttpStatusCode.Unauthorized);
+ challenge.Headers.TryAddWithoutValidation(
+ AAuthRequirementHeader.Name,
+ AAuthRequirementHeader.FormatAuthToken("fake-resource-token"));
+ return Task.FromResult(challenge);
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent("{\"ok\":true}", Encoding.UTF8, "application/json"),
+ });
+ }
+ }
+
+ ///
+ /// PS exchange mock: serves metadata, returns a single
+ /// 202 + requirement=clarification on the token request, then mints the
+ /// auth token once the agent answers on the pending URL.
+ ///
+ private sealed class ClarifyingExchangeHandler : HttpMessageHandler
+ {
+ public string? LastClarificationResponse { get; private set; }
+ public bool DeleteCalled { get; private set; }
+ public List DeclaredCapabilities { get; } = new();
+
+ /// When set, the PS moves to a user-interaction gate once the
+ /// clarification is answered (clarification then §User Interaction).
+ public bool EscalateToInteraction { get; init; }
+
+ private bool _answered;
+ private bool _interactionServed;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path.Contains("well-known"))
+ {
+ return Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["token_endpoint"] = Ps + "/token",
+ });
+ }
+
+ if (request.Method == HttpMethod.Delete)
+ {
+ DeleteCalled = true;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ if (path == "/token" && request.Method == HttpMethod.Post)
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ if (body?["capabilities"] is JsonArray caps)
+ {
+ foreach (var c in caps)
+ {
+ if ((string?)c is { } v) { DeclaredCapabilities.Add(v); }
+ }
+ }
+ return Clarify();
+ }
+
+ if (path == "/pending/abc" && request.Method == HttpMethod.Post)
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ if (body?["clarification_response"] is { } cr)
+ {
+ LastClarificationResponse = (string?)cr;
+ }
+ _answered = true;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ if (path == "/pending/abc" && request.Method == HttpMethod.Get)
+ {
+ if (!_answered) { return Clarify(); }
+ // After the answer, optionally escalate to a single user-interaction
+ // gate before minting the token (clarification then §User Interaction).
+ if (EscalateToInteraction && !_interactionServed)
+ {
+ _interactionServed = true;
+ return Interact();
+ }
+ return Json(HttpStatusCode.OK, new JsonObject { ["auth_token"] = "fake-auth-token" });
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+
+ private static HttpResponseMessage Clarify()
+ {
+ var response = Json(HttpStatusCode.Accepted, new JsonObject
+ {
+ ["status"] = "pending",
+ ["clarification"] = "Why does this mission need this access?",
+ ["timeout"] = 120,
+ });
+ response.Headers.Location = new Uri(Ps + "/pending/abc");
+ response.Headers.TryAddWithoutValidation(
+ AAuthRequirementHeader.Name, "requirement=clarification");
+ return response;
+ }
+
+ private static HttpResponseMessage Interact()
+ {
+ // Mirrors the real PS: the polled interaction 202 carries the
+ // requirement header but NO Location (the pending URL is unchanged).
+ var response = Json(HttpStatusCode.Accepted, new JsonObject { ["status"] = "pending" });
+ response.Headers.TryAddWithoutValidation(
+ AAuthRequirementHeader.Name,
+ Interaction.Format(Ps + "/interaction", "abc"));
+ return response;
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs b/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs
new file mode 100644
index 0000000..2982afb
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/ClarificationChatTests.cs
@@ -0,0 +1,253 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Discovery;
+using AAuth.Headers;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the agent side of the clarification chat (AAuth protocol
+/// §Clarification Chat, §Agent Response to Clarification, §Clarification Limits).
+///
+public class ClarificationChatTests
+{
+ private const string Ps = "http://localhost:5555";
+
+ private static TokenExchangeClient BuildClient(HttpMessageHandler handler)
+ {
+ var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) };
+ var metadata = new MetadataClient(new HttpClient(handler));
+ return new TokenExchangeClient(http, metadata);
+ }
+
+ [Fact(DisplayName = "§Clarification Required — requirement=clarification parsed into a typed model")]
+ public void ClarificationRequirement_ParsedFromResponse()
+ {
+ var parsed = AAuthRequirementHeader.Parse("requirement=clarification");
+ var body = new JsonObject
+ {
+ ["status"] = "pending",
+ ["clarification"] = "Why do you need write access to my calendar?",
+ ["timeout"] = 120,
+ ["options"] = new JsonArray { "read-only", "read-write" },
+ };
+
+ var clarification = ClarificationRequirement.FromResponse(parsed, body);
+
+ Assert.NotNull(clarification);
+ Assert.Equal("Why do you need write access to my calendar?", clarification!.Clarification);
+ Assert.Equal(120, clarification.TimeoutSeconds);
+ Assert.Equal(new[] { "read-only", "read-write" }, clarification.Options);
+ }
+
+ [Fact(DisplayName = "§Clarification Required — missing clarification field throws")]
+ public void ClarificationRequirement_MissingQuestion_Throws()
+ {
+ var parsed = AAuthRequirementHeader.Parse("requirement=clarification");
+ Assert.Throws(() =>
+ ClarificationRequirement.FromResponse(parsed, new JsonObject { ["status"] = "pending" }));
+ }
+
+ [Fact(DisplayName = "§Agent Response to Clarification — clarification_response POST then resume polling yields auth token")]
+ public async Task ClarificationResponse_PostThenPoll_ReturnsAuthToken()
+ {
+ var handler = new ClarificationHandler();
+ var client = BuildClient(handler);
+
+ ClarificationRequirement? seen = null;
+ var token = await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ OnClarificationRequired = (clarification, _) =>
+ {
+ seen = clarification;
+ return Task.FromResult(ClarificationResponse.Respond("I need to create a calendar invite."));
+ },
+ });
+
+ Assert.Equal("fake-auth-token", token);
+ Assert.NotNull(seen);
+ Assert.Equal("Why do you need write access?", seen!.Clarification);
+ Assert.Equal("I need to create a calendar invite.", handler.LastClarificationResponse);
+ }
+
+ [Fact(DisplayName = "§Agent Response to Clarification — updated resource_token POST replaces the request")]
+ public async Task ClarificationResponse_UpdatedRequest_PostsNewResourceToken()
+ {
+ var handler = new ClarificationHandler();
+ var client = BuildClient(handler);
+
+ var token = await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ OnClarificationRequired = (_, _) =>
+ Task.FromResult(ClarificationResponse.Update("reduced-resource-token", "Reduced to read-only.")),
+ });
+
+ Assert.Equal("fake-auth-token", token);
+ Assert.Equal("reduced-resource-token", handler.LastUpdatedResourceToken);
+ Assert.Equal("Reduced to read-only.", handler.LastUpdatedJustification);
+ }
+
+ [Fact(DisplayName = "§Cancel Request — DELETE withdraws and surfaces a cancelled exception")]
+ public async Task ClarificationResponse_Cancel_DeletesPendingUrl()
+ {
+ var handler = new ClarificationHandler();
+ var client = BuildClient(handler);
+
+ await Assert.ThrowsAsync(() =>
+ client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ OnClarificationRequired = (_, _) => Task.FromResult(ClarificationResponse.Cancel()),
+ }));
+
+ Assert.True(handler.DeleteCalled);
+ }
+
+ [Fact(DisplayName = "§Clarification — missing callback throws when PS asks for clarification")]
+ public async Task Clarification_NoCallback_Throws()
+ {
+ var handler = new ClarificationHandler();
+ var client = BuildClient(handler);
+
+ await Assert.ThrowsAsync(() =>
+ client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest()));
+ }
+
+ [Fact(DisplayName = "§Clarification Limits — exceeding the round limit throws")]
+ public async Task Clarification_RoundLimit_Enforced()
+ {
+ // PS keeps asking for clarification forever.
+ var handler = new ClarificationHandler { AlwaysClarify = true };
+ var client = BuildClient(handler);
+
+ await Assert.ThrowsAsync(() =>
+ client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ MaxClarificationRounds = 2,
+ OnClarificationRequired = (_, _) =>
+ Task.FromResult(ClarificationResponse.Respond("Still need it.")),
+ }));
+ }
+
+ [Fact(DisplayName = "§Clarification — agent declares the clarification capability")]
+ public async Task Clarification_CapabilityDeclared()
+ {
+ var handler = new ClarificationHandler();
+ var client = BuildClient(handler);
+
+ await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ OnClarificationRequired = (_, _) =>
+ Task.FromResult(ClarificationResponse.Respond("ok")),
+ });
+
+ Assert.Contains("clarification", handler.DeclaredCapabilities);
+ }
+
+ ///
+ /// Stateful PS mock: first POST /token returns a clarification 202; after the
+ /// agent answers on the pending URL, a GET returns the auth token. With
+ /// , every poll returns a new clarification 202.
+ ///
+ private sealed class ClarificationHandler : HttpMessageHandler
+ {
+ public bool AlwaysClarify { get; init; }
+ public string? LastClarificationResponse { get; private set; }
+ public string? LastUpdatedResourceToken { get; private set; }
+ public string? LastUpdatedJustification { get; private set; }
+ public bool DeleteCalled { get; private set; }
+ public List DeclaredCapabilities { get; } = new();
+
+ private bool _answered;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path == "/.well-known/aauth-person.json")
+ {
+ return Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["token_endpoint"] = Ps + "/token",
+ });
+ }
+
+ if (request.Method == HttpMethod.Delete)
+ {
+ DeleteCalled = true;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ if (path == "/token" && request.Method == HttpMethod.Post)
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ if (body?["capabilities"] is JsonArray caps)
+ {
+ foreach (var c in caps)
+ {
+ var v = (string?)c;
+ if (v is not null) { DeclaredCapabilities.Add(v); }
+ }
+ }
+ return Clarify();
+ }
+
+ if (path == "/pending/abc" && request.Method == HttpMethod.Post)
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ if (body?["clarification_response"] is { } cr)
+ {
+ LastClarificationResponse = (string?)cr;
+ }
+ if (body?["resource_token"] is { } rt)
+ {
+ LastUpdatedResourceToken = (string?)rt;
+ LastUpdatedJustification = (string?)body["justification"];
+ }
+ _answered = true;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ if (path == "/pending/abc" && request.Method == HttpMethod.Get)
+ {
+ if (AlwaysClarify)
+ {
+ return Clarify();
+ }
+ return _answered
+ ? Json(HttpStatusCode.OK, new JsonObject { ["auth_token"] = "fake-auth-token" })
+ : Clarify();
+ }
+
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+
+ private static HttpResponseMessage Clarify()
+ {
+ var response = Json(HttpStatusCode.Accepted, new JsonObject
+ {
+ ["status"] = "pending",
+ ["clarification"] = "Why do you need write access?",
+ ["timeout"] = 120,
+ });
+ response.Headers.Location = new Uri(Ps + "/pending/abc");
+ response.Headers.TryAddWithoutValidation(AAuthRequirementHeader.Name, "requirement=clarification");
+ return response;
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs
new file mode 100644
index 0000000..d0c7609
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs
@@ -0,0 +1,245 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Crypto;
+using AAuth.Discovery;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the bound governance client and
+/// (AAuth protocol §Mission Creation, §Mission Approval, §Permission Endpoint,
+/// §Audit Endpoint, §Interaction Endpoint). A client bound to a Person Server
+/// exposes , which returns a
+/// session that auto-threads the mission claim ({approver, s256}) and PS
+/// into every subsequent governed call.
+///
+public class GovernanceClientBuilderTests
+{
+ private const string Ps = "http://localhost:5557";
+
+ private static AAuthGovernanceClient BuildBound(SessionHandler handler)
+ => AAuthGovernanceClient.Create(
+ new HttpClient(handler) { BaseAddress = new Uri(Ps) },
+ new MetadataClient(new HttpClient(handler)),
+ personServer: Ps);
+
+ [Fact(DisplayName = "§Mission Creation — Create factory binds the Person Server")]
+ public void Create_BindsPersonServer()
+ {
+ var client = BuildBound(new SessionHandler());
+ Assert.Equal(Ps, client.PersonServer);
+ }
+
+ [Fact(DisplayName = "§Mission Creation — BuildGovernance binds WithPersonServer URL")]
+ public void BuildGovernance_BindsPersonServer()
+ {
+ var client = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseHwk()
+ .WithPersonServer(Ps)
+ .WithInnerHandler(new SessionHandler())
+ .BuildGovernance();
+
+ Assert.Equal(Ps, client.PersonServer);
+ }
+
+ [Fact(DisplayName = "§Mission Creation — the client cannot be constructed without a Person Server")]
+ public void Ctor_MissingPersonServer_Throws()
+ {
+ Assert.Throws(() => new AAuthGovernanceClient(
+ new HttpClient(new SessionHandler()) { BaseAddress = new Uri(Ps) },
+ new MetadataClient(new HttpClient(new SessionHandler())),
+ personServer: ""));
+ }
+
+ [Fact(DisplayName = "§Mission Approval — ProposeMissionAsync returns a session over the approved mission")]
+ public async Task ProposeMissionAsync_ReturnsSession()
+ {
+ var client = BuildBound(new SessionHandler());
+
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")
+ {
+ Tools = new[] { new MissionTool("WebSearch", "Search the web") },
+ });
+
+ Assert.Equal(Ps, session.PersonServer);
+ Assert.Equal("aauth:assistant@agent.example", session.Mission.Agent);
+ Assert.NotEmpty(session.Mission.S256);
+ }
+
+ [Fact(DisplayName = "§Permission Endpoint — session threads the mission claim into permission requests")]
+ public async Task Session_Permission_ThreadsMissionClaim()
+ {
+ var handler = new SessionHandler();
+ var client = BuildBound(handler);
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
+
+ var result = await session.RequestPermissionAsync(new MissionAction("SendEmail"));
+
+ Assert.True(result.IsGranted);
+ Assert.Equal(session.Mission.S256, (string?)handler.LastPermissionBody?["mission"]?["s256"]);
+ Assert.Equal(Ps, (string?)handler.LastPermissionBody?["mission"]?["approver"]);
+ }
+
+ [Fact(DisplayName = "§Permission Endpoint — a pre-approved tool short-circuits to granted")]
+ public async Task Session_Permission_PreApprovedTool_ShortCircuits()
+ {
+ var handler = new SessionHandler();
+ var client = BuildBound(handler);
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip")
+ {
+ Tools = new[] { new MissionTool("WebSearch", "Search the web") },
+ });
+
+ var result = await session.RequestPermissionAsync(new MissionAction("WebSearch"));
+
+ Assert.True(result.IsGranted);
+ // Pre-approved tools never reach the PS permission endpoint.
+ Assert.Null(handler.LastPermissionBody);
+ }
+
+ [Fact(DisplayName = "§Audit Endpoint — session threads the mission claim into audit records")]
+ public async Task Session_Audit_ThreadsMissionClaim()
+ {
+ var handler = new SessionHandler();
+ var client = BuildBound(handler);
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
+
+ await session.RecordAuditAsync(new MissionAction("WebSearch"), description: "Looked up flights");
+
+ Assert.Equal(session.Mission.S256, (string?)handler.LastAuditBody?["mission"]?["s256"]);
+ Assert.Equal("WebSearch", (string?)handler.LastAuditBody?["action"]);
+ }
+
+ [Fact(DisplayName = "§Interaction Endpoint — session asks a question and returns the answer")]
+ public async Task Session_AskQuestion_ReturnsAnswer()
+ {
+ var handler = new SessionHandler();
+ var client = BuildBound(handler);
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
+
+ var answer = await session.AskQuestionAsync("Refundable option?");
+
+ Assert.Equal("Yes, go ahead.", answer);
+ Assert.Equal(session.Mission.S256, (string?)handler.LastInteractionBody?["mission"]?["s256"]);
+ }
+
+ [Fact(DisplayName = "§Interaction Endpoint — session proposes completion and observes termination")]
+ public async Task Session_ProposeCompletion_Terminates()
+ {
+ var handler = new SessionHandler();
+ var client = BuildBound(handler);
+ var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
+
+ var terminated = await session.ProposeCompletionAsync("All booked.");
+
+ Assert.True(terminated);
+ Assert.Equal("completion", (string?)handler.LastInteractionBody?["type"]);
+ }
+
+ /// PS mock that serves the governance endpoints and captures request bodies.
+ private sealed class SessionHandler : HttpMessageHandler
+ {
+ public JsonObject? LastPermissionBody { get; private set; }
+ public JsonObject? LastAuditBody { get; private set; }
+ public JsonObject? LastInteractionBody { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path == "/.well-known/aauth-person.json")
+ {
+ return Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["jwks_uri"] = Ps + "/jwks",
+ ["token_endpoint"] = Ps + "/token",
+ ["mission_endpoint"] = Ps + "/mission",
+ ["permission_endpoint"] = Ps + "/permission",
+ ["audit_endpoint"] = Ps + "/audit",
+ ["interaction_endpoint"] = Ps + "/interaction",
+ });
+ }
+
+ switch (path)
+ {
+ case "/mission":
+ {
+ var proposal = await ReadBody(request, ct);
+ var description = (string?)proposal?["description"] ?? "# Mission";
+ var tools = proposal?["tools"] as JsonArray ?? new JsonArray();
+ var blob = new JsonObject
+ {
+ ["approver"] = Ps,
+ ["agent"] = "aauth:assistant@agent.example",
+ ["approved_at"] = "2026-04-07T14:30:00Z",
+ ["description"] = description,
+ ["approved_tools"] = tools.DeepClone(),
+ };
+ var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString());
+ var s256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes));
+ var resp = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(bytes),
+ };
+ resp.Content.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+ resp.Headers.TryAddWithoutValidation(
+ "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{s256}\"");
+ return resp;
+ }
+
+ case "/permission":
+ LastPermissionBody = await ReadBody(request, ct);
+ return Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" });
+
+ case "/audit":
+ LastAuditBody = await ReadBody(request, ct);
+ return new HttpResponseMessage(HttpStatusCode.Created);
+
+ case "/interaction":
+ {
+ LastInteractionBody = await ReadBody(request, ct);
+ var type = (string?)LastInteractionBody?["type"];
+ return type switch
+ {
+ "question" => Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." }),
+ "completion" => Json(HttpStatusCode.OK, new JsonObject { ["mission_status"] = "terminated" }),
+ _ => new HttpResponseMessage(HttpStatusCode.OK),
+ };
+ }
+
+ default:
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ }
+
+ private static async Task ReadBody(HttpRequestMessage request, CancellationToken ct)
+ {
+ if (request.Content is null)
+ {
+ return null;
+ }
+ var raw = await request.Content.ReadAsStringAsync(ct);
+ return string.IsNullOrWhiteSpace(raw) ? null : JsonNode.Parse(raw) as JsonObject;
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs
new file mode 100644
index 0000000..fd63641
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs
@@ -0,0 +1,390 @@
+using System;
+using System.Collections.Generic;
+using System.Net;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Discovery;
+using AAuth.Errors;
+using AAuth.Headers;
+using AAuth.Tokens;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the agent-side PS governance clients (AAuth protocol
+/// §PS Governance Endpoints, §Mission Creation, §Permission Endpoint,
+/// §Audit Endpoint, §Interaction Endpoint, §Person Server Metadata).
+///
+public class GovernanceClientTests
+{
+ private const string Ps = "http://localhost:5555";
+
+ private static readonly MissionClaim TestMission =
+ new("http://localhost:5555", "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
+
+ private static (HttpClient signed, MetadataClient metadata) Build(HttpMessageHandler handler)
+ => (new HttpClient(handler) { BaseAddress = new Uri(Ps) },
+ new MetadataClient(new HttpClient(handler)));
+
+ // ---- §Person Server Metadata ----
+
+ [Fact(DisplayName = "§Person Server Metadata — all four governance endpoints are parsed")]
+ public void ServerMetadata_ParsesGovernanceEndpoints()
+ {
+ var doc = new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["jwks_uri"] = Ps + "/jwks",
+ ["token_endpoint"] = Ps + "/token",
+ ["mission_endpoint"] = Ps + "/mission",
+ ["permission_endpoint"] = Ps + "/permission",
+ ["audit_endpoint"] = Ps + "/audit",
+ ["interaction_endpoint"] = Ps + "/interaction",
+ };
+
+ var metadata = ServerMetadata.FromJson(doc);
+
+ Assert.Equal(Ps + "/mission", metadata.MissionEndpoint);
+ Assert.Equal(Ps + "/permission", metadata.PermissionEndpoint);
+ Assert.Equal(Ps + "/audit", metadata.AuditEndpoint);
+ Assert.Equal(Ps + "/interaction", metadata.InteractionEndpoint);
+ }
+
+ // ---- §Mission Creation / §Mission Approval ----
+
+ [Fact(DisplayName = "§Mission Approval — ProposeAsync returns an approved mission and verifies s256")]
+ public async Task MissionClient_Propose_ReturnsApprovedMission()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new MissionClient(signed, metadata, Ps);
+
+ var mission = await client.ProposeAsync(new MissionProposal("# Plan a trip")
+ {
+ Tools = new[] { new MissionTool("WebSearch", "Search the web") },
+ });
+
+ Assert.Equal("aauth:assistant@agent.example", mission.Agent);
+ Assert.Single(mission.ApprovedTools);
+ Assert.Equal("WebSearch", mission.ApprovedTools[0].Name);
+ Assert.True(mission.VerifyS256(handler.MissionS256));
+ }
+
+ [Fact(DisplayName = "§Mission Approval — s256 header mismatch throws")]
+ public async Task MissionClient_S256Mismatch_Throws()
+ {
+ var handler = new GovernanceHandler { TamperMissionHeaderS256 = true };
+ var (signed, metadata) = Build(handler);
+ var client = new MissionClient(signed, metadata, Ps);
+
+ await Assert.ThrowsAsync(() =>
+ client.ProposeAsync(new MissionProposal("# Plan a trip")));
+ }
+
+ [Fact(DisplayName = "§Mission Creation — 202 clarification review resolves to an approved mission")]
+ public async Task MissionClient_ClarificationReview_ResolvesToMission()
+ {
+ var handler = new GovernanceHandler { MissionNeedsClarification = true };
+ var (signed, metadata) = Build(handler);
+ var client = new MissionClient(signed, metadata, Ps);
+
+ ClarificationRequirement? seen = null;
+ var mission = await client.ProposeAsync(new MissionProposal("# Plan a trip"),
+ new GovernanceOptions
+ {
+ OnClarificationRequired = (clarification, _) =>
+ {
+ seen = clarification;
+ return Task.FromResult(ClarificationResponse.Respond("2 adults, $5k budget."));
+ },
+ });
+
+ Assert.NotNull(seen);
+ Assert.Equal("aauth:assistant@agent.example", mission.Agent);
+ Assert.Equal("2 adults, $5k budget.", handler.LastClarificationResponse);
+ }
+
+ // ---- §Permission Endpoint ----
+
+ [Fact(DisplayName = "§Permission Response — granted is parsed")]
+ public async Task PermissionClient_Granted()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new PermissionClient(signed, metadata, Ps);
+
+ var result = await client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail"))
+ {
+ Description = "Send the itinerary",
+ Mission = TestMission,
+ });
+
+ Assert.True(result.IsGranted);
+ }
+
+ [Fact(DisplayName = "§Permission Response — denied carries a reason")]
+ public async Task PermissionClient_Denied()
+ {
+ var handler = new GovernanceHandler { PermissionDenied = true };
+ var (signed, metadata) = Build(handler);
+ var client = new PermissionClient(signed, metadata, Ps);
+
+ var result = await client.RequestAsync(new PermissionRequest(new MissionAction("DeleteAll")));
+
+ Assert.Equal(PermissionGrant.Denied, result.Grant);
+ Assert.Equal("Out of scope.", result.Reason);
+ }
+
+ [Fact(DisplayName = "§Permission Endpoint — approved_tools short-circuit avoids the PS call")]
+ public async Task PermissionClient_ApprovedTool_ShortCircuits()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new PermissionClient(signed, metadata, Ps);
+
+ var mission = new Mission
+ {
+ Approver = Ps,
+ Agent = "aauth:assistant@agent.example",
+ ApprovedAt = DateTimeOffset.UtcNow,
+ Description = "x",
+ S256 = "abc",
+ ApprovedTools = new[] { new MissionTool("WebSearch") },
+ };
+
+ var result = await client.RequestAsync(new MissionAction("WebSearch"), mission);
+
+ Assert.True(result.IsGranted);
+ Assert.False(handler.PermissionCalled);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — permission 403 mission_terminated throws")]
+ public async Task PermissionClient_MissionTerminated_Throws()
+ {
+ var handler = new GovernanceHandler { MissionTerminated = true };
+ var (signed, metadata) = Build(handler);
+ var client = new PermissionClient(signed, metadata, Ps);
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission }));
+
+ Assert.Equal("terminated", ex.MissionStatus);
+ }
+
+ // ---- §Audit Endpoint ----
+
+ [Fact(DisplayName = "§Audit Response — 201 acknowledges the record")]
+ public async Task AuditClient_Records()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new AuditClient(signed, metadata, Ps);
+
+ await client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch"))
+ {
+ Description = "Searched for flights",
+ });
+
+ Assert.True(handler.AuditCalled);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — audit 403 mission_terminated throws")]
+ public async Task AuditClient_MissionTerminated_Throws()
+ {
+ var handler = new GovernanceHandler { MissionTerminated = true };
+ var (signed, metadata) = Build(handler);
+ var client = new AuditClient(signed, metadata, Ps);
+
+ await Assert.ThrowsAsync(() =>
+ client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch"))));
+ }
+
+ [Fact(DisplayName = "§Audit Response — a non-201 acknowledgment is rejected (F3)")]
+ public async Task AuditClient_Non201_Throws()
+ {
+ // The spec requires the PS to acknowledge with 201 Created; a 200 OK
+ // (or any other 2xx) must not be treated as success.
+ var handler = new GovernanceHandler { AuditStatus = HttpStatusCode.OK };
+ var (signed, metadata) = Build(handler);
+ var client = new AuditClient(signed, metadata, Ps);
+
+ await Assert.ThrowsAsync(() =>
+ client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch"))));
+ }
+
+ // ---- §Interaction Endpoint ----
+
+ [Fact(DisplayName = "§Interaction Response — question returns the user's answer")]
+ public async Task InteractionClient_Question_ReturnsAnswer()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new InteractionClient(signed, metadata, Ps);
+
+ var answer = await client.AskQuestionAsync("Refundable option?");
+
+ Assert.Equal("Yes, go ahead.", answer);
+ }
+
+ [Fact(DisplayName = "§Interaction Response — completion terminates the mission")]
+ public async Task InteractionClient_Completion_Terminates()
+ {
+ var handler = new GovernanceHandler();
+ var (signed, metadata) = Build(handler);
+ var client = new InteractionClient(signed, metadata, Ps);
+
+ var terminated = await client.ProposeCompletionAsync("# Done", TestMission);
+
+ Assert.True(terminated);
+ Assert.Equal("completion", handler.LastInteractionType);
+ }
+
+ /// Configurable PS mock for the governance endpoints.
+ private sealed class GovernanceHandler : HttpMessageHandler
+ {
+ public bool PermissionDenied { get; init; }
+ public bool MissionTerminated { get; init; }
+ public bool TamperMissionHeaderS256 { get; init; }
+ public bool MissionNeedsClarification { get; init; }
+ public HttpStatusCode AuditStatus { get; init; } = HttpStatusCode.Created;
+
+ public bool PermissionCalled { get; private set; }
+ public bool AuditCalled { get; private set; }
+ public string? LastInteractionType { get; private set; }
+ public string? LastClarificationResponse { get; private set; }
+ public string MissionS256 { get; private set; } = "";
+
+ private bool _missionClarified;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path == "/.well-known/aauth-person.json")
+ {
+ return Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["jwks_uri"] = Ps + "/jwks",
+ ["token_endpoint"] = Ps + "/token",
+ ["mission_endpoint"] = Ps + "/mission",
+ ["permission_endpoint"] = Ps + "/permission",
+ ["audit_endpoint"] = Ps + "/audit",
+ ["interaction_endpoint"] = Ps + "/interaction",
+ });
+ }
+
+ if (MissionTerminated)
+ {
+ return Json(HttpStatusCode.Forbidden, new JsonObject
+ {
+ ["error"] = "mission_terminated",
+ ["mission_status"] = "terminated",
+ });
+ }
+
+ // §Clarification Chat during mission review.
+ if (path == "/pending/m" && request.Method == HttpMethod.Post)
+ {
+ var crBody = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ LastClarificationResponse = (string?)crBody?["clarification_response"];
+ _missionClarified = true;
+ return new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ switch (path)
+ {
+ case "/mission" when MissionNeedsClarification && !_missionClarified:
+ case "/pending/m" when MissionNeedsClarification && !_missionClarified:
+ {
+ var clarify = Json(HttpStatusCode.Accepted, new JsonObject
+ {
+ ["status"] = "pending",
+ ["clarification"] = "How many travelers and what budget?",
+ });
+ clarify.Headers.Location = new Uri(Ps + "/pending/m");
+ clarify.Headers.TryAddWithoutValidation(
+ AAuth.Headers.AAuthRequirementHeader.Name, "requirement=clarification");
+ return clarify;
+ }
+
+ case "/mission":
+ case "/pending/m":
+ {
+ // Return the mission blob verbatim and the AAuth-Mission header
+ // whose s256 is SHA-256 over the exact body bytes (§Mission Approval).
+ var blob = new JsonObject
+ {
+ ["approver"] = Ps,
+ ["agent"] = "aauth:assistant@agent.example",
+ ["approved_at"] = "2026-04-07T14:30:00Z",
+ ["description"] = "# Plan a trip",
+ ["approved_tools"] = new JsonArray
+ {
+ new JsonObject { ["name"] = "WebSearch", ["description"] = "Search the web" },
+ },
+ };
+ var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString());
+ MissionS256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes));
+ var headerS256 = TamperMissionHeaderS256 ? "tampered-value" : MissionS256;
+
+ var resp = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(bytes),
+ };
+ resp.Content.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+ resp.Headers.TryAddWithoutValidation(
+ "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{headerS256}\"");
+ return resp;
+ }
+
+ case "/permission":
+ {
+ PermissionCalled = true;
+ return PermissionDenied
+ ? Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["permission"] = "denied",
+ ["reason"] = "Out of scope.",
+ })
+ : Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" });
+ }
+
+ case "/audit":
+ AuditCalled = true;
+ return new HttpResponseMessage(AuditStatus);
+
+ case "/interaction":
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ LastInteractionType = (string?)body?["type"];
+ return LastInteractionType switch
+ {
+ "question" => Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." }),
+ "completion" => new HttpResponseMessage(HttpStatusCode.OK),
+ _ => new HttpResponseMessage(HttpStatusCode.OK),
+ };
+ }
+
+ default:
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
new file mode 100644
index 0000000..9f9d504
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
@@ -0,0 +1,537 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Server.Governance;
+using AAuth.Server.Verification;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the governance mapper's mission-creation endpoint and
+/// deferred-consent (202 poll) flow (AAuth protocol §Mission Creation,
+/// §Mission Approval, §Deferred Consent). The mapper maps mission_endpoint
+/// via and, when AddAAuthDeferredConsent is
+/// called, resolves a Prompt outcome by parking the request and answering
+/// 202 with a poll Location.
+///
+public class GovernanceDeferredConsentMapperTests
+{
+ private const string Ps = "https://ps.example";
+ private const string Agent = "aauth:assistant@agent.example";
+
+ // Build a host with the mapper, a stub that marks every request as carrying a
+ // verified agent token, and the supplied governance seam overrides.
+ private static async Task BuildHostAsync(Action? configure = null)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAAuthGovernance();
+ builder.Services.AddRouting();
+ configure?.Invoke(builder.Services);
+
+ var app = builder.Build();
+
+ // Stand in for the verification middleware: present a verified agent token.
+ app.Use(async (ctx, next) =>
+ {
+ ctx.Features.Set(new AAuthVerificationResult
+ {
+ Level = AAuthLevel.Identified,
+ Scheme = "jwt",
+ TokenType = AAuthTokenType.AgentToken,
+ Agent = Agent,
+ });
+ await next();
+ });
+
+ app.MapAAuthGovernance(o =>
+ {
+ o.Approver = Ps;
+ o.InteractionUrl = Ps + "/interaction";
+ });
+
+ await app.StartAsync();
+ return app;
+ }
+
+ private static StringContent JsonContent(JsonObject body)
+ => new(body.ToJsonString(), Encoding.UTF8, "application/json");
+
+ private static async Task ReadJson(HttpResponseMessage response)
+ => JsonNode.Parse(await response.Content.ReadAsStringAsync()) as JsonObject;
+
+ [Fact(DisplayName = "§Mission Creation — the default approver approves and returns a verifiable blob")]
+ public async Task Mission_DefaultApprover_ReturnsApprovedBlob()
+ {
+ using var host = await BuildHostAsync();
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["description"] = "# Plan a trip",
+ ["tools"] = new JsonArray { new JsonObject { ["name"] = "WebSearch" } },
+ };
+ var response = await client.PostAsync("https://localhost/mission", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.True(response.Headers.Contains("AAuth-Mission"));
+
+ var bytes = await response.Content.ReadAsByteArrayAsync();
+ var mission = Mission.FromApprovalBytes(bytes);
+ Assert.Equal(Ps, mission.Approver);
+ Assert.Equal(Agent, mission.Agent);
+ Assert.Contains(mission.ApprovedTools, t => t.Name == "WebSearch");
+
+ // The mission is persisted and verifiable by its s256.
+ var store = host.Services.GetRequiredService();
+ var stored = await store.GetAsync(mission.S256);
+ Assert.NotNull(stored);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (403 invalid_carrier_token)")]
+ public async Task Mission_NoAgentToken_Unauthorized()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAAuthGovernance();
+ builder.Services.AddRouting();
+ var app = builder.Build();
+ app.MapAAuthGovernance(); // no agent-token stub middleware
+ await app.StartAsync();
+
+ using var client = app.GetTestServer().CreateClient();
+ var response = await client.PostAsync("https://localhost/mission",
+ JsonContent(new JsonObject { ["description"] = "# Plan a trip" }));
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ await app.StopAsync();
+ ((IDisposable)app).Dispose();
+ }
+
+ [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 denied")]
+ public async Task Mission_DecliningApprover_Forbidden()
+ {
+ using var host = await BuildHostAsync(s =>
+ s.AddSingleton(new StubApprover(MissionApprovalDecision.Decline("not now"))));
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/mission",
+ JsonContent(new JsonObject { ["description"] = "# Plan a trip" }));
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("denied", (string?)json?["error"]);
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Deferred Consent — a prompting approver parks the mission and answers 202 with a poll Location")]
+ public async Task Mission_Prompt_Parks202_ThenApprovalCompletes()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubApprover(MissionApprovalDecision.Defer()));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/mission",
+ JsonContent(new JsonObject { ["description"] = "# Plan a trip" }));
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ var location = response.Headers.Location!.ToString();
+ Assert.Contains("/governance-pending/", location);
+ Assert.True(response.Headers.Contains("AAuth-Requirement"));
+
+ // The user has not decided yet — the poll holds at 202.
+ using var pendingPoll = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode);
+
+ // The user approves at the PS consent page (resolve the parked entry).
+ var id = location[(location.LastIndexOf('/') + 1)..];
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: true);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ Assert.True(done.Headers.Contains("AAuth-Mission"));
+ var mission = Mission.FromApprovalBytes(await done.Content.ReadAsByteArrayAsync());
+ Assert.Equal(Agent, mission.Agent);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Deferred Consent — a declined mission poll resolves to 403 denied")]
+ public async Task Mission_Prompt_Declined_Forbidden()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubApprover(MissionApprovalDecision.Defer()));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/mission",
+ JsonContent(new JsonObject { ["description"] = "# Plan a trip" }));
+ var location = response.Headers.Location!.ToString();
+ var id = location[(location.LastIndexOf('/') + 1)..];
+
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: false);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.Forbidden, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("denied", (string?)json?["error"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Deferred Consent — a prompting permission parks and resolves to a granted decision")]
+ public async Task Permission_Prompt_Parks202_ThenGrant()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubDecider(
+ new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/permission",
+ JsonContent(new JsonObject { ["action"] = "SendEmail" }));
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ var location = response.Headers.Location!.ToString();
+ var id = location[(location.LastIndexOf('/') + 1)..];
+
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: true);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("granted", (string?)json?["permission"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Deferred Consent — a declined permission poll resolves to a denied decision (200, not denied)")]
+ public async Task Permission_Prompt_Declined_ReturnsDenied()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubDecider(
+ new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope)));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/permission",
+ JsonContent(new JsonObject { ["action"] = "SendEmail" }));
+ var location = response.Headers.Location!.ToString();
+ var id = location[(location.LastIndexOf('/') + 1)..];
+
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: false);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("denied", (string?)json?["permission"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Deferred Consent — without the store a prompting permission falls back to a denial")]
+ public async Task Permission_Prompt_NoStore_Denied()
+ {
+ using var host = await BuildHostAsync(s =>
+ s.AddSingleton(new StubDecider(
+ new PermissionDecision(PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope))));
+ using var client = host.GetTestServer().CreateClient();
+
+ var response = await client.PostAsync("https://localhost/permission",
+ JsonContent(new JsonObject { ["action"] = "SendEmail" }));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("denied", (string?)json?["permission"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — a pending interaction relay parks and answers 202 with a poll Location, then completes")]
+ public async Task Interaction_PendingRelay_Parks202_ThenCompletes()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true }));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "interaction",
+ ["url"] = "https://booking.example/confirm",
+ ["code"] = "X7K2-M9P4",
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ var location = response.Headers.Location!.ToString();
+ Assert.Contains("/governance-pending/", location);
+
+ // The user has not completed the interaction yet — the poll holds at 202.
+ using var pendingPoll = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode);
+
+ // The user completes the interaction at the resource's interaction URL.
+ var id = location[(location.LastIndexOf('/') + 1)..];
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: true);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("ok", (string?)json?["status"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — a pending payment relay also parks and answers 202")]
+ public async Task Interaction_PendingPayment_Parks202()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true }));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "payment",
+ ["url"] = "https://pay.example/checkout",
+ ["code"] = "PAY-9931",
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ Assert.Contains("/governance-pending/", response.Headers.Location!.ToString());
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — a non-pending interaction relay resolves synchronously (200, no poll)")]
+ public async Task Interaction_NotPending_Returns200()
+ {
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = false }));
+ });
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "interaction",
+ ["url"] = "https://booking.example/confirm",
+ ["code"] = "X7K2-M9P4",
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Null(response.Headers.Location);
+ var json = await ReadJson(response);
+ Assert.Equal("ok", (string?)json?["status"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — without the deferred store a pending relay falls back to a synchronous 200")]
+ public async Task Interaction_PendingRelay_NoStore_Returns200()
+ {
+ using var host = await BuildHostAsync(s =>
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true })));
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "interaction",
+ ["url"] = "https://booking.example/confirm",
+ ["code"] = "X7K2-M9P4",
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Null(response.Headers.Location);
+ var json = await ReadJson(response);
+ Assert.Equal("ok", (string?)json?["status"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — a completion relay reviewing asynchronously parks 202, then terminates the mission on accept")]
+ public async Task Completion_PendingRelay_Parks202_ThenTerminates()
+ {
+ const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true }));
+ });
+ var missionStore = host.Services.GetRequiredService();
+ await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 }));
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "completion",
+ ["summary"] = "# Booked the refundable option",
+ ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 },
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ // §Interaction Response: "The PS returns a deferred response while the user reviews."
+ Assert.Equal(HttpStatusCode.Accepted, response.StatusCode);
+ var location = response.Headers.Location!.ToString();
+ Assert.Contains("/governance-pending/", location);
+
+ // The mission stays active while the user reviews.
+ using var pendingPoll = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.Accepted, pendingPoll.StatusCode);
+ Assert.Equal(MissionState.Active, (await missionStore.GetAsync(s256))!.State);
+
+ // The user accepts the summary; the poll terminates the mission.
+ var id = location[(location.LastIndexOf('/') + 1)..];
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: true);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("terminated", (string?)json?["mission_status"]);
+ Assert.Equal(MissionState.Terminated, (await missionStore.GetAsync(s256))!.State);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — a reviewed completion the user does not accept keeps the mission active")]
+ public async Task Completion_PendingRelay_FollowUp_StaysActive()
+ {
+ const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ using var host = await BuildHostAsync(s =>
+ {
+ s.AddAAuthDeferredConsent();
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Pending = true }));
+ });
+ var missionStore = host.Services.GetRequiredService();
+ await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 }));
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "completion",
+ ["summary"] = "# Draft itinerary",
+ ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 },
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+ var location = response.Headers.Location!.ToString();
+
+ var id = location[(location.LastIndexOf('/') + 1)..];
+ var consent = host.Services.GetRequiredService();
+ await consent.ResolveAsync(id, approved: false);
+
+ using var done = await client.GetAsync("https://localhost" + location);
+ Assert.Equal(HttpStatusCode.OK, done.StatusCode);
+ var json = await ReadJson(done);
+ Assert.Equal("active", (string?)json?["mission_status"]);
+ Assert.Equal(MissionState.Active, (await missionStore.GetAsync(s256))!.State);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Response — without the deferred store a completion resolves synchronously off the relay's Accepted result")]
+ public async Task Completion_NoStore_ResolvesSynchronously()
+ {
+ const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ using var host = await BuildHostAsync(s =>
+ s.AddSingleton(new StubRelay(new InteractionRelayResult { Accepted = true })));
+ var missionStore = host.Services.GetRequiredService();
+ await missionStore.SaveAsync(new StoredMission(s256, Ps, Agent, new byte[] { 1, 2, 3 }));
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "completion",
+ ["summary"] = "# Done",
+ ["mission"] = new JsonObject { ["approver"] = Ps, ["s256"] = s256 },
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ Assert.Null(response.Headers.Location);
+ var json = await ReadJson(response);
+ Assert.Equal("terminated", (string?)json?["mission_status"]);
+ Assert.Equal(MissionState.Terminated, (await missionStore.GetAsync(s256))!.State);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction Endpoint — AddAAuthInteractionRelay wires a delegate relay used for a question")]
+ public async Task DelegateInteractionRelay_AnswersQuestion()
+ {
+ using var host = await BuildHostAsync(s =>
+ s.AddAAuthInteractionRelay((req, ct) =>
+ Task.FromResult(new InteractionRelayResult { Answer = "Yes, the refundable option." })));
+ using var client = host.GetTestServer().CreateClient();
+
+ var body = new JsonObject
+ {
+ ["type"] = "question",
+ ["question"] = "# Which option?",
+ };
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("Yes, the refundable option.", (string?)json?["answer"]);
+
+ await host.StopAsync();
+ }
+
+ private sealed class StubApprover(MissionApprovalDecision decision) : IMissionApprover
+ {
+ public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default)
+ => Task.FromResult(decision);
+ }
+
+ private sealed class StubDecider(PermissionDecision decision) : IPermissionDecider
+ {
+ public Task DecideAsync(PermissionDecisionContext context, CancellationToken ct = default)
+ => Task.FromResult(decision);
+ }
+
+ private sealed class StubRelay(InteractionRelayResult result) : IInteractionRelay
+ {
+ public Task RelayAsync(InteractionRequest request, CancellationToken ct = default)
+ => Task.FromResult(result);
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs
new file mode 100644
index 0000000..e2ad879
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceEndpointMapperTests.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Server.Governance;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the PS governance endpoint mapper
+/// (MapAAuthGovernance) over a real in-process host (AAuth protocol
+/// §Permission Endpoint, §Audit Endpoint, §Interaction Endpoint, §Mission Status
+/// Errors). The mapper drives the registered seams; AddAAuthGovernance
+/// supplies conservative defaults.
+///
+public class GovernanceEndpointMapperTests : IAsyncLifetime
+{
+ private const string Ps = "https://ps.example";
+ private const string Approver = Ps;
+
+ private IHost? _host;
+ private string _missionS256 = string.Empty;
+
+ public async Task InitializeAsync()
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+ builder.Services.AddAAuthGovernance();
+ builder.Services.AddRouting();
+
+ var app = builder.Build();
+ app.MapAAuthGovernance();
+
+ // Seed an active mission with one pre-approved tool ("WebSearch").
+ var store = app.Services.GetRequiredService();
+ var (blob, s256) = BuildMission("aauth:assistant@agent.example", "WebSearch");
+ _missionS256 = s256;
+ await store.SaveAsync(new StoredMission(s256, Approver, "aauth:assistant@agent.example", blob));
+
+ await app.StartAsync();
+ _host = app;
+ }
+
+ public async Task DisposeAsync()
+ {
+ if (_host is not null) { await _host.StopAsync(); _host.Dispose(); }
+ }
+
+ private HttpClient Client() => _host!.GetTestServer().CreateClient();
+
+ private JsonObject MissionClaim() => new()
+ {
+ ["approver"] = Approver,
+ ["s256"] = _missionS256,
+ };
+
+ [Fact(DisplayName = "§Permission Endpoint — a pre-approved tool is granted by the default decider")]
+ public async Task Permission_ApprovedTool_Granted()
+ {
+ using var client = Client();
+ var body = new JsonObject { ["action"] = "WebSearch", ["mission"] = MissionClaim() };
+
+ var response = await client.PostAsync("https://localhost/permission", JsonContent(body));
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("granted", (string?)json?["permission"]);
+ }
+
+ [Fact(DisplayName = "§Permission Endpoint — an out-of-scope action is denied (no user channel in the mapper)")]
+ public async Task Permission_OutOfScope_Denied()
+ {
+ using var client = Client();
+ var body = new JsonObject { ["action"] = "SendEmail", ["mission"] = MissionClaim() };
+
+ var response = await client.PostAsync("https://localhost/permission", JsonContent(body));
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("denied", (string?)json?["permission"]);
+ }
+
+ [Fact(DisplayName = "§Permission Endpoint — missing action is a 400")]
+ public async Task Permission_MissingAction_BadRequest()
+ {
+ using var client = Client();
+ var body = new JsonObject { ["mission"] = MissionClaim() };
+
+ var response = await client.PostAsync("https://localhost/permission", JsonContent(body));
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ }
+
+ [Fact(DisplayName = "§Audit Endpoint — a valid record is acknowledged with 201 Created")]
+ public async Task Audit_Valid_Created()
+ {
+ using var client = Client();
+ var body = new JsonObject { ["mission"] = MissionClaim(), ["action"] = "WebSearch" };
+
+ var response = await client.PostAsync("https://localhost/audit", JsonContent(body));
+ Assert.Equal(HttpStatusCode.Created, response.StatusCode);
+ }
+
+ [Fact(DisplayName = "§Interaction Endpoint — a question returns an answer field")]
+ public async Task Interaction_Question_ReturnsAnswer()
+ {
+ using var client = Client();
+ var body = new JsonObject
+ {
+ ["type"] = "question",
+ ["question"] = "Refundable?",
+ ["mission"] = MissionClaim(),
+ };
+
+ var response = await client.PostAsync("https://localhost/mission-interaction", JsonContent(body));
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.NotNull(json?["answer"]);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — permission on a terminated mission is 403 mission_terminated")]
+ public async Task Permission_TerminatedMission_Forbidden()
+ {
+ // Terminate the seeded mission, then request permission under it.
+ var store = _host!.Services.GetRequiredService();
+ await store.SetStateAsync(_missionS256, MissionState.Terminated);
+
+ using var client = Client();
+ var body = new JsonObject { ["action"] = "WebSearch", ["mission"] = MissionClaim() };
+
+ var response = await client.PostAsync("https://localhost/permission", JsonContent(body));
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var json = await ReadJson(response);
+ Assert.Equal("mission_terminated", (string?)json?["error"]);
+
+ // Restore active state so test ordering does not affect other cases.
+ await store.SetStateAsync(_missionS256, MissionState.Active);
+ }
+
+ private static (byte[] Blob, string S256) BuildMission(string agent, params string[] approvedTools)
+ {
+ var tools = new JsonArray();
+ foreach (var name in approvedTools)
+ {
+ tools.Add(new JsonObject { ["name"] = name });
+ }
+ var blob = new JsonObject
+ {
+ ["approver"] = Approver,
+ ["agent"] = agent,
+ ["approved_at"] = "2026-04-07T14:30:00Z",
+ ["description"] = "# Plan a trip",
+ ["approved_tools"] = tools,
+ };
+ var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString());
+ return (bytes, Mission.ComputeS256(bytes));
+ }
+
+ private static StringContent JsonContent(JsonObject body)
+ => new(body.ToJsonString(), Encoding.UTF8, "application/json");
+
+ private static async Task ReadJson(HttpResponseMessage response)
+ => JsonNode.Parse(await response.Content.ReadAsStringAsync()) as JsonObject;
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs
new file mode 100644
index 0000000..6232320
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs
@@ -0,0 +1,183 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Crypto;
+using AAuth.Discovery;
+using AAuth.Tokens;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the bundled governance facade (AAuth protocol
+/// §PS Governance Endpoints). exposes the
+/// mission / permission / audit / interaction clients over a single signed
+/// channel, and wires one from
+/// the same signed exchange pipeline used for token exchange.
+///
+public class GovernanceFacadeTests
+{
+ private const string Ps = "http://localhost:5555";
+
+ private static readonly MissionClaim TestMission =
+ new(Ps, "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
+
+ private static AAuthGovernanceClient BuildFacade(HttpMessageHandler handler)
+ => new(
+ new HttpClient(handler) { BaseAddress = new Uri(Ps) },
+ new MetadataClient(new HttpClient(handler)),
+ Ps);
+
+ [Fact(DisplayName = "§PS Governance Endpoints — facade exposes all four governance clients")]
+ public void Facade_Ctor_ExposesFourClients()
+ {
+ var facade = BuildFacade(new FacadeHandler());
+
+ Assert.NotNull(facade.Mission);
+ Assert.NotNull(facade.Permission);
+ Assert.NotNull(facade.Audit);
+ Assert.NotNull(facade.Interaction);
+ }
+
+ [Fact(DisplayName = "§PS Governance Endpoints — null signed client is rejected")]
+ public void Facade_Ctor_NullSignedClient_Throws()
+ {
+ Assert.Throws(() =>
+ new AAuthGovernanceClient(null!, new MetadataClient(new HttpClient()), Ps));
+ }
+
+ [Fact(DisplayName = "§PS Governance Endpoints — facade clients share one signed channel and work end-to-end")]
+ public async Task Facade_SubClients_AreFunctional()
+ {
+ var handler = new FacadeHandler();
+ var facade = BuildFacade(handler);
+
+ var mission = await facade.Mission.ProposeAsync(new MissionProposal("# Plan a trip")
+ {
+ Tools = new[] { new MissionTool("WebSearch", "Search the web") },
+ });
+ Assert.Equal("aauth:assistant@agent.example", mission.Agent);
+
+ var permission = await facade.Permission.RequestAsync(
+ new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission });
+ Assert.True(permission.IsGranted);
+
+ await facade.Audit.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch")));
+ Assert.True(handler.AuditCalled);
+
+ var answer = await facade.Interaction.AskQuestionAsync("Refundable option?");
+ Assert.Equal("Yes, go ahead.", answer);
+ }
+
+ [Fact(DisplayName = "§PS Governance Endpoints — BuildGovernance wires a facade from a signing mode")]
+ public void BuildGovernance_WithSigningMode_ReturnsWiredFacade()
+ {
+ var facade = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseHwk()
+ .WithPersonServer(Ps)
+ .WithInnerHandler(new FacadeHandler())
+ .BuildGovernance();
+
+ Assert.NotNull(facade.Mission);
+ Assert.NotNull(facade.Permission);
+ Assert.NotNull(facade.Audit);
+ Assert.NotNull(facade.Interaction);
+ }
+
+ [Fact(DisplayName = "§PS Governance Endpoints — BuildGovernance requires an explicit signing mode")]
+ public void BuildGovernance_NoSigningMode_Throws()
+ {
+ var builder = new AAuthClientBuilder(AAuthKey.Generate());
+
+ var ex = Assert.Throws(() => builder.BuildGovernance());
+ Assert.Contains("signing mode", ex.Message);
+ }
+
+ /// Minimal PS mock serving the governance endpoints for the facade.
+ private sealed class FacadeHandler : HttpMessageHandler
+ {
+ public bool AuditCalled { get; private set; }
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path == "/.well-known/aauth-person.json")
+ {
+ return Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["jwks_uri"] = Ps + "/jwks",
+ ["token_endpoint"] = Ps + "/token",
+ ["mission_endpoint"] = Ps + "/mission",
+ ["permission_endpoint"] = Ps + "/permission",
+ ["audit_endpoint"] = Ps + "/audit",
+ ["interaction_endpoint"] = Ps + "/interaction",
+ });
+ }
+
+ switch (path)
+ {
+ case "/mission":
+ {
+ var blob = new JsonObject
+ {
+ ["approver"] = Ps,
+ ["agent"] = "aauth:assistant@agent.example",
+ ["approved_at"] = "2026-04-07T14:30:00Z",
+ ["description"] = "# Plan a trip",
+ ["approved_tools"] = new JsonArray
+ {
+ new JsonObject { ["name"] = "WebSearch", ["description"] = "Search the web" },
+ },
+ };
+ var bytes = Encoding.UTF8.GetBytes(blob.ToJsonString());
+ var s256 = Base64UrlEncoder.Encode(SHA256.HashData(bytes));
+ var resp = new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new ByteArrayContent(bytes),
+ };
+ resp.Content.Headers.ContentType =
+ new System.Net.Http.Headers.MediaTypeHeaderValue("application/json");
+ resp.Headers.TryAddWithoutValidation(
+ "AAuth-Mission", $"approver=\"{Ps}\"; s256=\"{s256}\"");
+ return resp;
+ }
+
+ case "/permission":
+ return Json(HttpStatusCode.OK, new JsonObject { ["permission"] = "granted" });
+
+ case "/audit":
+ AuditCalled = true;
+ return new HttpResponseMessage(HttpStatusCode.Created);
+
+ case "/interaction":
+ {
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))?.AsObject();
+ return (string?)body?["type"] == "question"
+ ? Json(HttpStatusCode.OK, new JsonObject { ["answer"] = "Yes, go ahead." })
+ : new HttpResponseMessage(HttpStatusCode.OK);
+ }
+
+ default:
+ return new HttpResponseMessage(HttpStatusCode.NotFound);
+ }
+ }
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs
new file mode 100644
index 0000000..641ac90
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs
@@ -0,0 +1,229 @@
+using System;
+using System.Linq;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Server.Governance;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the PS-side governance seams (AAuth protocol §PS Governance
+/// Endpoints, §Mission Log, §Mission Status Errors): request parsers, the
+/// mission store, the ordered mission log with prior-consent lookup, and the
+/// mission_terminated response helper.
+///
+public class GovernanceServerTests
+{
+ private const string S256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+ // ---- §Request parsers ----
+
+ [Fact(DisplayName = "§Permission Request — parser maps JSON to PermissionRequest")]
+ public void ParsePermission_MapsFields()
+ {
+ var body = new JsonObject
+ {
+ ["action"] = "SendEmail",
+ ["description"] = "Send the itinerary",
+ ["parameters"] = new JsonObject { ["to"] = "user@example.com" },
+ ["mission"] = new JsonObject { ["approver"] = "https://ps.example", ["s256"] = S256 },
+ };
+
+ var request = GovernanceEndpoints.ParsePermission(body);
+
+ Assert.Equal("SendEmail", request.Action.Name);
+ Assert.Equal("Send the itinerary", request.Description);
+ Assert.Equal("user@example.com", (string?)request.Parameters!["to"]);
+ Assert.Equal(S256, request.Mission!.S256);
+ }
+
+ [Fact(DisplayName = "§Permission Request — missing action throws")]
+ public void ParsePermission_MissingAction_Throws()
+ => Assert.Throws(() =>
+ GovernanceEndpoints.ParsePermission(new JsonObject { ["description"] = "x" }));
+
+ [Fact(DisplayName = "§Audit Request — parser requires mission and action")]
+ public void ParseAudit_MapsFields()
+ {
+ var body = new JsonObject
+ {
+ ["mission"] = new JsonObject { ["approver"] = "https://ps.example", ["s256"] = S256 },
+ ["action"] = "WebSearch",
+ ["result"] = new JsonObject { ["status"] = "completed" },
+ };
+
+ var record = GovernanceEndpoints.ParseAudit(body);
+
+ Assert.Equal(S256, record.Mission.S256);
+ Assert.Equal("WebSearch", record.Action.Name);
+ Assert.Equal("completed", (string?)record.Result!["status"]);
+ }
+
+ [Fact(DisplayName = "§Audit Request — missing mission throws")]
+ public void ParseAudit_MissingMission_Throws()
+ => Assert.Throws(() =>
+ GovernanceEndpoints.ParseAudit(new JsonObject { ["action"] = "WebSearch" }));
+
+ [Theory(DisplayName = "§Interaction Request — parser maps each type")]
+ [InlineData("interaction", InteractionType.Interaction)]
+ [InlineData("payment", InteractionType.Payment)]
+ [InlineData("question", InteractionType.Question)]
+ [InlineData("completion", InteractionType.Completion)]
+ public void ParseInteraction_MapsType(string wire, InteractionType expected)
+ {
+ var request = GovernanceEndpoints.ParseInteraction(new JsonObject { ["type"] = wire });
+ Assert.Equal(expected, request.Type);
+ }
+
+ [Fact(DisplayName = "§Interaction Request — unknown type throws")]
+ public void ParseInteraction_UnknownType_Throws()
+ => Assert.Throws(() =>
+ GovernanceEndpoints.ParseInteraction(new JsonObject { ["type"] = "bogus" }));
+
+ [Fact(DisplayName = "§Mission Creation — proposal parser maps description and tools")]
+ public void ParseMissionProposal_MapsFields()
+ {
+ var body = new JsonObject
+ {
+ ["description"] = "# Plan a trip",
+ ["tools"] = new JsonArray
+ {
+ new JsonObject { ["name"] = "WebSearch", ["description"] = "Search" },
+ },
+ };
+
+ var proposal = GovernanceEndpoints.ParseMissionProposal(body);
+
+ Assert.Equal("# Plan a trip", proposal.Description);
+ Assert.Single(proposal.Tools);
+ Assert.Equal("WebSearch", proposal.Tools[0].Name);
+ }
+
+ // ---- §Mission Status Errors ----
+
+ [Fact(DisplayName = "§Mission Status Errors — helper emits the spec 403 body")]
+ public void MissionTerminatedBody_MatchesSpec()
+ {
+ var body = GovernanceEndpoints.MissionTerminatedBody();
+ Assert.Equal(403, GovernanceEndpoints.MissionTerminatedStatus);
+ Assert.Equal("mission_terminated", (string?)body["error"]);
+ Assert.Equal("terminated", (string?)body["mission_status"]);
+ }
+
+ // ---- §Mission Approval / §Mission Management (store) ----
+
+ [Fact(DisplayName = "§Mission store — stores verbatim blob bytes and state transitions")]
+ public async Task MissionStore_StoresBlobAndState()
+ {
+ var store = new InMemoryMissionStore();
+ var blob = System.Text.Encoding.UTF8.GetBytes("{\"approver\":\"https://ps.example\"}");
+ await store.SaveAsync(new StoredMission(S256, "https://ps.example", "aauth:a@x.example", blob));
+
+ var loaded = await store.GetAsync(S256);
+ Assert.NotNull(loaded);
+ Assert.Equal(MissionState.Active, loaded!.State);
+ Assert.True(blob.AsSpan().SequenceEqual(loaded.Blob.Span));
+
+ await store.SetStateAsync(S256, MissionState.Terminated);
+ var terminated = await store.GetAsync(S256);
+ Assert.Equal(MissionState.Terminated, terminated!.State);
+ }
+
+ [Fact(DisplayName = "§Mission store — absent mission returns null")]
+ public async Task MissionStore_Absent_ReturnsNull()
+ => Assert.Null(await new InMemoryMissionStore().GetAsync("nope"));
+
+ // ---- §Mission Log ----
+
+ [Fact(DisplayName = "§Mission Log — entries are appended and read in order")]
+ public async Task MissionLog_PreservesOrder()
+ {
+ var log = new InMemoryMissionLog();
+ var now = DateTimeOffset.UtcNow;
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, now) { Detail = "first" });
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Permission, now) { Detail = "second" });
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Audit, now) { Detail = "third" });
+
+ var entries = await log.ReadAsync(S256);
+
+ Assert.Equal(new[] { "first", "second", "third" }, entries.Select(e => e.Detail));
+ }
+
+ [Fact(DisplayName = "§Mission Log — prior consent keyed by (s256, resource, scope)")]
+ public async Task MissionLog_PriorConsent()
+ {
+ var log = new InMemoryMissionLog();
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow)
+ {
+ Resource = "https://calendar.example",
+ Scope = "read",
+ Granted = true,
+ });
+
+ Assert.True(await log.HasPriorConsentAsync(S256, "https://calendar.example", "read"));
+ Assert.False(await log.HasPriorConsentAsync(S256, "https://calendar.example", "write"));
+ Assert.False(await log.HasPriorConsentAsync(S256, "https://mail.example", "read"));
+ }
+
+ [Fact(DisplayName = "§Mission Log — a denied token entry does not count as prior consent")]
+ public async Task MissionLog_DeniedNotConsent()
+ {
+ var log = new InMemoryMissionLog();
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow)
+ {
+ Resource = "https://calendar.example",
+ Scope = "read",
+ Granted = false,
+ });
+
+ Assert.False(await log.HasPriorConsentAsync(S256, "https://calendar.example", "read"));
+ }
+
+ // ---- §Permission Endpoint (decider seam) ----
+
+ [Fact(DisplayName = "§Permission Endpoint — decider is invoked with mission + log context")]
+ public async Task PermissionDecider_ReceivesContext()
+ {
+ var store = new InMemoryMissionStore();
+ var log = new InMemoryMissionLog();
+ var blob = System.Text.Encoding.UTF8.GetBytes("{}");
+ await store.SaveAsync(new StoredMission(S256, "https://ps.example", "aauth:a@x.example", blob));
+ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateTimeOffset.UtcNow)
+ {
+ Resource = "https://calendar.example",
+ Scope = "read",
+ Granted = true,
+ });
+
+ var decider = new StubDecider();
+ var request = new PermissionRequest(new MissionAction("SendEmail"))
+ {
+ Mission = new AAuth.Tokens.MissionClaim("https://ps.example", S256),
+ };
+ var mission = await store.GetAsync(S256);
+ var entries = await log.ReadAsync(S256);
+
+ var decision = await decider.DecideAsync(new PermissionDecisionContext(request, mission, entries));
+
+ Assert.Equal(PermissionOutcome.Prompt, decision.Outcome);
+ Assert.Equal(PermissionDecisionReason.OutOfScope, decision.Reason);
+ Assert.Same(request, decider.LastContext!.Request);
+ Assert.Equal(S256, decider.LastContext.Mission!.S256);
+ Assert.Single(decider.LastContext.Log);
+ }
+
+ private sealed class StubDecider : IPermissionDecider
+ {
+ public PermissionDecisionContext? LastContext { get; private set; }
+
+ public Task DecideAsync(PermissionDecisionContext context, System.Threading.CancellationToken ct = default)
+ {
+ LastContext = context;
+ return Task.FromResult(new PermissionDecision(
+ PermissionOutcome.Prompt, PermissionDecisionReason.OutOfScope));
+ }
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/MissionClaimTests.cs b/tests/AAuth.Conformance/Missions/MissionClaimTests.cs
new file mode 100644
index 0000000..4da20a4
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/MissionClaimTests.cs
@@ -0,0 +1,149 @@
+using System.Text.Json.Nodes;
+using AAuth.Crypto;
+using AAuth.Tokens;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the optional mission claim ({approver, s256}) carried in
+/// resource and auth tokens (§Resource Token Structure, §Auth Token Structure).
+///
+public class MissionClaimTests
+{
+ private const string Iss = "https://resource.example";
+ private const string Aud = "https://ps.example";
+ private const string Agent = "aauth:alice@ap.example";
+ private const string Approver = "https://ps.example";
+ private const string S256 = "47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU";
+
+ private static JsonObject PayloadOf(string jwt)
+ {
+ var parts = jwt.Split('.');
+ return (JsonObject)JsonNode.Parse(Base64UrlEncoder.Decode(parts[1]))!;
+ }
+
+ [Fact(DisplayName = "§Resource Token Structure — mission omitted when not set")]
+ public void ResourceToken_OmitsMission_WhenNotSet()
+ {
+ var jwt = new ResourceTokenBuilder
+ {
+ Issuer = Iss,
+ Audience = Aud,
+ Agent = Agent,
+ AgentJkt = "thumb",
+ Key = AAuthKey.Generate(),
+ KeyId = "r1",
+ Scope = "whoami",
+ }.Build();
+
+ Assert.False(PayloadOf(jwt).ContainsKey("mission"));
+ }
+
+ [Fact(DisplayName = "§Resource Token Structure — mission emitted as {approver, s256} when set")]
+ public void ResourceToken_EmitsMission_WhenSet()
+ {
+ var jwt = new ResourceTokenBuilder
+ {
+ Issuer = Iss,
+ Audience = Aud,
+ Agent = Agent,
+ AgentJkt = "thumb",
+ Key = AAuthKey.Generate(),
+ KeyId = "r1",
+ Scope = "whoami",
+ Mission = new MissionClaim(Approver, S256),
+ }.Build();
+
+ var mission = PayloadOf(jwt)["mission"] as JsonObject;
+ Assert.NotNull(mission);
+ Assert.Equal(Approver, (string?)mission!["approver"]);
+ Assert.Equal(S256, (string?)mission["s256"]);
+ }
+
+ [Fact(DisplayName = "§Auth Token Structure — mission omitted when not set")]
+ public void AuthToken_OmitsMission_WhenNotSet()
+ {
+ var jwt = new AuthTokenBuilder
+ {
+ Issuer = Aud,
+ Audience = Iss,
+ Agent = Agent,
+ AgentConfirmationKey = AAuthKey.Generate(),
+ Key = AAuthKey.Generate(),
+ KeyId = "p1",
+ Scope = "whoami",
+ }.Build();
+
+ Assert.False(PayloadOf(jwt).ContainsKey("mission"));
+ }
+
+ [Fact(DisplayName = "§Auth Token Structure — mission emitted as {approver, s256} when set")]
+ public void AuthToken_EmitsMission_WhenSet()
+ {
+ var jwt = new AuthTokenBuilder
+ {
+ Issuer = Aud,
+ Audience = Iss,
+ Agent = Agent,
+ AgentConfirmationKey = AAuthKey.Generate(),
+ Key = AAuthKey.Generate(),
+ KeyId = "p1",
+ Scope = "whoami",
+ Mission = new MissionClaim(Approver, S256),
+ }.Build();
+
+ var mission = PayloadOf(jwt)["mission"] as JsonObject;
+ Assert.NotNull(mission);
+ Assert.Equal(Approver, (string?)mission!["approver"]);
+ Assert.Equal(S256, (string?)mission["s256"]);
+ }
+
+ [Fact(DisplayName = "§Auth Token Verification — VerifiedToken.Mission surfaces the verified claim")]
+ public void VerifiedToken_SurfacesMission()
+ {
+ var issuerKey = AAuthKey.Generate();
+ var agentKey = AAuthKey.Generate();
+ var jwt = new AuthTokenBuilder
+ {
+ Issuer = Aud,
+ Audience = Iss,
+ Agent = Agent,
+ AgentConfirmationKey = agentKey,
+ Key = issuerKey,
+ KeyId = "p1",
+ Scope = "whoami",
+ Mission = new MissionClaim(Approver, S256),
+ }.Build();
+
+ var verified = new TokenVerifier().VerifyAuthToken(
+ jwt, issuerKey, Iss, agentKey, Agent);
+
+ Assert.NotNull(verified.Mission);
+ Assert.Equal(Approver, verified.Mission!.Approver);
+ Assert.Equal(S256, verified.Mission.S256);
+ }
+
+ [Fact(DisplayName = "§Auth Token Verification — VerifiedToken.Mission is null when absent")]
+ public void VerifiedToken_MissionNull_WhenAbsent()
+ {
+ var issuerKey = AAuthKey.Generate();
+ var agentKey = AAuthKey.Generate();
+ var jwt = new AuthTokenBuilder
+ {
+ Issuer = Aud,
+ Audience = Iss,
+ Agent = Agent,
+ AgentConfirmationKey = agentKey,
+ Key = issuerKey,
+ KeyId = "p1",
+ Scope = "whoami",
+ }.Build();
+
+ var verified = new TokenVerifier().VerifyAuthToken(
+ jwt, issuerKey, Iss, agentKey, Agent);
+
+ Assert.Null(verified.Mission);
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs b/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs
new file mode 100644
index 0000000..97343cb
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/MissionHeaderSeamTests.cs
@@ -0,0 +1,122 @@
+using System;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Crypto;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the originating-agent mission seam
+/// ( /
+/// ). Per §Mission Context at Resources the
+/// agent "includes the AAuth-Mission header when sending requests to
+/// resources, unless the mission is already conveyed in an auth token", and per
+/// the HTTP Message Signatures section it "adds aauth-mission to the signed
+/// components". A client configured with WithMission(...) emits the header
+/// from the agent's own approved mission and the signing pipeline covers it.
+///
+public class MissionHeaderSeamTests
+{
+ private const string Approver = "https://ps.example";
+
+ private static Mission BuildMission()
+ {
+ var blob = new JsonObject
+ {
+ ["approver"] = Approver,
+ ["agent"] = "aauth:agent@example",
+ ["approved_at"] = "2026-06-06T00:00:00Z",
+ ["description"] = "Keep the inbox under control",
+ ["approved_tools"] = new JsonArray(),
+ }.ToJsonString();
+ return Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(blob));
+ }
+
+ private sealed class CaptureHandler : HttpMessageHandler
+ {
+ public HttpRequestMessage? Captured { get; private set; }
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ Captured = request;
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
+ }
+ }
+
+ [Fact(DisplayName = "§Mission Context at Resources — WithMission emits the AAuth-Mission header")]
+ public async Task WithMission_EmitsMissionHeader()
+ {
+ var mission = BuildMission();
+ var capture = new CaptureHandler();
+ using var client = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseJwt("a.b.c")
+ .WithMission(mission)
+ .WithInnerHandler(capture)
+ .Build();
+
+ await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"));
+
+ var value = capture.Captured!.Headers.GetValues(AAuthMissionHeader.Name).Single();
+ Assert.Equal(AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256), value);
+ }
+
+ [Fact(DisplayName = "§HTTP Message Signatures — the emitted mission header is covered as aauth-mission")]
+ public async Task WithMission_CoversAauthMissionComponent()
+ {
+ var mission = BuildMission();
+ var capture = new CaptureHandler();
+ using var client = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseJwt("a.b.c")
+ .WithMission(mission)
+ .WithInnerHandler(capture)
+ .Build();
+
+ await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"));
+
+ var input = string.Join(',', capture.Captured!.Headers.GetValues("Signature-Input"));
+ Assert.Contains("\"aauth-mission\"", input);
+ }
+
+ [Fact(DisplayName = "§Mission Context at Resources — the header is not emitted twice when already present")]
+ public async Task WithMission_DoesNotDuplicate_WhenAlreadyPresent()
+ {
+ var mission = BuildMission();
+ var capture = new CaptureHandler();
+ using var client = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseJwt("a.b.c")
+ .WithMission(mission)
+ .WithInnerHandler(capture)
+ .Build();
+
+ var request = new HttpRequestMessage(HttpMethod.Get, "https://r.example/path");
+ var preset = AAuthMissionHeader.FormatStructured(Approver, "preset-s256-value");
+ request.Headers.TryAddWithoutValidation(AAuthMissionHeader.Name, preset);
+
+ await client.SendAsync(request);
+
+ var values = capture.Captured!.Headers.GetValues(AAuthMissionHeader.Name).ToArray();
+ Assert.Single(values);
+ Assert.Equal(preset, values[0]);
+ }
+
+ [Fact(DisplayName = "§Mission Context at Resources — no mission header without WithMission")]
+ public async Task WithoutMission_NoMissionHeader()
+ {
+ var capture = new CaptureHandler();
+ using var client = new AAuthClientBuilder(AAuthKey.Generate())
+ .UseJwt("a.b.c")
+ .WithInnerHandler(capture)
+ .Build();
+
+ await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "https://r.example/path"));
+
+ Assert.False(capture.Captured!.Headers.Contains(AAuthMissionHeader.Name));
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/MissionModelTests.cs b/tests/AAuth.Conformance/Missions/MissionModelTests.cs
new file mode 100644
index 0000000..17541a8
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/MissionModelTests.cs
@@ -0,0 +1,102 @@
+using System;
+using System.Linq;
+using System.Text;
+using AAuth.Agent;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance tests for the mission blob model (§Mission Approval, §Mission Management).
+///
+public class MissionModelTests
+{
+ private const string ApprovalBody = """
+ {
+ "approver": "https://ps.example",
+ "agent": "aauth:assistant@agent.example",
+ "approved_at": "2026-04-07T14:30:00Z",
+ "description": "# Plan Japan Vacation\n\nPlan and book a trip.",
+ "approved_tools": [
+ { "name": "WebSearch", "description": "Search the web" },
+ { "name": "Read", "description": "Read files and web pages" }
+ ],
+ "capabilities": [ "interaction", "payment" ]
+ }
+ """;
+
+ [Fact(DisplayName = "§Mission Approval — mission blob parses required fields")]
+ public void FromApprovalBytes_ParsesRequiredFields()
+ {
+ var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody));
+
+ Assert.Equal("https://ps.example", mission.Approver);
+ Assert.Equal("aauth:assistant@agent.example", mission.Agent);
+ Assert.Equal(
+ new DateTimeOffset(2026, 4, 7, 14, 30, 0, TimeSpan.Zero),
+ mission.ApprovedAt);
+ Assert.StartsWith("# Plan Japan Vacation", mission.Description);
+ }
+
+ [Fact(DisplayName = "§Mission Approval — approved_tools parse into MissionTool list")]
+ public void FromApprovalBytes_ParsesApprovedTools()
+ {
+ var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody));
+
+ Assert.Equal(2, mission.ApprovedTools.Count);
+ Assert.Equal("WebSearch", mission.ApprovedTools[0].Name);
+ Assert.Equal("Search the web", mission.ApprovedTools[0].Description);
+ Assert.Equal("Read", mission.ApprovedTools[1].Name);
+ }
+
+ [Fact(DisplayName = "§Mission Approval — capabilities parse into string list")]
+ public void FromApprovalBytes_ParsesCapabilities()
+ {
+ var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody));
+
+ Assert.Equal(new[] { "interaction", "payment" }, mission.Capabilities.ToArray());
+ }
+
+ [Fact(DisplayName = "§Mission Management — a new mission defaults to active state")]
+ public void FromApprovalBytes_DefaultsToActive()
+ {
+ var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(ApprovalBody));
+
+ Assert.Equal(MissionState.Active, mission.State);
+ }
+
+ [Fact(DisplayName = "§Mission Approval — optional fields default to empty when absent")]
+ public void FromApprovalBytes_OptionalFieldsDefaultEmpty()
+ {
+ const string minimal = """
+ {
+ "approver": "https://ps.example",
+ "agent": "aauth:assistant@agent.example",
+ "approved_at": "2026-04-07T14:30:00Z",
+ "description": "Minimal mission"
+ }
+ """;
+
+ var mission = Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(minimal));
+
+ Assert.Empty(mission.ApprovedTools);
+ Assert.Empty(mission.Capabilities);
+ }
+
+ [Theory(DisplayName = "§Mission Approval — missing required field throws")]
+ [InlineData("{ \"agent\": \"a\", \"approved_at\": \"2026-04-07T14:30:00Z\", \"description\": \"d\" }")]
+ [InlineData("{ \"approver\": \"https://ps.example\", \"approved_at\": \"2026-04-07T14:30:00Z\", \"description\": \"d\" }")]
+ [InlineData("{ \"approver\": \"https://ps.example\", \"agent\": \"a\", \"description\": \"d\" }")]
+ [InlineData("{ \"approver\": \"https://ps.example\", \"agent\": \"a\", \"approved_at\": \"2026-04-07T14:30:00Z\" }")]
+ public void FromApprovalBytes_MissingRequiredField_Throws(string body)
+ {
+ Assert.Throws(
+ () => Mission.FromApprovalBytes(Encoding.UTF8.GetBytes(body)));
+ }
+
+ [Fact(DisplayName = "§Mission Approval — empty body throws")]
+ public void FromApprovalBytes_EmptyBody_Throws()
+ {
+ Assert.Throws(() => Mission.FromApprovalBytes(ReadOnlySpan.Empty));
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/MissionS256Tests.cs b/tests/AAuth.Conformance/Missions/MissionS256Tests.cs
new file mode 100644
index 0000000..0621e37
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/MissionS256Tests.cs
@@ -0,0 +1,74 @@
+using System.Security.Cryptography;
+using System.Text;
+using AAuth.Agent;
+using Microsoft.IdentityModel.Tokens;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance tests for the mission s256 identity (§Mission Approval):
+/// base64url(SHA-256(exact response body bytes)), stored verbatim with no
+/// re-serialization.
+///
+public class MissionS256Tests
+{
+ private static readonly byte[] Body = Encoding.UTF8.GetBytes("""
+ {
+ "approver": "https://ps.example",
+ "agent": "aauth:assistant@agent.example",
+ "approved_at": "2026-04-07T14:30:00Z",
+ "description": "Plan a trip"
+ }
+ """);
+
+ [Fact(DisplayName = "§Mission Approval — s256 is base64url(SHA-256(body bytes))")]
+ public void S256_MatchesSha256OfBodyBytes()
+ {
+ var mission = Mission.FromApprovalBytes(Body);
+
+ var expected = Base64UrlEncoder.Encode(SHA256.HashData(Body));
+ Assert.Equal(expected, mission.S256);
+ }
+
+ [Fact(DisplayName = "§Mission Approval — stored RawBytes are verbatim (no re-serialization)")]
+ public void RawBytes_AreVerbatim()
+ {
+ var mission = Mission.FromApprovalBytes(Body);
+
+ Assert.Equal(Body, mission.RawBytes.ToArray());
+ }
+
+ [Fact(DisplayName = "§Mission Approval — VerifyS256 accepts the matching hash")]
+ public void VerifyS256_AcceptsMatchingHash()
+ {
+ var mission = Mission.FromApprovalBytes(Body);
+
+ Assert.True(mission.VerifyS256(mission.S256));
+ Assert.True(mission.VerifyS256(Base64UrlEncoder.Encode(SHA256.HashData(Body))));
+ }
+
+ [Fact(DisplayName = "§Mission Approval — VerifyS256 rejects a non-matching hash")]
+ public void VerifyS256_RejectsNonMatchingHash()
+ {
+ var mission = Mission.FromApprovalBytes(Body);
+
+ Assert.False(mission.VerifyS256("not-the-hash"));
+ Assert.False(mission.VerifyS256(""));
+ }
+
+ [Fact(DisplayName = "§Mission Approval — whitespace differences change the s256")]
+ public void S256_IsSensitiveToByteDifferences()
+ {
+ var compact = Encoding.UTF8.GetBytes(
+ "{\"approver\":\"https://ps.example\",\"agent\":\"aauth:assistant@agent.example\"," +
+ "\"approved_at\":\"2026-04-07T14:30:00Z\",\"description\":\"Plan a trip\"}");
+
+ var fromPretty = Mission.FromApprovalBytes(Body);
+ var fromCompact = Mission.FromApprovalBytes(compact);
+
+ // Same logical content, different bytes — different identity. This is why
+ // the agent MUST store the body verbatim and never re-serialize.
+ Assert.NotEqual(fromPretty.S256, fromCompact.S256);
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs b/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs
new file mode 100644
index 0000000..55f4c57
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/MissionTerminatedTests.cs
@@ -0,0 +1,119 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Discovery;
+using AAuth.Errors;
+using AAuth.Headers;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the 403 mission_terminated response (AAuth protocol
+/// §Mission Status Errors). The PS rejects any request referencing a mission
+/// that is no longer active; the agent surfaces a typed exception.
+///
+public class MissionTerminatedTests
+{
+ private const string Ps = "http://localhost:5555";
+
+ private static TokenExchangeClient BuildClient(HttpMessageHandler handler)
+ {
+ var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) };
+ var metadata = new MetadataClient(new HttpClient(handler));
+ return new TokenExchangeClient(http, metadata);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — 403 mission_terminated on token request throws typed exception")]
+ public async Task MissionTerminated_OnTokenRequest_Throws()
+ {
+ var client = BuildClient(new TerminatedHandler(deferUntilPoll: false));
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.ExchangeAsync(Ps, "fake-resource-token"));
+
+ Assert.Equal("terminated", ex.MissionStatus);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — 403 mission_terminated surfaced during polling")]
+ public async Task MissionTerminated_DuringPolling_Throws()
+ {
+ var client = BuildClient(new TerminatedHandler(deferUntilPoll: true));
+
+ var ex = await Assert.ThrowsAsync(() =>
+ client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ OnInteractionRequired = (_, _) => Task.CompletedTask,
+ }));
+
+ Assert.Equal("terminated", ex.MissionStatus);
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — error/mission_status codes round-trip via TokenErrorCode")]
+ public void MissionTerminated_TokenErrorCode_RoundTrips()
+ {
+ Assert.True(TokenErrorResponse.TryParseCode("mission_terminated", out var code));
+ Assert.Equal(TokenErrorCode.MissionTerminated, code);
+ Assert.Equal("mission_terminated", new TokenErrorResponse(code).ErrorCode);
+ }
+
+ ///
+ /// PS mock that returns 403 mission_terminated — either immediately on
+ /// the token request, or after an interaction deferral on the polled pending URL.
+ ///
+ private sealed class TerminatedHandler : HttpMessageHandler
+ {
+ private readonly bool _deferUntilPoll;
+ public TerminatedHandler(bool deferUntilPoll) => _deferUntilPoll = deferUntilPoll;
+
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+
+ if (path == "/.well-known/aauth-person.json")
+ {
+ return Task.FromResult(Json(HttpStatusCode.OK, new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["token_endpoint"] = Ps + "/token",
+ }));
+ }
+
+ if (path == "/token")
+ {
+ if (_deferUntilPoll)
+ {
+ var pending = Json(HttpStatusCode.Accepted, new JsonObject { ["status"] = "pending" });
+ pending.Headers.Location = new Uri(Ps + "/pending/abc");
+ pending.Headers.TryAddWithoutValidation(
+ AAuthRequirementHeader.Name,
+ "requirement=interaction; url=\"" + Ps + "/i\"; code=\"ABCD1234\"");
+ return Task.FromResult(pending);
+ }
+ return Task.FromResult(Terminated());
+ }
+
+ // Pending URL poll → mission terminated.
+ return Task.FromResult(Terminated());
+ }
+
+ private static HttpResponseMessage Terminated()
+ => Json(HttpStatusCode.Forbidden, new JsonObject
+ {
+ ["error"] = "mission_terminated",
+ ["mission_status"] = "terminated",
+ });
+
+ private static HttpResponseMessage Json(HttpStatusCode status, JsonObject body)
+ => new(status)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs
new file mode 100644
index 0000000..5e99ff9
--- /dev/null
+++ b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs
@@ -0,0 +1,135 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Text;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Discovery;
+using Xunit;
+
+namespace AAuth.Conformance.Missions;
+
+///
+/// Conformance for the optional agent token-request parameters (AAuth protocol
+/// §Agent Token Request): justification, login_hint, tenant,
+/// domain_hint, platform, and device.
+///
+public class TokenRequestParamsTests
+{
+ private const string Ps = "http://localhost:5555";
+
+ private static TokenExchangeClient BuildClient(HttpMessageHandler handler)
+ {
+ var http = new HttpClient(handler) { BaseAddress = new Uri(Ps) };
+ var metadata = new MetadataClient(new HttpClient(handler));
+ return new TokenExchangeClient(http, metadata);
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — all optional params are serialized into the POST body")]
+ public async Task OptionalParams_SerializedIntoBody()
+ {
+ JsonObject? captured = null;
+ var client = BuildClient(new CaptureHandler(body => captured = body));
+
+ await client.ExchangeAsync(Ps, "fake-resource-token", new TokenExchangeRequest
+ {
+ Justification = "Booking a flight on your behalf.",
+ LoginHint = "alice@example.com",
+ Tenant = "contoso",
+ DomainHint = "example.com",
+ Platform = "ios",
+ Device = "iphone-15",
+ });
+
+ Assert.NotNull(captured);
+ Assert.Equal("Booking a flight on your behalf.", (string?)captured!["justification"]);
+ Assert.Equal("alice@example.com", (string?)captured["login_hint"]);
+ Assert.Equal("contoso", (string?)captured["tenant"]);
+ Assert.Equal("example.com", (string?)captured["domain_hint"]);
+ Assert.Equal("ios", (string?)captured["platform"]);
+ Assert.Equal("iphone-15", (string?)captured["device"]);
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — unset optional params are omitted from the POST body")]
+ public async Task OptionalParams_OmittedWhenUnset()
+ {
+ JsonObject? captured = null;
+ var client = BuildClient(new CaptureHandler(body => captured = body));
+
+ await client.ExchangeAsync(Ps, "fake-resource-token");
+
+ Assert.NotNull(captured);
+ Assert.False(captured!.ContainsKey("justification"));
+ Assert.False(captured.ContainsKey("login_hint"));
+ Assert.False(captured.ContainsKey("tenant"));
+ Assert.False(captured.ContainsKey("domain_hint"));
+ Assert.False(captured.ContainsKey("platform"));
+ Assert.False(captured.ContainsKey("device"));
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — device is accepted at the 64-char boundary")]
+ public void Device_AtMaxLength_Accepted()
+ {
+ var device = new string('a', 64);
+ var request = new TokenExchangeRequest { Device = device };
+ Assert.Equal(device, request.Device);
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — device longer than 64 chars is rejected")]
+ public void Device_TooLong_Throws()
+ {
+ var ex = Assert.Throws(() =>
+ new TokenExchangeRequest { Device = new string('a', 65) });
+ Assert.Equal("Device", ex.ParamName);
+ }
+
+ [Theory(DisplayName = "§Agent Token Request — device with control characters is rejected")]
+ [InlineData("Chrome on\tmacOS")]
+ [InlineData("line\nbreak")]
+ [InlineData("null\0byte")]
+ [InlineData("bell\u0007")]
+ public void Device_ControlCharacters_Throws(string device)
+ {
+ var ex = Assert.Throws(() =>
+ new TokenExchangeRequest { Device = device });
+ Assert.Equal("Device", ex.ParamName);
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — printable device string is accepted")]
+ public void Device_Printable_Accepted()
+ {
+ var request = new TokenExchangeRequest { Device = "Chrome on macOS (M3)" };
+ Assert.Equal("Chrome on macOS (M3)", request.Device);
+ }
+
+ private sealed class CaptureHandler : HttpMessageHandler
+ {
+ private readonly Action _onBody;
+ public CaptureHandler(Action onBody) => _onBody = onBody;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request, CancellationToken ct)
+ {
+ if (request.RequestUri!.AbsolutePath == "/.well-known/aauth-person.json")
+ {
+ return Json(new JsonObject
+ {
+ ["issuer"] = Ps,
+ ["token_endpoint"] = Ps + "/token",
+ });
+ }
+
+ var body = JsonNode.Parse(await request.Content!.ReadAsStringAsync(ct))!.AsObject();
+ _onBody(body);
+ return Json(new JsonObject { ["auth_token"] = "fake-auth-token" });
+ }
+
+ private static HttpResponseMessage Json(JsonObject body)
+ => new(HttpStatusCode.OK)
+ {
+ Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"),
+ };
+ }
+}
diff --git a/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs
new file mode 100644
index 0000000..5e4f33c
--- /dev/null
+++ b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs
@@ -0,0 +1,369 @@
+using System;
+using System.Net;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using System.Threading;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Crypto;
+using AAuth.Discovery;
+using AAuth.HttpSig;
+using AAuth.Person;
+using AAuth.Server.Governance;
+using AAuth.Tokens;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Xunit;
+
+namespace AAuth.Conformance.Person;
+
+///
+/// Conformance for the Person Server mapper (MapAAuthPersonServer) — the
+/// one-call PS issuer (AAuth protocol §Agent Token Request, §PS-asserted access,
+/// §PS-AS Federation). The mapper verifies the resource token, delegates the
+/// identity + consent decision to , mints
+/// the auth token (three-party) or routes to an Access Server (four-party), and
+/// packages the mission three-gate model over the mission primitives.
+///
+public class PersonServerMapperTests
+{
+ private const string PsIssuer = "https://ps.test";
+ private const string AsIssuer = "https://as.test";
+ private const string ResourceUrl = "https://whoami.test";
+ private const string AgentId = "aauth:demo@ap.example";
+ private const string PsKid = "ps-1";
+ private const string ResKid = "whoami-1";
+
+ private static readonly AAuthKey ResourceKey = AAuthKey.Generate();
+
+ // Build a PS host: real verification middleware + stub resource discovery +
+ // the supplied asserter (default asserts a fixed sub).
+ private static async Task BuildHostAsync(IIdentityClaimsAsserter? asserter = null)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseTestServer();
+
+ var psKey = AAuthKey.Generate();
+ builder.Services.AddSingleton(new AAuthVerifier { MaxAge = TimeSpan.FromSeconds(300) });
+ builder.Services.AddSingleton(new TokenVerifier());
+ builder.Services.AddSingleton(new MetadataClient(new HttpClient(new StubResourceHandler())));
+ builder.Services.AddSingleton(new JwksClient(new HttpClient(new StubResourceHandler())));
+ builder.Services.AddAAuthGovernance();
+ builder.Services.AddSingleton(sp => new UpstreamTokenValidator(
+ sp.GetRequiredService(), sp.GetRequiredService()));
+ builder.Services.AddSingleton();
+ builder.Services.AddSingleton(asserter ?? new DefaultIdentityClaimsAsserter("user-42"));
+ builder.Services.AddRouting();
+
+ var app = builder.Build();
+ app.MapAAuthPersonServer(new AAuthPersonServerOptions
+ {
+ Issuer = PsIssuer,
+ SigningKeys = new System.Collections.Generic.Dictionary { [PsKid] = psKey },
+ TrustedAccessServers = new[] { AsIssuer },
+ });
+ await app.StartAsync();
+ return app;
+ }
+
+ private static HttpClient SignedAgentClient(IHost host, AAuthKey agentKey, string agentId)
+ {
+ var agentToken = new AgentTokenBuilder
+ {
+ Issuer = "https://ap.example",
+ Subject = agentId,
+ KeyId = "agent-1",
+ Key = agentKey,
+ PersonServer = PsIssuer,
+ }.Build();
+ var signing = new AAuthSigningHandler(agentKey, () => agentToken)
+ {
+ InnerHandler = host.GetTestServer().CreateHandler(),
+ };
+ return new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) };
+ }
+
+ private static string ResourceToken(
+ AAuthKey agentKey, string agentId, string audience, string scope = "whoami", MissionClaim? mission = null)
+ => new ResourceTokenBuilder
+ {
+ Issuer = ResourceUrl,
+ Audience = audience,
+ Agent = agentId,
+ AgentJkt = agentKey.ComputeJwkThumbprint(),
+ Key = ResourceKey,
+ KeyId = ResKid,
+ Scope = scope,
+ Mission = mission,
+ }.Build();
+
+ private static JsonObject DecodePayload(string jwt)
+ {
+ var segments = jwt.Split('.');
+ return (JsonObject)JsonNode.Parse(
+ Microsoft.IdentityModel.Tokens.Base64UrlEncoder.DecodeBytes(segments[1]))!;
+ }
+
+ [Fact(DisplayName = "§PS-asserted access — three-party mint binds the agent key and asserts the directed sub")]
+ public async Task ThreeParty_MintsAuthToken_BoundToAgentKey()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) });
+
+ Assert.True(response.IsSuccessStatusCode,
+ $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
+ var body = await response.Content.ReadFromJsonAsync();
+ var payload = DecodePayload((string)body!["auth_token"]!);
+ Assert.Equal(PsIssuer, (string?)payload["iss"]);
+ Assert.Equal(ResourceUrl, (string?)payload["aud"]);
+ Assert.Equal(AgentId, (string?)payload["agent"]);
+ Assert.Equal("user-42", (string?)payload["sub"]);
+ Assert.Equal(AuthTokenBuilder.PersonDwk, (string?)payload["dwk"]);
+ var boundKey = AAuthKey.FromJwk((JsonObject)payload["cnf"]!["jwk"]!);
+ Assert.Equal(agentKey.ComputeJwkThumbprint(), boundKey.ComputeJwkThumbprint());
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Error Responses — an auth token presented as carrier is refused (403 invalid_carrier_token)")]
+ public async Task ThreeParty_RejectsAuthTokenAsCarrier()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+
+ // Sign with an auth token (wrong carrier type), not an agent token.
+ var authTokenAsCarrier = new AuthTokenBuilder
+ {
+ Issuer = PsIssuer,
+ Audience = ResourceUrl,
+ Agent = AgentId,
+ AgentConfirmationKey = agentKey,
+ Key = AAuthKey.Generate(),
+ KeyId = "x",
+ Subject = "pairwise",
+ Scope = "whoami",
+ }.Build();
+ var signing = new AAuthSigningHandler(agentKey, () => authTokenAsCarrier)
+ {
+ InnerHandler = host.GetTestServer().CreateHandler(),
+ };
+ using var http = new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) };
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = "irrelevant" });
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("invalid_carrier_token", (string?)body!["error"]);
+
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — a missing resource_token is a 400")]
+ public async Task ThreeParty_RejectsMissingResourceToken()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ using var response = await http.PostAsJsonAsync("/token", new JsonObject());
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Token Endpoint Error Codes — an unverifiable resource_token is a 400 invalid_resource_token (not a 401)")]
+ public async Task ThreeParty_RejectsInvalidResourceToken_With400()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ // A resource token carrying the published kid but signed with a different
+ // key — the PS resolves the genuine JWKS key and the signature check fails.
+ var forged = new ResourceTokenBuilder
+ {
+ Issuer = ResourceUrl,
+ Audience = PsIssuer,
+ Agent = AgentId,
+ AgentJkt = agentKey.ComputeJwkThumbprint(),
+ Key = AAuthKey.Generate(),
+ KeyId = ResKid,
+ Scope = "whoami",
+ }.Build();
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = forged });
+
+ // §Token Endpoint Error Codes lists invalid_resource_token / expired_resource_token
+ // as 400 (a bad token parameter in the body). §Authentication Errors reserves 401
+ // for request-signature failures carrying a Signature-Error header — the agent's
+ // request signature is valid here, so a 401 would mismatch the spec.
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("invalid_resource_token", (string?)body!["error"]);
+ Assert.False(response.Headers.Contains("Signature-Error"));
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§PS-asserted access — a denying asserter yields 403 denied")]
+ public async Task ThreeParty_DenyingAsserter_Forbidden()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.Deny("not allowed")));
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) });
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("denied", (string?)body!["error"]);
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Interaction — NeedsConsent parks a 202 poll; the host verdict resolves the mint")]
+ public async Task ThreeParty_NeedsConsent_Parks202_ThenMints()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.NeedsConsent()));
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ using var post = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, PsIssuer) });
+
+ Assert.Equal(HttpStatusCode.Accepted, post.StatusCode);
+ var location = post.Headers.Location!.OriginalString;
+ Assert.Contains("/pending/", location);
+
+ // The host's interaction page resolves the verdict against the store.
+ var store = (InMemoryPersonPendingStore)host.Services.GetRequiredService();
+ var id = location[(location.LastIndexOf('/') + 1)..];
+ store.MarkAllowed(id, "user-99");
+
+ using var poll = await http.GetAsync(location);
+ Assert.Equal(HttpStatusCode.OK, poll.StatusCode);
+ var body = await poll.Content.ReadFromJsonAsync();
+ var payload = DecodePayload((string)body!["auth_token"]!);
+ Assert.Equal("user-99", (string?)payload["sub"]);
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Mission Status Errors — a terminated mission is rejected (403 mission_terminated)")]
+ public async Task Mission_Terminated_Rejected()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ var missions = host.Services.GetRequiredService();
+ await missions.SaveAsync(new StoredMission(s256, PsIssuer, AgentId, new byte[] { 1, 2, 3 }));
+ await missions.SetStateAsync(s256, MissionState.Terminated);
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject
+ {
+ ["resource_token"] = ResourceToken(
+ agentKey, AgentId, PsIssuer, mission: new MissionClaim(PsIssuer, s256)),
+ });
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("mission_terminated", (string?)body!["error"]);
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§Agent Token Request — an in-scope mission mints silently and records the grant")]
+ public async Task Mission_InScope_Mints_AndLogsGrant()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync(new StubAsserter(IdentityAssertion.Assert("user-42")));
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ const string s256 = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject
+ {
+ ["resource_token"] = ResourceToken(
+ agentKey, AgentId, PsIssuer, mission: new MissionClaim(PsIssuer, s256)),
+ });
+
+ Assert.True(response.IsSuccessStatusCode,
+ $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
+ var payload = DecodePayload((string)(await response.Content.ReadFromJsonAsync())!["auth_token"]!);
+ Assert.Equal(PsIssuer, (string?)payload["iss"]);
+ Assert.NotNull(payload["mission"]);
+
+ // The grant was recorded so a repeat request resolves via prior consent.
+ var log = host.Services.GetRequiredService();
+ Assert.True(await log.HasPriorConsentAsync(s256, ResourceUrl, "whoami"));
+ await host.StopAsync();
+ }
+
+ [Fact(DisplayName = "§PS-AS Federation — a resource token audienced to an untrusted AS is refused")]
+ public async Task FourParty_UntrustedAccessServer_Refused()
+ {
+ var agentKey = AAuthKey.Generate();
+ using var host = await BuildHostAsync();
+ using var http = SignedAgentClient(host, agentKey, AgentId);
+
+ using var response = await http.PostAsJsonAsync("/token",
+ new JsonObject { ["resource_token"] = ResourceToken(agentKey, AgentId, "https://untrusted-as.test") });
+
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
+ var body = await response.Content.ReadFromJsonAsync();
+ Assert.Equal("untrusted_access_server", (string?)body!["error"]);
+ await host.StopAsync();
+ }
+
+ private sealed class StubAsserter : IIdentityClaimsAsserter
+ {
+ private readonly IdentityAssertion _assertion;
+ public StubAsserter(IdentityAssertion assertion) => _assertion = assertion;
+ public Task AssertAsync(
+ IdentityAssertionRequest request, CancellationToken cancellationToken = default)
+ => Task.FromResult(_assertion);
+ }
+
+ // Serves the resource's well-known metadata + JWKS so the SDK's
+ // VerifyResourceTokenAsync resolves the resource's signing key in-process.
+ private sealed class StubResourceHandler : HttpMessageHandler
+ {
+ protected override Task SendAsync(
+ HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ var path = request.RequestUri!.AbsolutePath;
+ string json;
+ if (path == "/.well-known/aauth-resource.json")
+ {
+ json = new JsonObject
+ {
+ ["issuer"] = ResourceUrl,
+ ["jwks_uri"] = $"{ResourceUrl}/.well-known/jwks.json",
+ }.ToJsonString();
+ }
+ else
+ {
+ var jwk = ResourceKey.ToPublicJwk();
+ jwk["kid"] = ResKid;
+ jwk["use"] = "sig";
+ jwk["alg"] = AAuthKey.Algorithm;
+ json = new JsonObject { ["keys"] = new JsonArray(jwk) }.ToJsonString();
+ }
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"),
+ });
+ }
+ }
+}
diff --git a/tests/AAuth.Tests/Agent/InteractionChainingTests.cs b/tests/AAuth.Tests/Agent/InteractionChainingTests.cs
index 83c1828..2e18e70 100644
--- a/tests/AAuth.Tests/Agent/InteractionChainingTests.cs
+++ b/tests/AAuth.Tests/Agent/InteractionChainingTests.cs
@@ -7,6 +7,7 @@
using System.Threading.Tasks;
using AAuth.Agent;
using AAuth.Discovery;
+using AAuth.Errors;
using AAuth.Headers;
using Xunit;
@@ -78,16 +79,18 @@ public async Task DirectInteractionCallback_StillPollsToTerminal()
Assert.True(handler.PendingPolled);
}
- [Fact(DisplayName = "Chaining — no callback on a 202 still throws the existing HttpRequestException")]
- public async Task NoCallback_StillThrowsExisting()
+ [Fact(DisplayName = "Chaining — no callback on a 202 throws a terminal user_unreachable token-exchange error")]
+ public async Task NoCallback_ThrowsUserUnreachable()
{
var handler = new DeferredExchangeHandler();
var metaClient = new MetadataClient(new HttpClient(handler));
var exchangeClient = new TokenExchangeClient(new HttpClient(handler), metaClient);
- await Assert.ThrowsAsync(
+ var ex = await Assert.ThrowsAsync(
() => exchangeClient.ExchangeAsync(PsUrl, "fake-resource-token"));
+ Assert.Equal("user_unreachable", ex.ErrorCode);
+ Assert.True(ex.IsTerminal);
Assert.False(handler.PendingPolled);
}
diff --git a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs
new file mode 100644
index 0000000..9fef660
--- /dev/null
+++ b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs
@@ -0,0 +1,73 @@
+using System.Linq;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Server.Governance;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AAuth.Tests.DependencyInjection;
+
+///
+/// Unit tests for AddAAuthGovernance (§PS Governance Endpoints): the call
+/// registers in-memory storage seams plus conservative default policy/user-channel
+/// seams, all via TryAdd so a PS overrides only what it needs.
+///
+public class AAuthGovernanceDITests
+{
+ [Fact]
+ public void AddAAuthGovernance_RegistersStorageAndDefaultSeams()
+ {
+ var services = new ServiceCollection();
+ services.AddAAuthGovernance();
+ var provider = services.BuildServiceProvider();
+
+ Assert.IsType(provider.GetRequiredService());
+ Assert.IsType(provider.GetRequiredService());
+ Assert.IsType(provider.GetRequiredService());
+ Assert.IsType(provider.GetRequiredService());
+ Assert.IsType(provider.GetRequiredService());
+ }
+
+ [Fact]
+ public void AddAAuthGovernance_DoesNotOverrideCustomSeams()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton();
+ services.AddAAuthGovernance();
+ var provider = services.BuildServiceProvider();
+
+ Assert.IsType(provider.GetRequiredService());
+ }
+
+ [Fact]
+ public async Task DefaultPermissionDecider_PromptsForUnknownAction()
+ {
+ var decider = new DefaultPermissionDecider();
+ var context = new PermissionDecisionContext(
+ new PermissionRequest(new MissionAction("SendEmail")), Mission: null, Log: System.Array.Empty());
+
+ var decision = await decider.DecideAsync(context);
+
+ Assert.Equal(PermissionOutcome.Prompt, decision.Outcome);
+ Assert.Equal(PermissionDecisionReason.OutOfScope, decision.Reason);
+ }
+
+ [Fact]
+ public async Task DefaultInteractionRelay_HasNoUserChannel()
+ {
+ var relay = new DefaultInteractionRelay();
+
+ var question = await relay.RelayAsync(new InteractionRequest(InteractionType.Question));
+ Assert.Equal(string.Empty, question.Answer);
+
+ var completion = await relay.RelayAsync(new InteractionRequest(InteractionType.Completion));
+ Assert.False(completion.Accepted);
+ }
+
+ private sealed class CustomDecider : IPermissionDecider
+ {
+ public Task DecideAsync(PermissionDecisionContext context, System.Threading.CancellationToken ct = default)
+ => Task.FromResult(new PermissionDecision(PermissionOutcome.Denied, PermissionDecisionReason.OutOfScope));
+ }
+}
diff --git a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
new file mode 100644
index 0000000..703372d
--- /dev/null
+++ b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
@@ -0,0 +1,377 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Json;
+using System.Text.Json.Nodes;
+using System.Threading.Tasks;
+using AAuth.Agent;
+using AAuth.Agent.Governance;
+using AAuth.Crypto;
+using AAuth.Discovery;
+using AAuth.Errors;
+using AAuth.HttpSig;
+using AAuth.Server.Governance;
+using AAuth.Tokens;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.Extensions.DependencyInjection;
+using Xunit;
+
+namespace AAuth.Tests.Integration;
+
+///
+/// End-to-end Consent-Matrix coverage for the mission-governance MockPersonServer
+/// (Phase 6a). Each test drives the shipped SDK governance clients
+/// (, ,
+/// , ,
+/// ) against the in-process PS and asserts both the
+/// agent-observable outcome and the recorded mission-log decision reason.
+///
+/// The three-gate model (§Agent Token Request): a mission token request is silent
+/// when the (resource, scope) is within the approved intent (gate 2a) or already
+/// consented earlier in the mission (gate 2b), otherwise the user is prompted
+/// (gate 2c). A permission request is silent for a pre-approved tool, else prompts
+/// (§Permission Endpoint). User decisions are scripted via /admin/mission-script.
+///
+public class MissionAgentFlowTests : IClassFixture>, IDisposable
+{
+ private const string PsIssuer = "https://ps.test";
+ private const string ResourceUrl = "https://whoami.test";
+ private const string ApIssuer = "https://ap.example";
+
+ private readonly WebApplicationFactory _factory;
+
+ public MissionAgentFlowTests(WebApplicationFactory factory)
+ {
+ _factory = factory.WithWebHostBuilder(b =>
+ {
+ b.UseSetting("AAuth:Issuer", PsIssuer);
+ b.ConfigureServices(ResourceStub.WireDiscovery);
+ });
+ }
+
+ public void Dispose() => _factory.Dispose();
+
+ // ---- Mission creation (rows 1-2) -----------------------------------
+
+ [Fact]
+ public async Task Row01_MissionApproved_ReturnsActiveMission()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true });
+
+ var mission = await ProposeMissionAsync(agent, "row01 research mission");
+
+ Assert.Equal(PsIssuer, mission.Approver);
+ Assert.Equal(agent.AgentId, mission.Agent);
+ Assert.False(string.IsNullOrEmpty(mission.S256));
+ }
+
+ [Fact]
+ public async Task Row02_MissionDenied_Aborts()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveMission"] = false });
+
+ var proposal = new MissionProposal("row02 rejected mission");
+ await Assert.ThrowsAsync(
+ () => MissionClientFor(agent).ProposeAsync(proposal));
+ }
+
+ // ---- Token gate (rows 3-8) -----------------------------------------
+
+ [Fact]
+ public async Task Row03_TokenInScope_SilentGrant()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject
+ {
+ ["reset"] = true,
+ ["inScope"] = new JsonArray(InScope(ResourceUrl, "whoami")),
+ });
+ var mission = await ProposeMissionAsync(agent, "row03 in-scope mission");
+
+ var token = await ExchangeAsync(agent, mission, "whoami", new TokenExchangeRequest());
+
+ Assert.False(string.IsNullOrEmpty(token));
+ await AssertTokenReasonAsync(mission, "whoami", granted: true, reason: "InScope");
+ }
+
+ [Fact]
+ public async Task Row04_TokenRepeat_PriorConsentSilentGrant()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = true });
+ var mission = await ProposeMissionAsync(agent, "row04 prior-consent mission");
+
+ // First out-of-scope request: prompted then approved -> recorded as prior consent.
+ _ = await ExchangeAsync(agent, mission, "whoami:admin", Promptable());
+ // Second request for the same (resource, scope): now silent via prior consent.
+ var token = await ExchangeAsync(agent, mission, "whoami:admin", new TokenExchangeRequest());
+
+ Assert.False(string.IsNullOrEmpty(token));
+ await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "PriorConsent");
+ }
+
+ [Fact]
+ public async Task Row05_TokenOutOfScope_PromptThenIssue()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = true });
+ var mission = await ProposeMissionAsync(agent, "row05 out-of-scope approve mission");
+
+ var prompted = false;
+ var options = new TokenExchangeRequest
+ {
+ OnInteractionRequired = (_, _) => { prompted = true; return Task.CompletedTask; },
+ };
+ var token = await ExchangeAsync(agent, mission, "whoami:admin", options);
+
+ Assert.True(prompted);
+ Assert.False(string.IsNullOrEmpty(token));
+ await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "OutOfScope");
+ }
+
+ [Fact]
+ public async Task Row06_TokenOutOfScope_PromptThenDeny()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approveToken"] = false });
+ var mission = await ProposeMissionAsync(agent, "row06 out-of-scope deny mission");
+
+ await Assert.ThrowsAsync(
+ () => ExchangeAsync(agent, mission, "whoami:admin", Promptable()));
+ await AssertTokenReasonAsync(mission, "whoami:admin", granted: false, reason: "OutOfScope");
+ }
+
+ [Fact]
+ public async Task Row07_TokenClarification_RoundThenIssue()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject
+ {
+ ["reset"] = true,
+ ["approveToken"] = true,
+ ["requireClarification"] = true,
+ });
+ var mission = await ProposeMissionAsync(agent, "row07 clarification mission");
+
+ var asked = false;
+ var options = new TokenExchangeRequest
+ {
+ OnInteractionRequired = (_, _) => Task.CompletedTask,
+ OnClarificationRequired = (_, _) =>
+ {
+ asked = true;
+ return Task.FromResult(ClarificationResponse.Respond("The mission needs admin scope to read roles."));
+ },
+ };
+ var token = await ExchangeAsync(agent, mission, "whoami:admin", options);
+
+ Assert.True(asked);
+ Assert.False(string.IsNullOrEmpty(token));
+ await AssertTokenReasonAsync(mission, "whoami:admin", granted: true, reason: "OutOfScope");
+ var entries = await ReadLogAsync(mission);
+ Assert.Contains(entries, e => e.Kind == MissionLogEntryKind.Clarification);
+ }
+
+ [Fact]
+ public async Task Row08_TokenClarification_CancelViaDelete()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject
+ {
+ ["reset"] = true,
+ ["approveToken"] = true,
+ ["requireClarification"] = true,
+ });
+ var mission = await ProposeMissionAsync(agent, "row08 clarification cancel mission");
+
+ var options = new TokenExchangeRequest
+ {
+ OnInteractionRequired = (_, _) => Task.CompletedTask,
+ OnClarificationRequired = (_, _) => Task.FromResult(ClarificationResponse.Cancel()),
+ };
+
+ await Assert.ThrowsAsync(
+ () => ExchangeAsync(agent, mission, "whoami:admin", options));
+
+ var entries = await ReadLogAsync(mission);
+ Assert.Contains(entries, e => e.Kind == MissionLogEntryKind.Clarification && e.Detail == "cancelled");
+ // No token was issued for this (resource, scope).
+ Assert.DoesNotContain(entries, e => e.Kind == MissionLogEntryKind.Token && e.Granted == true);
+ }
+
+ // ---- Permission gate (rows 9-11) -----------------------------------
+
+ [Fact]
+ public async Task Row09_PermissionApprovedTool_SilentGrant()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true });
+ var mission = await ProposeMissionAsync(agent, "row09 approved-tool mission", "send_email");
+
+ var result = await PermissionClientFor(agent)
+ .RequestAsync(new MissionAction("send_email"), mission);
+
+ Assert.True(result.IsGranted);
+ Assert.Equal(PermissionGrant.Granted, result.Grant);
+ }
+
+ [Fact]
+ public async Task Row10_PermissionNonPreApproved_PromptThenGrant()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = true });
+ var mission = await ProposeMissionAsync(agent, "row10 prompt-grant mission", "send_email");
+
+ var request = new PermissionRequest(new MissionAction("delete_file"))
+ {
+ Mission = new MissionClaim(mission.Approver, mission.S256),
+ };
+ var result = await PermissionClientFor(agent).RequestAsync(request);
+
+ Assert.True(result.IsGranted);
+ await AssertPermissionReasonAsync(mission, "delete_file", granted: true);
+ }
+
+ [Fact]
+ public async Task Row11_PermissionNonPreApproved_PromptThenDeny()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = false });
+ var mission = await ProposeMissionAsync(agent, "row11 prompt-deny mission", "send_email");
+
+ var request = new PermissionRequest(new MissionAction("delete_file"))
+ {
+ Mission = new MissionClaim(mission.Approver, mission.S256),
+ };
+ var result = await PermissionClientFor(agent).RequestAsync(request);
+
+ Assert.False(result.IsGranted);
+ Assert.Equal(PermissionGrant.Denied, result.Grant);
+ await AssertPermissionReasonAsync(mission, "delete_file", granted: false);
+ }
+
+ // ---- Termination (row 12) ------------------------------------------
+
+ [Fact]
+ public async Task Row12_TerminationMidFlow_RejectsWithMissionTerminated()
+ {
+ var agent = NewAgent();
+ await ScriptAsync(agent, new JsonObject
+ {
+ ["reset"] = true,
+ ["inScope"] = new JsonArray(InScope(ResourceUrl, "whoami")),
+ });
+ var mission = await ProposeMissionAsync(agent, "row12 terminated mission");
+
+ // Terminate the mission, then attempt a token request.
+ using var terminate = await agent.Plain.PostAsJsonAsync("/admin/mission-terminate",
+ new JsonObject { ["s256"] = mission.S256 });
+ Assert.True(terminate.IsSuccessStatusCode);
+
+ await Assert.ThrowsAsync(
+ () => ExchangeAsync(agent, mission, "whoami", new TokenExchangeRequest()));
+ }
+
+ // ---- Helpers -------------------------------------------------------
+
+ private sealed record Agent(string AgentId, AAuthKey AgentKey, HttpClient Signed, HttpClient Plain, MetadataClient Metadata);
+
+ private Agent NewAgent(string? agentId = null)
+ {
+ agentId ??= $"aauth:demo@ap.example";
+ var agentKey = AAuthKey.Generate();
+ var agentToken = new AgentTokenBuilder
+ {
+ Issuer = ApIssuer,
+ Subject = agentId,
+ KeyId = "demo",
+ Key = agentKey,
+ PersonServer = PsIssuer,
+ }.Build();
+ var signing = new AAuthSigningHandler(agentKey, () => agentToken)
+ {
+ InnerHandler = _factory.Server.CreateHandler(),
+ };
+ var signed = new HttpClient(signing) { BaseAddress = new Uri(PsIssuer) };
+ var plain = _factory.CreateClient(new WebApplicationFactoryClientOptions
+ {
+ BaseAddress = new Uri(PsIssuer),
+ });
+ var metadata = new MetadataClient(new HttpClient(_factory.Server.CreateHandler()));
+ return new Agent(agentId, agentKey, signed, plain, metadata);
+ }
+
+ private MissionClient MissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata, PsIssuer);
+
+ private PermissionClient PermissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata, PsIssuer);
+
+ private async Task ScriptAsync(Agent agent, JsonObject body)
+ {
+ using var response = await agent.Plain.PostAsJsonAsync("/admin/mission-script", body);
+ Assert.True(response.IsSuccessStatusCode,
+ $"Status={(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");
+ }
+
+ private async Task ProposeMissionAsync(Agent agent, string description, params string[] tools)
+ {
+ var proposal = new MissionProposal(description)
+ {
+ Tools = tools.Select(t => new MissionTool(t)).ToArray(),
+ };
+ return await MissionClientFor(agent).ProposeAsync(proposal);
+ }
+
+ private async Task ExchangeAsync(Agent agent, Mission mission, string scope, TokenExchangeRequest options)
+ {
+ var resourceToken = new ResourceTokenBuilder
+ {
+ Issuer = ResourceUrl,
+ Audience = PsIssuer,
+ Agent = agent.AgentId,
+ AgentJkt = agent.AgentKey.ComputeJwkThumbprint(),
+ Key = ResourceStub.Key,
+ KeyId = ResourceStub.Kid,
+ Scope = scope,
+ Mission = new MissionClaim(mission.Approver, mission.S256),
+ }.Build();
+
+ var exchange = new TokenExchangeClient(agent.Signed, agent.Metadata);
+ return await exchange.ExchangeAsync(PsIssuer, resourceToken, options);
+ }
+
+ private static TokenExchangeRequest Promptable() => new()
+ {
+ OnInteractionRequired = (_, _) => Task.CompletedTask,
+ };
+
+ private static JsonObject InScope(string resource, string scope)
+ => new() { ["resource"] = resource, ["scope"] = scope };
+
+ private async Task> ReadLogAsync(Mission mission)
+ {
+ var log = _factory.Services.GetRequiredService();
+ return await log.ReadAsync(mission.S256);
+ }
+
+ private async Task AssertTokenReasonAsync(Mission mission, string scope, bool granted, string reason)
+ {
+ var entries = await ReadLogAsync(mission);
+ var entry = entries.LastOrDefault(e =>
+ e.Kind == MissionLogEntryKind.Token && e.Scope == scope);
+ Assert.NotNull(entry);
+ Assert.Equal(granted, entry!.Granted);
+ Assert.Equal(reason, entry.Detail);
+ }
+
+ private async Task AssertPermissionReasonAsync(Mission mission, string action, bool granted)
+ {
+ var entries = await ReadLogAsync(mission);
+ var entry = entries.LastOrDefault(e =>
+ e.Kind == MissionLogEntryKind.Permission && e.Action == action);
+ Assert.NotNull(entry);
+ Assert.Equal(granted, entry!.Granted);
+ }
+}
diff --git a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs
index 7a0d83e..d28d327 100644
--- a/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs
+++ b/tests/AAuth.Tests/Integration/MockAccessServerKeycloakTests.cs
@@ -28,7 +28,7 @@ namespace AAuth.Tests.Integration;
/// 2. The user's browser completes /interaction/callback (the AS
/// exchanges the code and asks Keycloak for the uma-ticket decision).
/// 3. The PS polls /pending/{id} → 200 auth_token (allow) or
-/// 403 access_denied (deny), mirroring the PS deferred shape.
+/// 403 denied (deny), mirroring the PS deferred shape.
/// The stub Keycloak grants whoami to anyone and whoami:admin
/// only when the claim_token carries the whoami-admin role.
///
@@ -107,7 +107,7 @@ public async Task InteractiveFlow_DeniesAdminScope_ForNonAdminAgent()
Assert.Equal(HttpStatusCode.Forbidden, poll.StatusCode);
var body = await poll.Content.ReadFromJsonAsync();
- Assert.Equal("access_denied", (string?)body!["error"]);
+ Assert.Equal("denied", (string?)body!["error"]);
}
[Fact]
@@ -296,7 +296,7 @@ protected override async Task SendAsync(
var hasAdminRole = HasAdminRole(form.GetValueOrDefault("claim_token"));
return (!elevated || hasAdminRole)
? Json(HttpStatusCode.OK, new JsonObject { ["result"] = true })
- : Json(HttpStatusCode.Forbidden, new JsonObject { ["error"] = "access_denied" });
+ : Json(HttpStatusCode.Forbidden, new JsonObject { ["error"] = "denied" });
}
return new HttpResponseMessage(HttpStatusCode.BadRequest);
diff --git a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs
index 1c7b630..620fa91 100644
--- a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs
+++ b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs
@@ -128,7 +128,10 @@ public async Task Token_RejectsResourceTokenForDifferentAudience()
["resource_token"] = resourceToken,
});
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ // §Token Endpoint Error Codes: a resource_token that fails verification
+ // (here, aud mismatch) is a 400 invalid_resource_token, not a 401 — 401 is
+ // reserved for request-signature failures carrying a Signature-Error header.
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
@@ -184,7 +187,7 @@ public async Task Token_GrantsElevatedScope_ForAdminAgent()
public async Task Token_DeniesElevatedScope_ForNonAdminAgent()
{
// A non-admin agent requesting whoami:admin is denied by the stub
- // policy (no whoami-admin role) → 403 access_denied.
+ // policy (no whoami-admin role) → 403 denied.
const string GuestId = "aauth:guest@ap.test";
var agentKey = AAuthKey.Generate();
var agentToken = BuildAgentToken(agentKey, GuestId);
@@ -199,7 +202,7 @@ public async Task Token_DeniesElevatedScope_ForNonAdminAgent()
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
- Assert.Equal("access_denied", (string?)body!["error"]);
+ Assert.Equal("denied", (string?)body!["error"]);
}
[Fact]
diff --git a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
index 154ee92..92324df 100644
--- a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
+++ b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
@@ -181,7 +181,7 @@ public async Task Token_RejectsAuthTokenAsCarrier()
var response = await http.PostAsJsonAsync("/token",
new JsonObject { ["resource_token"] = "irrelevant" });
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.Equal("invalid_carrier_token", (string?)body!["error"]);
}
@@ -254,7 +254,10 @@ public async Task Token_RejectsForgedResourceToken()
var response = await http.PostAsJsonAsync("/token",
new JsonObject { ["resource_token"] = forged });
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ // §Token Endpoint Error Codes: invalid_resource_token is a 400 (a bad token
+ // in the body), not a 401 — 401 is reserved for request-signature failures
+ // carrying a Signature-Error header (§Authentication Errors).
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.Equal("invalid_resource_token", (string?)body!["error"]);
}
@@ -303,7 +306,10 @@ public async Task Token_RejectsTamperedResourceToken()
var response = await http.PostAsJsonAsync("/token",
new JsonObject { ["resource_token"] = tampered });
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ // §Token Endpoint Error Codes: invalid_resource_token is a 400 (a bad token
+ // in the body), not a 401 — 401 is reserved for request-signature failures
+ // carrying a Signature-Error header (§Authentication Errors).
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync();
Assert.Equal("invalid_resource_token", (string?)body!["error"]);
}
@@ -468,12 +474,12 @@ public async Task Interaction_PostApproveWithUnknownCode_Returns404()
}
[Fact]
- public async Task Pending_Returns403AccessDenied_AfterDeny()
+ public async Task Pending_Returns403Denied_AfterDeny()
{
// Verifies the deny path: POST /interaction/deny marks the
// pending entry as denied (rather than removing it), and the
// subsequent /pending/{id} poll surfaces a deterministic 403
- // with body { error: "access_denied" }. This is what
+ // with body { error: "denied" }. This is what
// AAuthInteractionDeniedException is keyed off in the SDK.
var agentKey = AAuthKey.Generate();
var agentId = "aauth:denier@ap.example";
@@ -497,12 +503,12 @@ public async Task Pending_Returns403AccessDenied_AfterDeny()
}));
Assert.True(deny.IsSuccessStatusCode);
- // Agent's next poll → 403 access_denied (not 404 / not 202).
+ // Agent's next poll → 403 denied (not 404 / not 202).
var pendingPath = initial.Headers.Location!.OriginalString;
using var pending = await signedClient.GetAsync(pendingPath);
Assert.Equal(HttpStatusCode.Forbidden, pending.StatusCode);
var body = await pending.Content.ReadFromJsonAsync();
- Assert.Equal("access_denied", (string?)body!["error"]);
+ Assert.Equal("denied", (string?)body!["error"]);
}
// ----------------------------------------------------------------
diff --git a/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs b/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs
index 9a4f8bd..4a70f62 100644
--- a/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs
+++ b/tests/AAuth.Tests/Integration/WhoAmIFlowTests.cs
@@ -516,7 +516,7 @@ public async Task ThreePartyUserConsentFlow_ThrowsAAuthInteractionDenied_WhenUse
// Same plumbing as the approval test, but the interaction
// callback simulates the user clicking Deny instead of Approve.
// The PS marks the pending entry as denied and the agent's
- // next /pending/{id} poll receives 403 + access_denied. The
+ // next /pending/{id} poll receives 403 + denied. The
// SDK must surface that as AAuthInteractionDeniedException
// rather than a generic HttpRequestException.
WebApplicationFactory? consentWhoAmI = null;
diff --git a/tests/e2e/helpers/tour.ts b/tests/e2e/helpers/tour.ts
index 7a8a913..12c9480 100644
--- a/tests/e2e/helpers/tour.ts
+++ b/tests/e2e/helpers/tour.ts
@@ -22,6 +22,8 @@ export const TourMode = {
Deferred: 'Deferred',
CallChain: 'CallChain',
Federated: 'Federated',
+ Mission: 'Mission',
+ MissionCallChain: 'MissionCallChain',
} as const;
export type TourMode = (typeof TourMode)[keyof typeof TourMode];
@@ -50,6 +52,14 @@ const PLAN_STEPS: Record = {
// exchange returns 202 (the AS requires consent — its own stub screen or
// Keycloak) the plan expands to 10 (consent + poll), mirroring deferred.
Federated: 7,
+ // Mission (PS-governed): 20 steps across three consent cycles — mission
+ // creation (4/5), the out-of-mission elevated scope token (12/13), and the
+ // out-of-scope delete_inbox permission (18/19).
+ Mission: 20,
+ // Mission + Call Chain: one mission governs a clarified elevated-scope
+ // grant (creation 4/5, elevated 10/11 with a clarification chat at 7/8) and
+ // a silent mission-forwarded call chain (Agent → Orchestrator → WhoAmI).
+ MissionCallChain: 14,
};
/** Select a flow in the `#flow-select` picker and wait for the timeline to reset. */