",
+ _ => string.Empty,
+ };
+ // The mission claim binds requests to a thumbprint (s256); surface the
+ // human-readable description the user approved so they can decide in
+ // context, with the s256 shown only as a verifiable reference. At
+ // creation time the s256 does not exist yet, so show only the prose.
+ var missionLine = isCreation
+ ? $"
";
+ // Show the mission's pre-approved tools so the user can see the full
+ // mission this request sits under (§Mission Approval). For a permission
+ // prompt this also makes plain WHY it needs a decision: the requested
+ // action is not among these approved tools.
+ var toolsLabel = isCreation ? "Tools" : "Approved tools";
+ var toolsLine = approvedTools.Count > 0
+ ? $"
";
+ var heading = isCreation
+ ? "An agent wants to start a new mission"
+ : $"An agent is requesting {(mission.Kind == MissionPendingKind.Token ? "access" : "permission")} under its mission";
+ var intro = isCreation
+ ? "The agent is asking you to approve a durable mission and the tools it may use. This is the authority that every later request will be checked against."
+ : "This request falls outside the agent's pre-approved mission scope, so the Person Server is asking you to decide.";
+ var missionHtml =
+ "Approve a mission request — Person Server"
+ + ""
+ + "
Person Server — mission governance
"
+ + "
localhost:5100 — overseeing what this agent does under its mission
"
+ + missionLine
+ + toolsLine
+ + what
+ + ""
+ + "";
+ return Results.Content(missionHtml, contentType: "text/html");
+ }
+
var entry = pending.Get(code);
if (entry is null)
{
@@ -682,13 +1413,31 @@ static bool IsAdminAgent(string agentId) =>
// consent for the entry's (agent, resource, scope) triple, and shows a
// confirmation page. Idempotent: re-submitting a code whose entry is
// already approved still 200s.
-app.MapPost("/interaction/approve", async (HttpContext ctx, ConsentStore consent, PendingStore pending) =>
+app.MapPost("/interaction/approve", async (HttpContext ctx, ConsentStore consent, PendingStore pending, MissionPendingStore missionPending) =>
{
var code = (await ctx.Request.ReadFormAsync())["code"].ToString();
if (string.IsNullOrEmpty(code))
{
return Results.BadRequest(new { error = "invalid_request", detail = "missing 'code'" });
}
+ // Mission token / permission prompt: record the user's approval so the
+ // agent's next poll resolves to a granted decision (§Missions).
+ var mission = missionPending.Get(code);
+ if (mission is not null)
+ {
+ mission.Decision = true;
+ return Results.Content(
+ "Approved — Person Server"
+ + ""
+ + "
Person Server — mission governance
"
+ + "
Approved
"
+ + $"
You approved {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent will proceed on its next poll.
"
+ + "
You can close this tab.
",
+ contentType: "text/html");
+ }
var entry = pending.Get(code);
if (entry is null)
{
@@ -713,13 +1462,31 @@ static bool IsAdminAgent(string agentId) =>
// Deny handler. Marks the pending entry as denied (rather than removing
// it) so the agent's next poll receives a deterministic
// `403 access_denied` instead of an ambiguous `404 unknown_pending`.
-app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending) =>
+app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending, MissionPendingStore missionPending) =>
{
var code = (await ctx.Request.ReadFormAsync())["code"].ToString();
if (string.IsNullOrEmpty(code))
{
return Results.BadRequest(new { error = "invalid_request", detail = "missing 'code'" });
}
+ // Mission token / permission prompt: record the user's denial so the
+ // agent's next poll resolves to a denied decision (§Missions).
+ var mission = missionPending.Get(code);
+ if (mission is not null)
+ {
+ mission.Decision = false;
+ return Results.Content(
+ "Denied — Person Server"
+ + ""
+ + "
Person Server — mission governance
"
+ + "
Denied
"
+ + $"
You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 access_denied.
"
+ + "
You can close this tab.
",
+ contentType: "text/html");
+ }
var entry = pending.Get(code);
if (entry is null)
{
@@ -744,7 +1511,7 @@ static bool IsAdminAgent(string agentId) =>
// -----------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------
-string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey confirmationKey, JsonObject? upstreamAct = null)
+string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey confirmationKey, JsonObject? upstreamAct = null, MissionClaim? mission = null)
=> new AuthTokenBuilder
{
Issuer = psIssuer,
@@ -758,6 +1525,7 @@ string IssueAuthToken(string agentId, string audience, string scope, IAAuthKey c
Roles = IsAdminAgent(agentId) ? demoRoles : null,
Groups = IsAdminAgent(agentId) ? demoGroups : null,
UpstreamAct = upstreamAct,
+ Mission = mission,
}.Build();
// Peek the `aud` claim of a (possibly unverified) compact JWT without checking
diff --git a/samples/WhoAmI/Program.cs b/samples/WhoAmI/Program.cs
index 6761fce..a1d8039 100644
--- a/samples/WhoAmI/Program.cs
+++ b/samples/WhoAmI/Program.cs
@@ -120,6 +120,21 @@
DefaultScopes = scope,
};
+// Challenge options for a mission-aware endpoint (§Terminology: "a mission-aware
+// resource includes the mission object from the AAuth-Mission header in the
+// resource tokens it issues"). When the agent sends a signed AAuth-Mission
+// header, the issued resource token carries the mission object (approver +
+// s256), so the agent's PS can govern the exchange against that mission.
+ChallengeOptions ChallengeForMission(string scope) => new()
+{
+ AccessMode = AAuthAccessMode.RequireAuthToken,
+ ResourceSigningKey = resourceKey,
+ ResourceKeyId = ResourceKid,
+ ResourceIdentifier = resourceUrl,
+ DefaultScopes = scope,
+ MissionAware = true,
+};
+
// Verification options for the four-party (federated) flow. The auth token is
// issued by the AS (iss = AS, dwk = aauth-access.json), so the AS is the
// trusted auth-token issuer here — not the PS.
@@ -165,6 +180,18 @@
ctx => ctx.Request.Path.StartsWithSegments("/jwks-uri"),
branch => branch.UseAAuthVerification(SignatureOnly()));
+// /jwt/mission — three-party mission-aware: full verification + a mission-aware
+// challenge. When the agent presents a signed AAuth-Mission header, the issued
+// resource token carries the mission object (approver + s256), so the agent's
+// PS governs the token exchange against that mission (§Terminology, §Missions).
+app.UseWhen(
+ ctx => ctx.Request.Path.StartsWithSegments("/jwt/mission"),
+ branch =>
+ {
+ branch.UseAAuthVerification(FullVerification());
+ branch.UseAAuthChallenge(ChallengeForMission(ScopeWhoami));
+ });
+
// /jwt/admin — three-party elevated: full verification + challenge for the
// elevated `whoami:admin` scope.
app.UseWhen(
@@ -198,7 +225,8 @@
app.UseWhen(
ctx => ctx.Request.Path.StartsWithSegments("/jwt")
&& !ctx.Request.Path.StartsWithSegments("/jwt/admin")
- && !ctx.Request.Path.StartsWithSegments("/jwt/roles"),
+ && !ctx.Request.Path.StartsWithSegments("/jwt/roles")
+ && !ctx.Request.Path.StartsWithSegments("/jwt/mission"),
branch =>
{
branch.UseAAuthVerification(FullVerification());
@@ -232,6 +260,7 @@
new { path = "/jkt-jwt", mode = "pseudonymous (key delegation)", auth = "signature only" },
new { path = "/jwks-uri", mode = "agent-identity", auth = "AAuth.Identified" },
new { path = "/jwt", mode = "three-party", auth = "AAuth.Scope.whoami" },
+ new { path = "/jwt/mission", mode = "three-party (mission-aware)", auth = "AAuth.Scope.whoami" },
new { path = "/jwt/admin", mode = "three-party (step-up)", auth = "AAuth.Scope.whoami:admin" },
new { path = "/jwt/roles", mode = "three-party (RBAC)", auth = "AAuth.Role.whoami-admin" },
new { path = "/federated", mode = "four-party", auth = "AAuth.Scope.whoami" },
@@ -320,6 +349,39 @@
});
}).RequireAuthorization("AAuth.Scope.whoami");
+// -----------------------------------------------------------------------
+// GET /jwt/mission — Three-party mission-aware access.
+//
+// This endpoint is mission-aware: when the agent sends a signed AAuth-Mission
+// header, the challenge issues a resource token carrying the mission object
+// (approver + s256). The agent's PS then governs the token exchange against
+// that mission, and the resulting auth token echoes the mission claim back —
+// surfaced here so the demo can show the mission round-tripping end to end
+// (§Terminology, §Missions, §Auth Token Structure). An agent without a
+// mission still gets the baseline `whoami` access (mission = null).
+// -----------------------------------------------------------------------
+app.MapGet("/jwt/mission", (HttpContext ctx) =>
+{
+ var result = ctx.GetAAuthVerification()!;
+ var parsed = ctx.GetAAuthParsedKey()!;
+ var mission = parsed.Payload?["mission"];
+
+ return Results.Ok(new
+ {
+ mode = "three-party",
+ scheme = "jwt",
+ access = "mission",
+ agent = result.Agent,
+ sub = result.Subject,
+ scope = result.Scopes,
+ iss = result.Issuer,
+ // The mission object (approver + s256) the PS embedded in the auth
+ // token, or null when the agent operated without a mission.
+ mission,
+ missionAware = true,
+ });
+}).RequireAuthorization("AAuth.Scope.whoami");
+
// -----------------------------------------------------------------------
// GET /jwt/admin — Three-party elevated access (step-up scope).
// Requires an auth token carrying the elevated `whoami:admin` scope.
diff --git a/src/AAuth/Agent/Mission.cs b/src/AAuth/Agent/Mission.cs
index 174154c..0e4a4dc 100644
--- a/src/AAuth/Agent/Mission.cs
+++ b/src/AAuth/Agent/Mission.cs
@@ -180,4 +180,35 @@ public static string FormatStructured(string approver, string s256)
ArgumentException.ThrowIfNullOrEmpty(s256);
return $"approver=\"{approver}\"; s256=\"{s256}\"";
}
+
+ ///
+ /// Parse a structured AAuth-Mission header value into its
+ /// approver and s256 components (§Call Chaining). Returns
+ /// when the value is absent or either field is missing.
+ ///
+ public static bool TryParseStructured(string? value, out string? approver, out string? s256)
+ {
+ approver = null;
+ s256 = null;
+ if (string.IsNullOrWhiteSpace(value))
+ return false;
+
+ foreach (var part in value.Split(';'))
+ {
+ var trimmed = part.Trim();
+ var eq = trimmed.IndexOf('=');
+ if (eq <= 0)
+ continue;
+ var name = trimmed[..eq].Trim();
+ var raw = trimmed[(eq + 1)..].Trim().Trim('"');
+ if (raw.Length == 0)
+ continue;
+ if (name.Equals("approver", StringComparison.OrdinalIgnoreCase))
+ approver = raw;
+ else if (name.Equals("s256", StringComparison.OrdinalIgnoreCase))
+ s256 = raw;
+ }
+
+ return !string.IsNullOrEmpty(approver) && !string.IsNullOrEmpty(s256);
+ }
}
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/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.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
new file mode 100644
index 0000000..67b163f
--- /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(PsIssuer, 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(PsIssuer, "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("delete_file")
+ {
+ Mission = new MissionClaim(mission.Approver, mission.S256),
+ };
+ var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, 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("delete_file")
+ {
+ Mission = new MissionClaim(mission.Approver, mission.S256),
+ };
+ var result = await PermissionClientFor(agent).RequestAsync(PsIssuer, 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);
+
+ private PermissionClient PermissionClientFor(Agent agent) => new(agent.Signed, agent.Metadata);
+
+ 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(PsIssuer, 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);
+ }
+}
From ee5ff130c9cc60a02cca53296cc14e179791d2fb Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Fri, 5 Jun 2026 21:50:06 +0000
Subject: [PATCH 07/24] feat(missions): add --pre-approve scope arg to
MissionAgent
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add repeatable --pre-approve CLI flag that seeds the
(resource origin, scope) pair as in-scope before the mission is
proposed, so the matching token request resolves silently at gate 2a
(reason InScope) instead of prompting (§Agent Token Request).
- Pre-approving the WhoAmI scope drops the token consent screen: an
interactive run shows two browser screens (mission creation +
delete_inbox permission) instead of three.
- Adapt step 3/4 narration to show IN SCOPE (silent) vs OUT OF SCOPE,
and document the flag in the README Options table.
Phase 6a of missions/PS governance.
---
samples/MissionAgent/Program.cs | 54 ++++++++++++++++++++++++++++-----
samples/MissionAgent/README.md | 19 ++++++++++++
2 files changed, 65 insertions(+), 8 deletions(-)
diff --git a/samples/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs
index ef14afd..f6f44c5 100644
--- a/samples/MissionAgent/Program.cs
+++ b/samples/MissionAgent/Program.cs
@@ -43,13 +43,22 @@
// =============================================================================
const string Usage =
- "Usage: MissionAgent [--ap ] [--ps ] [--resource ] [--sub ] [--auto]";
+ "Usage: MissionAgent [--ap ] [--ps ] [--resource ] [--sub ]\n"
+ + " [--pre-approve ]... [--auto]";
+
+// The scope WhoAmI's /jwt/mission resource demands (and therefore the scope the
+// PS gates the token request on). Pre-approving this scope makes step 3 silent.
+const string ResourceScope = "whoami";
string apUrl = "http://localhost:5301";
string personServer = "http://localhost:5100";
string resourceUrl = "http://localhost:5000/jwt/mission";
string subject = "aauth:mission-demo@ap.example";
bool interactive = true;
+// Scopes the user pre-approves as within the mission's intent (§Agent Token
+// Request, gate 2a). Seeding one lets the first resource access resolve
+// *silently* (reason = InScope) instead of prompting — one fewer consent screen.
+var preApprovedScopes = new List();
for (int i = 0; i < args.Length; i++)
{
@@ -61,7 +70,7 @@
case "--auto":
interactive = false;
break;
- case "--ap" or "--ps" or "--resource" or "--sub":
+ case "--ap" or "--ps" or "--resource" or "--sub" or "--pre-approve":
if (i + 1 >= args.Length)
{
Console.Error.WriteLine($"Missing value for {args[i]}.");
@@ -74,6 +83,7 @@
case "--ps": personServer = value; break;
case "--resource": resourceUrl = value; break;
case "--sub": subject = value; break;
+ case "--pre-approve": preApprovedScopes.Add(value); break;
}
break;
default:
@@ -86,6 +96,12 @@
apUrl = apUrl.TrimEnd('/');
personServer = personServer.TrimEnd('/');
+// The PS gates the token request on the resource token's (iss, scope). The
+// resource's iss is the WhoAmI *origin* (not the /jwt/mission path), so seed
+// the in-scope set against the origin to match what the PS will compare.
+var resourceOrigin = new Uri(resourceUrl).GetLeftPart(UriPartial.Authority);
+bool resourceScopePreApproved = preApprovedScopes.Contains(ResourceScope, StringComparer.Ordinal);
+
Section("1. Enrol with the Agent Provider");
// The agent's signing key is long-lived (it spans the agent install). The
// keystore holds the private key; we keep only its handle in memory here.
@@ -116,6 +132,13 @@
// Tell the mock PS how to resolve prompts. Interactive mode holds each prompt
// open until you decide in the browser; --auto resolves via scripted defaults.
+// Any --pre-approve scopes are seeded as in-scope (resource origin, scope) pairs
+// so the matching token request resolves silently at gate 2a (§Agent Token Request).
+var inScopeSeed = new JsonArray();
+foreach (var scope in preApprovedScopes)
+{
+ inScopeSeed.Add(new JsonObject { ["resource"] = resourceOrigin, ["scope"] = scope });
+}
await ScriptAsync(new JsonObject
{
["reset"] = true,
@@ -123,8 +146,13 @@ await ScriptAsync(new JsonObject
["approveMission"] = true,
["approveToken"] = true,
["approvePermission"] = true,
+ ["inScope"] = inScopeSeed,
});
Console.WriteLine($" prompt mode : {(interactive ? "interactive (decide in your browser)" : "auto (scripted approvals)")}");
+if (preApprovedScopes.Count > 0)
+{
+ Console.WriteLine($" pre-approved : {string.Join(", ", preApprovedScopes.Select(s => $"{resourceOrigin} / {s}"))} (in scope — no prompt)");
+}
// Generous polling budget so a human has time to click Approve.
var poller = new DeferredPollerOptions { MaxTotalWait = TimeSpan.FromMinutes(5) };
@@ -154,11 +182,18 @@ await ScriptAsync(new JsonObject
// approver, so a leaked token never exposes the mission's prose.
Console.WriteLine($" mission s256 : {mission.S256} (thumbprint reference to the description above)");
-Section("3. Access a mission-aware resource — first call is OUT OF SCOPE");
+Section(resourceScopePreApproved
+ ? "3. Access a mission-aware resource — IN SCOPE (silent, no prompt)"
+ : "3. Access a mission-aware resource — first call is OUT OF SCOPE");
// WhoAmI's /jwt/mission endpoint is mission-aware: it copies the mission claim
// from the AAuth-Mission header into the resource token it issues (§Terminology).
-// The PS reads that claim and governs the token request. This (resource, scope)
-// is not in the mission's pre-approved scope, so the PS prompts the user.
+// The PS reads that claim and governs the token request. When this (resource,
+// scope) was pre-approved as in-scope it resolves silently at gate 2a; otherwise
+// it falls outside the mission's pre-approved scope and the PS prompts the user.
+if (resourceScopePreApproved)
+{
+ Console.WriteLine($" pre-approved : {resourceOrigin} / {ResourceScope} was seeded in-scope, so this is granted silently (gate 2a — InScope)");
+}
var first = await AccessMissionResourceAsync();
Console.WriteLine($" resource said : access={first?["access"]}, scope={first?["scope"]}");
// The resource echoes only the {approver, s256} reference from the token — the
@@ -166,9 +201,12 @@ await ScriptAsync(new JsonObject
Console.WriteLine($" echoed mission : {first?["mission"]?.ToJsonString()}");
Console.WriteLine($" (s256 references: \"{mission.Description}\")");
-Section("4. Access it again — now silent via PRIOR CONSENT");
-// The same (resource, scope) was just approved under this mission, so the PS
-// grants the token silently this time (gate 2b) — no prompt.
+Section(resourceScopePreApproved
+ ? "4. Access it again — still silent (IN SCOPE)"
+ : "4. Access it again — now silent via PRIOR CONSENT");
+// Either the (resource, scope) is in the mission's in-scope set (gate 2a) or it
+// was just approved under this mission (gate 2b prior consent); either way the
+// PS grants the token silently this time — no prompt.
var second = await AccessMissionResourceAsync();
Console.WriteLine($" resource said : access={second?["access"]}, scope={second?["scope"]} (granted silently)");
diff --git a/samples/MissionAgent/README.md b/samples/MissionAgent/README.md
index 36a495c..0d3385b 100644
--- a/samples/MissionAgent/README.md
+++ b/samples/MissionAgent/README.md
@@ -183,6 +183,24 @@ via the PS's scripted defaults:
dotnet run --project samples/MissionAgent -- --auto
```
+### Pre-approving a scope (one fewer consent screen)
+
+The first resource access prompts because the `(resource, scope)` it needs isn't
+yet on the mission's silent-allow list (gate 3). You can declare a scope as
+**in scope** up front with `--pre-approve `; the PS then grants that
+token request **silently** at gate 2a (reason `InScope`) and no token consent
+screen appears:
+
+```bash
+# interactive: only TWO browser screens (mission creation + the delete_inbox
+# permission) instead of three — the WhoAmI token gate is now silent
+dotnet run --project samples/MissionAgent -- --pre-approve whoami
+```
+
+The scope is seeded against the resource's **origin** (`http://localhost:5000`),
+which is what the PS compares against the resource token's `iss`. Pass
+`--pre-approve` more than once to seed several scopes.
+
## Options
| Flag | Default | Description |
@@ -191,4 +209,5 @@ dotnet run --project samples/MissionAgent -- --auto
| `--ps ` | `http://localhost:5100` | Person Server base URL |
| `--resource ` | `http://localhost:5000/jwt/mission` | Mission-aware resource endpoint |
| `--sub ` | `aauth:mission-demo@ap.example` | Agent identifier to enrol as |
+| `--pre-approve ` | _(none)_ | Seed `(resource origin, scope)` as in-scope so its token request is granted silently (gate 2a); repeatable |
| `--auto` | _(off)_ | Resolve prompts via scripted PS defaults instead of waiting for a browser decision |
From a31a09a8ad28f79719aee2e8a1d5405c8aae4d63 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Fri, 5 Jun 2026 21:59:04 +0000
Subject: [PATCH 08/24] feat(missions): enhance Makefile and README for
agent-mission with PRE_APPROVE support
---
Makefile | 7 ++++---
samples/MissionAgent/README.md | 27 +++++++++++++++++++++++++++
2 files changed, 31 insertions(+), 3 deletions(-)
diff --git a/Makefile b/Makefile
index 1d61562..e473b44 100644
--- a/Makefile
+++ b/Makefile
@@ -241,7 +241,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent
@echo ""
@echo " Drive it from another terminal with: make agent-mission"
@echo " (out-of-scope prompts open the PS consent page in your browser;"
- @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults)"
+ @echo " add AUTO=1 to resolve prompts via the PS's scripted defaults;"
+ @echo " add PRE_APPROVE=whoami to seed an in-scope grant — one fewer prompt)"
@echo "------------------------------------------------------------------"
@echo ""
@trap 'echo; echo "Stopping..."; kill 0' INT TERM; \
@@ -250,8 +251,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent
$(DOTNET) run --project $(AP_PROJECT) & \
wait
-agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts)
- $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,)
+agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; PRE_APPROVE= to seed an in-scope grant)
+ $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(if $(PRE_APPROVE),--pre-approve $(PRE_APPROVE),)
# ----------------------------------------------------------------------------
# End-to-end (Playwright)
diff --git a/samples/MissionAgent/README.md b/samples/MissionAgent/README.md
index 0d3385b..c350c2d 100644
--- a/samples/MissionAgent/README.md
+++ b/samples/MissionAgent/README.md
@@ -171,6 +171,30 @@ Then run the agent:
dotnet run --project samples/MissionAgent
```
+### Using the Makefile
+
+The repo ships two convenience targets. Start the backend stack (AP + PS +
+WhoAmI) in one terminal:
+
+```bash
+make demo-mission
+```
+
+Then drive the agent from another terminal:
+
+```bash
+make agent-mission # interactive (decide in your browser)
+make agent-mission PRE_APPROVE=whoami # interactive, but seed an in-scope grant — one fewer browser screen
+make agent-mission AUTO=1 # unattended (scripted PS defaults — no browser screens)
+```
+
+`PRE_APPROVE=` maps to `--pre-approve ` and `AUTO=1` maps to
+`--auto` (both described below). `PRE_APPROVE` is most useful in **interactive**
+runs, where it removes a browser consent screen. Under `AUTO=1` there are no
+screens to remove, so it only changes the PS's decision reason (silent `InScope`
+at gate 2a instead of a scripted out-of-scope approval) — handy for tests, not
+for a human watching.
+
By default each out-of-scope prompt is **interactive**: the agent prints the
Person Server's consent URL (and tries to open it) and waits while you click
**Approve** or **Deny** in your browser. The PS holds the request at `202` until
@@ -195,6 +219,9 @@ screen appears:
# interactive: only TWO browser screens (mission creation + the delete_inbox
# permission) instead of three — the WhoAmI token gate is now silent
dotnet run --project samples/MissionAgent -- --pre-approve whoami
+
+# or, via the Makefile:
+make agent-mission PRE_APPROVE=whoami
```
The scope is seeded against the resource's **origin** (`http://localhost:5000`),
From 5fe680912da518d444df04dfeea367a8910e73d0 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sat, 6 Jun 2026 16:48:21 +0000
Subject: [PATCH 09/24] feat(missions): add out-of-mission scope gate, e2e
validation, fix agent-token replay
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Add the prompted out-of-mission scope gate (gate 3) so both halves of the
spec gate model are demonstrated: a prompted scope (§Agent Token Request
gate 3, §Scopes) alongside the prompted tool (§Permission Endpoint). WhoAmI
gains scope whoami:elevated_scope at /jwt/mission/elevated.
- SampleApp Mission.razor renders the full 5-gate flow (+ Home link);
GuidedTour TourMode.Mission drives every gate through both outcomes with the
PS decision reason surfaced.
- Fix GuidedTour mission hang: refresh the agent token (fresh jti) per resource
access so /jwt/mission/elevated is not rejected as a replay, restoring the
202 out-of-mission-scope prompt (§Agent Token replay protection). Remove
temporary DIAG instrumentation from TourSession and AAuthChallengeMiddleware.
- Default MissionAgent in-scope set to whoami (mirrors SampleApp) and rename
--pre-approve to --mission-approved (Makefile MISSION_APPROVED).
- Add SampleApp + GuidedTour mission Playwright specs; update picker spec for
the 7th (Mission) flow. MockPersonServer consent-screen UX refinements (D9).
Phase 6b of missions/PS governance.
---
.../implementation-plan.md | 110 ++-
.../research.md | 120 +++
Makefile | 11 +-
docs/concepts.md | 9 +-
samples/GuidedTour/CodeSnippets.cs | 129 +++
.../GuidedTour/Components/Pages/Tour.razor | 28 +-
samples/GuidedTour/TourOptions.cs | 1 +
samples/GuidedTour/TourSession.cs | 899 +++++++++++++++++-
.../playwright-tests/mission.spec.ts | 137 +++
.../playwright-tests/picker.spec.ts | 22 +-
samples/MissionAgent/Program.cs | 106 ++-
samples/MissionAgent/README.md | 160 ++--
samples/MockPersonServer/MissionGovernance.cs | 13 +
samples/MockPersonServer/Program.cs | 68 +-
.../SampleApp/Components/Layout/NavMenu.razor | 6 +
.../Components/Layout/NavMenu.razor.css | 4 +
samples/SampleApp/Components/Pages/Home.razor | 20 +
.../SampleApp/Components/Pages/Mission.razor | 667 +++++++++++++
.../playwright-tests/mission.spec.ts | 151 +++
samples/WhoAmI/Program.cs | 59 +-
tests/e2e/helpers/tour.ts | 5 +
21 files changed, 2588 insertions(+), 137 deletions(-)
create mode 100644 samples/GuidedTour/playwright-tests/mission.spec.ts
create mode 100644 samples/SampleApp/Components/Pages/Mission.razor
create mode 100644 samples/SampleApp/playwright-tests/mission.spec.ts
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
index d8b98ef..35cdc91 100644
--- a/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md
+++ b/.agent/plans/2026-06-05-missions-ps-governance/implementation-plan.md
@@ -457,12 +457,12 @@ rewrite of existing flows.
`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:** MockPersonServer governance endpoints + `s256`
+ - **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:** SampleApp `Mission.razor` (+ Home link); GuidedTour
+ - **6b — Blazor + e2e (DONE):** SampleApp `Mission.razor` (+ Home link); GuidedTour
`TourMode.Mission` (+ snippets, sequence diagram); the two Playwright specs.
- - **6c — Glue:** Orchestrator mission hop; `make demo-mission` / `e2e-mission`;
+ - **6c — Glue (PENDING):** Orchestrator mission hop; `make demo-mission` / `e2e-mission`;
READMEs + `tests/e2e/package.json` script.
- **Deterministic consent scripting (agreed 2026-06-05 — option A):** the
integration test drives mission-approval / token / permission outcomes by
@@ -509,27 +509,28 @@ mission-log **decision reason**.
- [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)_
-- [ ] SampleApp `Mission.razor` page renders the flow and labels each consent gate
+- [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)_
-- [ ] GuidedTour `TourMode.Mission` drives every gate through **both** outcomes
+- [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)_
-- [ ] The PS decision reason (in-scope / prior consent / `approved_tools` /
+- [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)_
- [x] `make demo-mission` boots the mission demo; existing `make demo` unchanged.
_(pulled forward from 6c; also added `agent-mission` runner)_
-- [ ] New GuidedTour + SampleApp mission Playwright **e2e** specs pass under the
- existing `guided-tour`/`sample-app` projects; existing specs still pass. _(6b)_
+- [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)_
-- [ ] `make e2e` (Blazor) and `dotnet test` (CLI integration) green locally and in CI.
- _(CLI integration green; Blazor e2e in 6b)_
+- [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)_
- [ ] Sample READMEs updated. _(MissionAgent + MockPersonServer done in 6a; others in 6c)_
#### Phase 6a additions (spec-driven, beyond the original file list)
@@ -551,9 +552,94 @@ mission-log **decision reason**.
§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
-## Phase 7 — Docs
+| 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)_
+
+---
**Goal:** Rewrite the stale missions doc and add PS-governance docs reflecting the
implemented surface. Separate phase from samples per the agreed workflow.
diff --git a/.agent/plans/2026-06-05-missions-ps-governance/research.md b/.agent/plans/2026-06-05-missions-ps-governance/research.md
index afadcc3..40e514f 100644
--- a/.agent/plans/2026-06-05-missions-ps-governance/research.md
+++ b/.agent/plans/2026-06-05-missions-ps-governance/research.md
@@ -651,3 +651,123 @@ under `docs/server/` + refresh `docs/advanced/missions.md` + add a
- **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.
+
diff --git a/Makefile b/Makefile
index e473b44..7af2c7f 100644
--- a/Makefile
+++ b/Makefile
@@ -240,9 +240,12 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent
@echo " MockAgentProvider: $(AP_URL) (agent registry)"
@echo ""
@echo " Drive it from another terminal with: make agent-mission"
- @echo " (out-of-scope prompts open the PS consent page in your browser;"
+ @echo " (the whoami token gate is mission-approved by default, so it is silent;"
+ @echo " the elevated whoami:elevated_scope step is out of the mission and"
+ @echo " prompts on its own;"
@echo " add AUTO=1 to resolve prompts via the PS's scripted defaults;"
- @echo " add PRE_APPROVE=whoami to seed an in-scope grant — one fewer prompt)"
+ @echo " set MISSION_APPROVED to replace the default in-scope set, e.g."
+ @echo " MISSION_APPROVED='whoami whoami:elevated_scope' to silence the elevated step too)"
@echo "------------------------------------------------------------------"
@echo ""
@trap 'echo; echo "Stopping..."; kill 0' INT TERM; \
@@ -251,8 +254,8 @@ demo-mission: ## Start the mission stack (AP + PS + WhoAmI) for the MissionAgent
$(DOTNET) run --project $(AP_PROJECT) & \
wait
-agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; PRE_APPROVE= to seed an in-scope grant)
- $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(if $(PRE_APPROVE),--pre-approve $(PRE_APPROVE),)
+agent-mission: ## Drive the MissionAgent CLI through the full mission lifecycle (AUTO=1 for scripted prompts; MISSION_APPROVED="..." to replace the default in-scope set)
+ $(DOTNET) run --project $(MISSION_PROJECT) -- $(if $(AUTO),--auto,) $(foreach scope,$(MISSION_APPROVED),--mission-approved $(scope))
# ----------------------------------------------------------------------------
# End-to-end (Playwright)
diff --git a/docs/concepts.md b/docs/concepts.md
index c0a65f6..13b22b9 100644
--- a/docs/concepts.md
+++ b/docs/concepts.md
@@ -41,9 +41,16 @@ Four modes:
### 3. Governance (Missions)
-Optional layer. Agent proposes missions; PS approves and scopes permissions.
+Optional layer. The agent proposes a mission — a Markdown **description** of intent plus an optional list of **tools** — and the PS approves it (§Mission Creation, §Mission Approval).
SDK: `Mission`, `AAuthMissionHeader`
+The two kinds of authority a mission governs are handled **asymmetrically**, and this is the key idea:
+
+- **Tools are *declared*.** A tool is an action the agent runs **itself** (a tool call, file write, sending a message) — no resource is involved. Because the PS can't observe a local action, the mission must name the tools up front: the approved `approved_tools` are pre-approved and resolve at the **permission endpoint** without a PS round-trip; any other action is referred to the user (§Permission Endpoint). SDK: `Mission.ApprovedTools`, `PermissionClient`.
+- **Scopes are *evaluated*, never declared.** A scope authorizes access to a remote **resource** (an API), carried in an **auth token** via the challenge → exchange → retry pattern (§Scopes). A mission proposal contains **no scopes**. Instead, when the agent later exchanges a resource token, the PS judges that requested scope *against the mission's natural-language description*: if it fits the stated intent it is granted silently (gate 2a), and prior decisions are remembered for the rest of the mission; otherwise the user is prompted (§Scopes — *"The PS evaluates requested scopes against mission context"*; §Agent Token Request). SDK: `AAuthScopeRequirement`, `AAuthVerificationResult.Scopes`.
+
+In short: **a mission lists the tools the agent may run locally, but it does not list scopes — the PS decides, per request, whether a requested resource scope fits the mission's intent.** Scopes and AS policy stay enforced by the resource and its Access Server; the mission is "a further restriction applied by the PS" (§Rationale).
+
See [Missions](https://explorer.aauth.dev/missions/compare).
## Token Types
diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs
index 9ce261b..693c920 100644
--- a/samples/GuidedTour/CodeSnippets.cs
+++ b/samples/GuidedTour/CodeSnippets.cs
@@ -232,4 +232,133 @@ internal static class CodeSnippets
var response = await client.GetAsync("https://resource.example/data");
// 401 → exchange → poll → retry all handled transparently
""";
+
+ // ── Mission-governed flow (§Missions, §PS Governance Endpoints) ──────────
+
+ public const string MissionDiscoverPs = """
+ // GET /.well-known/aauth-person.json
+ var meta = await metadata.FetchAsync(
+ "https://ps.example/.well-known/aauth-person.json");
+ var mission = (string)meta["mission_endpoint"];
+ var tokenEp = (string)meta["token_endpoint"];
+ var permission = (string)meta["permission_endpoint"];
+ """;
+
+ public const string MissionPropose = """
+ var governance = new AAuthGovernanceClient(signedClient, metadata);
+ var mission = await governance.Mission.ProposeAsync(
+ "https://ps.example",
+ new MissionProposal("Triage the user's inbox…")
+ {
+ Tools =
+ {
+ new MissionTool("summarize"),
+ new MissionTool("send_email"),
+ },
+ },
+ new GovernanceOptions { OnInteractionRequired = SurfaceToUser });
+ // SDK POSTs /mission → 202; SurfaceToUser shows the consent link,
+ // then the client polls until the user approves.
+ """;
+
+ public const string MissionPollCreate = """
+ // The MissionClient polls the mission-pending URL internally and
+ // returns the parsed, verified Mission once the user approves.
+ // mission.Approver / mission.S256 / mission.ApprovedTools
+ var verified = mission.VerifyS256(missionHeaderS256); // s256 integrity
+ """;
+
+ public const string MissionChallenge = """
+ // Advertise the mission so the resource binds it into the resource_token.
+ using var req = new HttpRequestMessage(HttpMethod.Get, resourceUrl);
+ req.Headers.Add(
+ AAuthMissionHeader.Name,
+ AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256));
+ var resp = await signedClient.SendAsync(req); // → 401 + AAuth-Requirement
+ var resourceToken = AAuthRequirementHeader.Parse(
+ resp.Headers.GetValues(AAuthRequirementHeader.Name).First()).ResourceToken;
+ """;
+
+ public const string MissionExchange = """
+ // The resource_token carries the mission claim; because (resource, whoami)
+ // is in the mission scope, the PS mints the auth_token SILENTLY.
+ var authToken = await exchange.ExchangeAsync("https://ps.example", resourceToken);
+ // Or, end-to-end: AAuthClientBuilder handles 401 → exchange → retry for you.
+ """;
+
+ public const string MissionReplay = """
+ using var client = new AAuthClientBuilder(key)
+ .WithTokenRefresh(() => authToken)
+ .Build();
+ var data = await client.GetAsync(resourceUrl); // 200 + mission round-tripped
+ """;
+
+ public const string MissionElevatedChallenge = """
+ // Same mission header, but the ELEVATED endpoint requires
+ // whoami:elevated_scope — a scope the mission never declared.
+ using var req = new HttpRequestMessage(HttpMethod.Get, elevatedUrl);
+ req.Headers.Add(AAuthMissionHeader.Name,
+ AAuthMissionHeader.FormatStructured(mission.Approver, mission.S256));
+ var resp = await signedClient.SendAsync(req); // → 401 + AAuth-Requirement
+ var resourceToken = AAuthRequirementHeader.Parse(
+ resp.Headers.GetValues(AAuthRequirementHeader.Name).First()).ResourceToken;
+ """;
+
+ public const string MissionElevatedExchange = """
+ // whoami:elevated_scope is OUTSIDE the mission's intent, so the PS
+ // cannot mint silently — it returns 202 and asks the user to decide.
+ // (Out-of-mission scopes prompt; they are never auto-denied.)
+ var authToken = await exchange.ExchangeAsync("https://ps.example", resourceToken,
+ new TokenExchangeRequest { OnInteractionRequired = SurfaceToUser });
+ """;
+
+ public const string MissionElevatedPoll = """
+ // Once the user approves, the poll returns the elevated auth_token.
+ // The consent accrues to the mission, so a later elevated request
+ // would resolve silently.
+ var data = await elevatedClient.GetAsync(elevatedUrl); // 200
+ """;
+
+ public const string MissionElevatedReplay = """
+ using var client = new AAuthClientBuilder(key)
+ .WithTokenRefresh(() => elevatedAuthToken)
+ .Build();
+ var data = await client.GetAsync(elevatedUrl); // 200 + elevated claims
+ """;
+
+ public const string MissionPreApproved = """
+ // Pre-approved tools never hit the network — the SDK short-circuits.
+ var result = await governance.Permission.RequestAsync(
+ "https://ps.example", "send_email", mission);
+ // result.IsGranted == true (no PS call: send_email ∈ mission.ApprovedTools)
+ """;
+
+ public const string MissionPermissionPrompt = """
+ // delete_inbox is NOT pre-approved → the PS prompts the user.
+ var result = await governance.Permission.RequestAsync(
+ "https://ps.example",
+ new PermissionRequest("delete_inbox") { Mission = missionClaim },
+ new GovernanceOptions { OnInteractionRequired = SurfaceToUser });
+ // SDK POSTs /permission → 202; surfaces the link; polls for the decision.
+ """;
+
+ public const string MissionPollPermission = """
+ // The poll returns a DECISION, not a token. The gate-2 auth_token is
+ // unaffected by whatever the user chooses here.
+ if (!result.IsGranted)
+ throw new InvalidOperationException(result.Reason); // user denied
+ // On grant: run delete_inbox, then report it to the audit_endpoint.
+ await governance.Audit.RecordAsync("https://ps.example",
+ new AuditRecord(missionClaim, "delete_inbox"));
+ """;
+
+ public const string MissionInspect = """
+ // One mission approval governed the whole session:
+ // gate 1 mission creation .... PROMPT
+ // gate 2 whoami token ........ SILENT (in scope)
+ // gate 3 elevated scope ...... PROMPT (out of mission scope)
+ // gate 4 send_email tool ..... SILENT (pre-approved, local)
+ // gate 5 delete_inbox action . PROMPT (out of scope)
+ // The PS is the policy-enforcement point; the resource stays oblivious.
+ """;
}
diff --git a/samples/GuidedTour/Components/Pages/Tour.razor b/samples/GuidedTour/Components/Pages/Tour.razor
index 6ea710d..2f514cd 100644
--- a/samples/GuidedTour/Components/Pages/Tour.razor
+++ b/samples/GuidedTour/Components/Pages/Tour.razor
@@ -40,6 +40,7 @@
+
@if (Session.Mode == TourMode.Identity)
@@ -152,6 +153,19 @@
login/consent).
break;
+ case TourMode.Mission:
+
+ Signing mode: Agent Token (sig=jwt) — the Person Server
+ acts as the policy-enforcement point. The agent first proposes a durable
+ mission (description + pre-approved tools) which the user approves once;
+ every later request is then checked against it. An in-scope token (whoami)
+ is minted silently; an out-of-mission scope (whoami:elevated_scope)
+ prompts; a pre-approved tool (send_email) is
+ resolved locally with no PS call; and an out-of-scope action
+ (delete_inbox) prompts again. Three human approvals:
+ creating the mission, the out-of-mission scope, and the out-of-scope action — everything in between flows without friction.
+
+ break;
}
@@ -186,8 +200,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) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)"
+ LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? Session.PollStepNumber : 0)"
CompletedLoopLabel="@CompletedLoopLabel()"
LoopCompletedKind="@CompletedLoopKind()" />
@@ -238,11 +252,19 @@
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 SequenceDiagram.LaneDefinition[]? ActiveLanes =>
Session.IsBootstrapMode ? BootstrapLanes :
Session.IsIdentityMode ? IdentityLanes :
Session.IsCallChainMode ? CallChainLanes :
- Session.IsFederatedMode ? FederatedLanes : null;
+ Session.IsFederatedMode ? FederatedLanes :
+ Session.IsMissionMode ? MissionLanes : null;
protected override async Task OnInitializedAsync()
{
diff --git a/samples/GuidedTour/TourOptions.cs b/samples/GuidedTour/TourOptions.cs
index 1ec7bb6..207a8dd 100644
--- a/samples/GuidedTour/TourOptions.cs
+++ b/samples/GuidedTour/TourOptions.cs
@@ -19,6 +19,7 @@ public enum TourMode
Deferred,
CallChain,
Federated,
+ Mission,
}
///
diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs
index 5d26592..d4fc74e 100644
--- a/samples/GuidedTour/TourSession.cs
+++ b/samples/GuidedTour/TourSession.cs
@@ -49,6 +49,17 @@ 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;
+
// 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 +140,21 @@ 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";
+
///
/// True when the current flow is the identity-based path. Forced on
/// when no PS URL is configured, regardless of .
@@ -152,6 +178,9 @@ 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 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 +205,7 @@ public int TotalSteps
{
if (IsBootstrapMode) return HasAgentProvider ? 3 : 2;
if (IsIdentityMode) return 2;
+ if (IsMissionMode) return 20;
if (IsCallChainMode) return _callChainPending ? 13 : 7;
if (IsFederatedMode) return _federatedPending ? 10 : 7;
return IsDeferredMode ? 9 : 6;
@@ -194,6 +224,7 @@ public IReadOnlyList Plan
{
if (IsBootstrapMode) return HasAgentProvider ? ApBootstrapPlan : LocalBootstrapPlan;
if (IsIdentityMode) return IdentityPlan;
+ if (IsMissionMode) return MissionPlan;
if (IsCallChainMode) return _callChainPending ? CallChainConsentPlan : CallChainPlan;
if (IsFederatedMode) return _federatedPending ? FederatedConsentPlan : FederatedPlan;
return IsDeferredMode ? DeferredPlan : AutonomousPlan;
@@ -276,6 +307,37 @@ 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),
+ };
+
private static readonly TourPlanStep[] FederatedPlan =
{
new(1, "Discover resource metadata", "Unsigned GET /federated/.well-known/aauth-resource.json.", Actor.Agent, Actor.Resource),
@@ -320,13 +382,21 @@ public IReadOnlyList Plan
/// The step number at which user approval occurs in deferred mode.
public int UserApprovalStepNumber =>
- IsCallChainPending
+ 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
+ IsMissionMode
+ ? (Steps.Count <= MissionHop1PollStep ? MissionHop1PollStep
+ : Steps.Count <= MissionHop2PollStep ? MissionHop2PollStep
+ : MissionHop3PollStep)
+ : IsCallChainPending
? (Steps.Count <= CallChainHop1PollStep ? CallChainHop1PollStep : CallChainHop2PollStep)
: 8;
@@ -337,6 +407,16 @@ 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;
+
///
/// 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 +432,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)
&& Steps.Count + 1 == UserApprovalStepNumber && !_userApproved;
/// The user-facing interaction URL captured during step 7 (deferred only).
@@ -431,6 +511,13 @@ private void ResetTimeline()
_federatedPending = false;
_callChainPending = false;
_aborted = false;
+ _missionApprover = null;
+ _missionS256 = null;
+ _missionDescription = null;
+ _missionApprovedToolCount = 0;
+ _missionResponseBody = null;
+ _missionEndpoint = null;
+ _permissionEndpoint = null;
}
///
@@ -627,6 +714,62 @@ public async Task RunNextAsync(CancellationToken ct = default)
case 7: StepFederatedInspectResult(); 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 +828,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 +863,7 @@ await adminClient.PostAsJsonAsync(
///
public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending)) { return Task.CompletedTask; }
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode)) { return Task.CompletedTask; }
if (Steps.Count + 1 != UserApprovalStepNumber)
{
throw new InvalidOperationException(
@@ -734,6 +903,58 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
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 `access_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 +1012,34 @@ public async Task PrepareConsentStateAsync(CancellationToken ct = default)
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
@@ -1444,7 +1693,7 @@ private async Task RunPendingPollAsync(
///
public Task StartPendingPollAsync()
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending) || _pendingUrl is null)
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) || _pendingUrl is null)
{
return Task.CompletedTask;
}
@@ -1460,6 +1709,13 @@ 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;
+
// 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 +1734,13 @@ public Task StartPendingPollAsync()
{
try
{
- await (hop2 ? StepCallChainPollHop2Async(ct) : StepPollPendingAsync(ct)).ConfigureAwait(false);
+ var poll =
+ missionCreatePoll ? StepMissionPollCreateAsync(ct)
+ : missionElevatedPoll ? StepMissionElevatedPollAsync(ct)
+ : missionPermissionPoll ? StepMissionPollPermissionAsync(ct)
+ : hop2 ? StepCallChainPollHop2Async(ct)
+ : StepPollPendingAsync(ct);
+ await poll.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -2410,6 +2672,631 @@ 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 access_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 `access_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 access_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 =
+ "PermissionClient.RequestAsync(ps, \"send_email\", mission)\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,
+ });
+ }
+
+ ///
+ /// 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/mission.spec.ts b/samples/GuidedTour/playwright-tests/mission.spec.ts
new file mode 100644
index 0000000..46ecc65
--- /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 access_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 access_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..78a5291 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 seven 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 seven 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(7);
await expect(flow.locator('option')).toContainText([
'Bootstrap',
'Identity-based',
@@ -19,16 +19,24 @@ test('flow picker offers all six flows and reacts to selection', async ({ page }
'PS-Asserted (Deferred)',
'Call Chain',
'Federated (Four-Party)',
+ 'Mission (PS-Governed)',
]);
// 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 string? Device { get; init; }
+ public string? Device
+ {
+ get => _device;
+ init => _device = ValidateDevice(value);
+ }
+
+ private readonly string? _device;
+
+ // §Agent Token Request: `device` MUST be printable (no control characters) and
+ // ≤ 64 characters. Reject anything outside printable ASCII (32–126) so display
+ // surfaces never receive control characters; allow null/empty (the field is optional).
+ private static string? ValidateDevice(string? value)
+ {
+ if (value is null)
+ {
+ return null;
+ }
+
+ if (value.Length > 64)
+ {
+ throw new ArgumentException(
+ $"device must be at most 64 characters (was {value.Length}).", nameof(Device));
+ }
+
+ foreach (var ch in value)
+ {
+ if (ch < ' ' || ch > '~')
+ {
+ throw new ArgumentException(
+ "device must contain only printable ASCII characters (no control characters).",
+ nameof(Device));
+ }
+ }
+
+ return value;
+ }
///
/// Invoked when the PS returns 202 with
diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
index fd31cee..14dd9f7 100644
--- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
+++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
@@ -61,7 +61,9 @@ public static IEndpointRouteBuilder MapAAuthGovernance(
(HttpContext ctx, IMissionStore missions, IMissionLog log, IPermissionDecider decider) =>
HandlePermissionAsync(ctx, options, missions, log, decider));
endpoints.MapPost(options.Resolve(options.AuditPath), HandleAuditAsync);
- endpoints.MapPost(options.Resolve(options.InteractionPath), HandleInteractionAsync);
+ endpoints.MapPost(options.Resolve(options.InteractionPath),
+ (HttpContext ctx, IMissionStore missions, IMissionLog log, IInteractionRelay relay) =>
+ HandleInteractionAsync(ctx, options, missions, log, relay));
endpoints.MapGet(options.Resolve(options.PendingPath).TrimEnd('/') + "/{id}",
(HttpContext ctx, string id, IMissionStore missions, IMissionLog log) =>
HandlePendingAsync(ctx, id, options, missions, log));
@@ -242,6 +244,7 @@ record = GovernanceEndpoints.ParseAudit(body);
private static async Task HandleInteractionAsync(
HttpContext ctx,
+ AAuthGovernancePipelineOptions options,
IMissionStore missions,
IMissionLog log,
IInteractionRelay relay)
@@ -296,6 +299,25 @@ await log.AppendAsync(new MissionLogEntry(
return Results.Json(new { mission_status = "active" });
default:
+ // interaction / payment: when the relay is still pending the PS
+ // MUST return a deferred response and let the agent poll until the
+ // user completes (§Interaction Response). Park it on the deferred
+ // store and answer 202; without a store there is no user channel,
+ // so treat the relay as having resolved synchronously (200).
+ if (result.Pending)
+ {
+ var store = ctx.RequestServices.GetService();
+ if (store is not null)
+ {
+ var parked = await store.ParkAsync(new DeferredConsent
+ {
+ Kind = DeferredConsentKind.Interaction,
+ Approver = request.Mission?.Approver ?? string.Empty,
+ Interaction = request,
+ }, ctx.RequestAborted).ConfigureAwait(false);
+ return DeferredAccepted(ctx, options, parked.Id);
+ }
+ }
return Results.Json(new { status = "ok" });
}
}
@@ -344,6 +366,15 @@ private static async Task HandlePendingAsync(
ctx, missions, entry.Approver, entry.Agent, proposal, proposal.Tools).ConfigureAwait(false);
}
+ if (entry.Kind == DeferredConsentKind.Interaction)
+ {
+ // The user completed the relayed interaction / payment; the poll loop
+ // terminates with the relay's final response (§Interaction Response).
+ // The interaction was already recorded in the mission log when it was
+ // relayed, so no further bookkeeping is needed here.
+ return Results.Json(new { status = "ok" });
+ }
+
// Permission: the endpoint always returns a decision (200), never access_denied.
var request = entry.Permission!;
var granted = entry.Decision.Value;
diff --git a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs
index b7272de..b5b7a51 100644
--- a/src/AAuth/HttpSig/ChallengeHandlingOptions.cs
+++ b/src/AAuth/HttpSig/ChallengeHandlingOptions.cs
@@ -1,6 +1,7 @@
using System;
using System.Threading;
using System.Threading.Tasks;
+using AAuth.Agent;
using AAuth.Headers;
namespace AAuth.HttpSig;
@@ -17,6 +18,22 @@ public sealed class ChallengeHandlingOptions
///
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/Server/Governance/GovernanceEndpoints.cs b/src/AAuth/Server/Governance/GovernanceEndpoints.cs
index 6fffa5c..e6d44b2 100644
--- a/src/AAuth/Server/Governance/GovernanceEndpoints.cs
+++ b/src/AAuth/Server/Governance/GovernanceEndpoints.cs
@@ -31,7 +31,7 @@ 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(action)
+ return new PermissionRequest(new MissionAction(action))
{
Description = (string?)body["description"],
Parameters = body["parameters"] as JsonObject,
@@ -50,7 +50,7 @@ public static AuditRecord ParseAudit(JsonObject 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, action)
+ return new AuditRecord(mission, new MissionAction(action))
{
Description = (string?)body["description"],
Parameters = body["parameters"] as JsonObject,
diff --git a/src/AAuth/Server/Governance/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
index 888b5bd..be994af 100644
--- a/src/AAuth/Server/Governance/IDeferredConsentStore.cs
+++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
@@ -15,6 +15,13 @@ public enum DeferredConsentKind
/// 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,
}
///
@@ -44,6 +51,9 @@ public sealed class DeferredConsent
/// 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.
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/GovernanceClientBuilderTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs
index a8a6c9b..d0c7609 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceClientBuilderTests.cs
@@ -84,7 +84,7 @@ public async Task Session_Permission_ThreadsMissionClaim()
var client = BuildBound(handler);
var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
- var result = await session.RequestPermissionAsync("SendEmail");
+ var result = await session.RequestPermissionAsync(new MissionAction("SendEmail"));
Assert.True(result.IsGranted);
Assert.Equal(session.Mission.S256, (string?)handler.LastPermissionBody?["mission"]?["s256"]);
@@ -101,7 +101,7 @@ public async Task Session_Permission_PreApprovedTool_ShortCircuits()
Tools = new[] { new MissionTool("WebSearch", "Search the web") },
});
- var result = await session.RequestPermissionAsync("WebSearch");
+ var result = await session.RequestPermissionAsync(new MissionAction("WebSearch"));
Assert.True(result.IsGranted);
// Pre-approved tools never reach the PS permission endpoint.
@@ -115,7 +115,7 @@ public async Task Session_Audit_ThreadsMissionClaim()
var client = BuildBound(handler);
var session = await client.ProposeMissionAsync(new MissionProposal("# Plan a trip"));
- await session.RecordAuditAsync("WebSearch", description: "Looked up flights");
+ 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"]);
diff --git a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs
index fd30c9b..fd63641 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceClientTests.cs
@@ -121,7 +121,7 @@ public async Task PermissionClient_Granted()
var (signed, metadata) = Build(handler);
var client = new PermissionClient(signed, metadata, Ps);
- var result = await client.RequestAsync(new PermissionRequest("SendEmail")
+ var result = await client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail"))
{
Description = "Send the itinerary",
Mission = TestMission,
@@ -137,7 +137,7 @@ public async Task PermissionClient_Denied()
var (signed, metadata) = Build(handler);
var client = new PermissionClient(signed, metadata, Ps);
- var result = await client.RequestAsync(new PermissionRequest("DeleteAll"));
+ var result = await client.RequestAsync(new PermissionRequest(new MissionAction("DeleteAll")));
Assert.Equal(PermissionGrant.Denied, result.Grant);
Assert.Equal("Out of scope.", result.Reason);
@@ -160,7 +160,7 @@ public async Task PermissionClient_ApprovedTool_ShortCircuits()
ApprovedTools = new[] { new MissionTool("WebSearch") },
};
- var result = await client.RequestAsync("WebSearch", mission);
+ var result = await client.RequestAsync(new MissionAction("WebSearch"), mission);
Assert.True(result.IsGranted);
Assert.False(handler.PermissionCalled);
@@ -174,7 +174,7 @@ public async Task PermissionClient_MissionTerminated_Throws()
var client = new PermissionClient(signed, metadata, Ps);
var ex = await Assert.ThrowsAsync(() =>
- client.RequestAsync(new PermissionRequest("SendEmail") { Mission = TestMission }));
+ client.RequestAsync(new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission }));
Assert.Equal("terminated", ex.MissionStatus);
}
@@ -188,7 +188,7 @@ public async Task AuditClient_Records()
var (signed, metadata) = Build(handler);
var client = new AuditClient(signed, metadata, Ps);
- await client.RecordAsync(new AuditRecord(TestMission, "WebSearch")
+ await client.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch"))
{
Description = "Searched for flights",
});
@@ -204,7 +204,20 @@ public async Task AuditClient_MissionTerminated_Throws()
var client = new AuditClient(signed, metadata, Ps);
await Assert.ThrowsAsync(() =>
- client.RecordAsync(new AuditRecord(TestMission, "WebSearch")));
+ 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 ----
@@ -241,6 +254,7 @@ private sealed class GovernanceHandler : HttpMessageHandler
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; }
@@ -348,7 +362,7 @@ protected override async Task SendAsync(
case "/audit":
AuditCalled = true;
- return new HttpResponseMessage(HttpStatusCode.Created);
+ return new HttpResponseMessage(AuditStatus);
case "/interaction":
{
diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
index 2bb31c7..6e4ded2 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
@@ -276,6 +276,118 @@ public async Task Permission_Prompt_NoStore_Denied()
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();
+ }
+
private sealed class StubApprover(MissionApprovalDecision decision) : IMissionApprover
{
public Task ApproveAsync(MissionApprovalContext context, CancellationToken ct = default)
@@ -287,4 +399,10 @@ private sealed class StubDecider(PermissionDecision decision) : IPermissionDecid
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/GovernanceFacadeTests.cs b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs
index d4209f9..6232320 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceFacadeTests.cs
@@ -68,10 +68,10 @@ public async Task Facade_SubClients_AreFunctional()
Assert.Equal("aauth:assistant@agent.example", mission.Agent);
var permission = await facade.Permission.RequestAsync(
- new PermissionRequest("SendEmail") { Mission = TestMission });
+ new PermissionRequest(new MissionAction("SendEmail")) { Mission = TestMission });
Assert.True(permission.IsGranted);
- await facade.Audit.RecordAsync(new AuditRecord(TestMission, "WebSearch"));
+ await facade.Audit.RecordAsync(new AuditRecord(TestMission, new MissionAction("WebSearch")));
Assert.True(handler.AuditCalled);
var answer = await facade.Interaction.AskQuestionAsync("Refundable option?");
diff --git a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs
index 1f83e2f..641ac90 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceServerTests.cs
@@ -34,7 +34,7 @@ public void ParsePermission_MapsFields()
var request = GovernanceEndpoints.ParsePermission(body);
- Assert.Equal("SendEmail", request.Action);
+ 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);
@@ -58,7 +58,7 @@ public void ParseAudit_MapsFields()
var record = GovernanceEndpoints.ParseAudit(body);
Assert.Equal(S256, record.Mission.S256);
- Assert.Equal("WebSearch", record.Action);
+ Assert.Equal("WebSearch", record.Action.Name);
Assert.Equal("completed", (string?)record.Result!["status"]);
}
@@ -199,7 +199,7 @@ await log.AppendAsync(new MissionLogEntry(S256, MissionLogEntryKind.Token, DateT
});
var decider = new StubDecider();
- var request = new PermissionRequest("SendEmail")
+ var request = new PermissionRequest(new MissionAction("SendEmail"))
{
Mission = new AAuth.Tokens.MissionClaim("https://ps.example", S256),
};
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/TokenRequestParamsTests.cs b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs
index 698c4d0..5e99ff9 100644
--- a/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs
+++ b/tests/AAuth.Conformance/Missions/TokenRequestParamsTests.cs
@@ -69,6 +69,41 @@ public async Task OptionalParams_OmittedWhenUnset()
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;
diff --git a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs
index adfe681..9fef660 100644
--- a/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs
+++ b/tests/AAuth.Tests/DependencyInjection/AAuthGovernanceDITests.cs
@@ -1,5 +1,6 @@
using System.Linq;
using System.Threading.Tasks;
+using AAuth.Agent;
using AAuth.Agent.Governance;
using AAuth.Server.Governance;
using Microsoft.Extensions.DependencyInjection;
@@ -44,7 +45,7 @@ public async Task DefaultPermissionDecider_PromptsForUnknownAction()
{
var decider = new DefaultPermissionDecider();
var context = new PermissionDecisionContext(
- new PermissionRequest("SendEmail"), Mission: null, Log: System.Array.Empty());
+ new PermissionRequest(new MissionAction("SendEmail")), Mission: null, Log: System.Array.Empty());
var decision = await decider.DecideAsync(context);
diff --git a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
index 6a10894..703372d 100644
--- a/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
+++ b/tests/AAuth.Tests/Integration/MissionAgentFlowTests.cs
@@ -212,7 +212,7 @@ public async Task Row09_PermissionApprovedTool_SilentGrant()
var mission = await ProposeMissionAsync(agent, "row09 approved-tool mission", "send_email");
var result = await PermissionClientFor(agent)
- .RequestAsync("send_email", mission);
+ .RequestAsync(new MissionAction("send_email"), mission);
Assert.True(result.IsGranted);
Assert.Equal(PermissionGrant.Granted, result.Grant);
@@ -225,7 +225,7 @@ public async Task Row10_PermissionNonPreApproved_PromptThenGrant()
await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = true });
var mission = await ProposeMissionAsync(agent, "row10 prompt-grant mission", "send_email");
- var request = new PermissionRequest("delete_file")
+ var request = new PermissionRequest(new MissionAction("delete_file"))
{
Mission = new MissionClaim(mission.Approver, mission.S256),
};
@@ -242,7 +242,7 @@ public async Task Row11_PermissionNonPreApproved_PromptThenDeny()
await ScriptAsync(agent, new JsonObject { ["reset"] = true, ["approvePermission"] = false });
var mission = await ProposeMissionAsync(agent, "row11 prompt-deny mission", "send_email");
- var request = new PermissionRequest("delete_file")
+ var request = new PermissionRequest(new MissionAction("delete_file"))
{
Mission = new MissionClaim(mission.Approver, mission.S256),
};
From de8ca74339ae607ee0d7527b106aad13097038e0 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 04:47:39 +0000
Subject: [PATCH 17/24] docs(missions): ground governance docs against SDK
(Phase 11 review)
Two independent review subagents validated this initiative post-commit: one
grounding every SDK change against the spec, the other grounding all docs and
sample snippets against the SDK. Remediate the documentation drift the second
reviewer found (D-1..D-7); the SDK reviewer found no CRITICAL/HIGH issues.
- mission-governance-clients.md, mission-governed-access.md: pass MissionAction
(not bare strings) to RequestPermissionAsync/RecordAuditAsync so the snippets
compile.
- server/mission-governance.md: compare t.Name to context.Request.Action.Name;
correct the interaction route comment to /mission-interaction.
- reference/dependency-injection.md + reference/configuration.md: replace the
non-existent AAuthAgentOptions members (UseHwk, InteractionHandling, etc.) in
examples and tables with the real surface (OnResourceInteraction,
OnApprovalPending, PollingTimeout); soften the no-op-seam prose.
- advanced/error-handling.md: add MissionTerminated to the TokenErrorCode listing.
SDK findings are adjudicated in the deviations log: DEV-12 (access_denied vs the
spec's `denied`, Polling Error Codes) surfaced as a cross-cutting decision;
DEV-13 (carrier-token 401 shape) and DEV-14 (forward-looking user_unreachable)
logged intentional. Plan gains a Phase 11 section. No code changed; build 0/0.
---
.../implementation-plan.md | 41 +++++++++++++++++++
.../issues-and-deviations.md | 4 ++
docs/advanced/error-handling.md | 1 +
docs/advanced/mission-governance-clients.md | 12 +++---
docs/reference/configuration.md | 20 ++++-----
docs/reference/dependency-injection.md | 36 +++++++---------
docs/server/mission-governance.md | 4 +-
docs/workflows/mission-governed-access.md | 6 +--
8 files changed, 78 insertions(+), 46 deletions(-)
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
index db3fe10..2f930eb 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
@@ -546,6 +546,47 @@ found non-compliant, fix it (DC6 still holds — fixes must not break existing f
---
+## 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).
+- [ ] DEV-12 (`access_denied`→`denied`) decision returned by the user.
+
+---
+
## Out of Scope
| Item | Reason |
diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
index 8e9be5c..1e93bfe 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
@@ -51,6 +51,10 @@ These judgment calls were made during Phase 1. **All confirmed by the user on
| DEV-8 | 5 | e2e spec timing | `mission-call-chain.spec.ts`'s `approvePrompt` asserted an **exact** transient `toHaveCount(2)` after the step-2 approval. Unlike `mission.spec.ts` — where every gate parks on the next prompt so the count settles — step 3 here is **silent and final**: it advances with no gate and appends its card immediately, and Blazor **coalesces** the step-2 and step-3 `StateHasChanged` into one render batch (the DOM jumps 1→3, never showing 2). The exact count was therefore racy by construction; the page behaviour is correct (forcing artificial render flushes into product code to satisfy a test would be the anti-pattern). **Fix:** the helper now waits for the just-approved step's card via `expect(stepCard(page, expectedCards)).toBeVisible()` (i.e. ≥ expectedCards), matching the helper's own documented intent ("reach expectedCards"). The strict final `toHaveCount(3)` and all per-card content assertions are unchanged; `mission.spec.ts` is untouched. | (e2e test only) | fixed (Phase 5; two consecutive clean full-suite runs) |
| DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"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."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) |
| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional |
+| DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) |
+| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code is emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. The SDK round-trips internally (the PS emits and the agent's classifier read the same string), so it works SDK-to-SDK, but a spec-conformant external PS returning `denied` would not be recognized. **Why not fixed inline:** the convention spans ~30 sites including the **out-of-scope** AccessServer path (`AccessServerClient`, `IAccessPolicy`, `IAccessPendingStore`), shared agent classifiers (`TokenExchangeClient.IsAccessDenied`, `DeferredExchange`), conformance/integration tests, and GuidedTour narration. A correct fix is a coordinated rename to `denied` with `access_denied` kept only as a read-side backward-compat alias, done as its own focused change with full test updates — a major cross-cutting decision surfaced for user input per the Working Agreement, not rushed into this PR. | §Polling Error Codes (L2023) | needs-decision |
+| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional |
+| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) |
## Notes
diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md
index 2a578f2..b538dc3 100644
--- a/docs/advanced/error-handling.md
+++ b/docs/advanced/error-handling.md
@@ -86,6 +86,7 @@ public enum TokenErrorCode
ExpiredResourceToken, // Resource token exp has passed
InteractionRequired, // User must approve (deferred consent, non-terminal 202)
UserUnreachable, // No channel to the user; agent declared no interaction capability (terminal 400)
+ MissionTerminated, // Mission already terminated (terminal 403 mission_terminated)
ServerError, // Internal server error (transient, retryable)
}
```
diff --git a/docs/advanced/mission-governance-clients.md b/docs/advanced/mission-governance-clients.md
index f1d791e..539cad7 100644
--- a/docs/advanced/mission-governance-clients.md
+++ b/docs/advanced/mission-governance-clients.md
@@ -108,14 +108,14 @@ else
}
```
-The action is a `MissionAction` POCO; a bare `string` converts implicitly, so
-`"email.send"` works directly. For an action not on the mission, the PS evaluates
+The action is a `MissionAction` POCO — construct it with `new MissionAction("email.send")`
+(or `tool.ToAction()` from a `MissionTool`). For an action not on the mission, the PS evaluates
it against the mission log and may prompt the user. Supply `OnInteractionRequired`
/ `OnClarificationRequired` via `GovernanceOptions` to participate in any deferral.
```csharp
PermissionResult outcome = await session.RequestPermissionAsync(
- "files.delete",
+ new MissionAction("files.delete"),
description: "Remove the stale draft the user mentioned.",
parameters: new JsonObject { ["path"] = "/drafts/old.md" });
```
@@ -129,7 +129,7 @@ surfaces as `AAuthMissionTerminatedException` (see
```csharp
await session.RecordAuditAsync(
- "email.send",
+ new MissionAction("email.send"),
description: "Sent booking confirmation to 4 recipients.",
result: new JsonObject { ["messageId"] = "msg-8842" });
```
@@ -176,11 +176,11 @@ var session = await governance.ProposeMissionAsync(
});
// 2. Permission for a pre-approved tool → granted silently
-var perm = await session.RequestPermissionAsync("bookmarks.archive");
+var perm = await session.RequestPermissionAsync(new MissionAction("bookmarks.archive"));
// 3. Do the work, then audit it
await session.RecordAuditAsync(
- "bookmarks.archive",
+ new MissionAction("bookmarks.archive"),
result: new JsonObject { ["archived"] = 12 });
// 4. Close the mission out
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index f3d573d..1725395 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -178,19 +178,13 @@ Standard `DelegatingHandler` — no configurable options. Requires an `ISignatur
| Property | Type | Required | Description |
|----------|------|:--------:|-------------|
-| `Key` | `IAAuthKey` | Yes | Agent signing key |
-| `BaseAddress` | `Uri?` | No | Target resource URL |
-| `SignatureKeyProvider` | `ISignatureKeyProvider?` | No | Custom signature key provider |
-| `PersonServer` | `string?` | No | Person Server URL (for challenge handling) |
-| `ChallengeHandling` | `bool` | No | Enable challenge handling |
-| `ChallengeHandlingOptions` | `Action?` | No | Configure challenge handling behavior |
-| `InteractionHandling` | `bool` | No | Enable interaction handling |
-| `InteractionHandlingOptions` | `Action?` | No | Configure interaction handling behavior |
-| `TokenRefresher` | `ITokenRefresher?` | No | Custom token refresh logic |
-| `RefreshThreshold` | `TimeSpan?` | No | Time before expiry to trigger refresh |
-| `Capabilities` | `string[]?` | No | Agent capabilities to advertise |
-| `InnerHandler` | `HttpMessageHandler?` | No | Custom inner HTTP handler |
-| `CallChainProvider` | `Func?` | No | Provider for upstream auth token (call chaining) |
+| `Key` | `IAAuthKey` | Yes | Agent signing key (must have private component) |
+| `PersonServer` | `string?` | No | Person Server URL; with `TokenRefresher`, enables 401 challenge handling |
+| `OnInteractionRequired` | `Func?` | No | PS interaction during token exchange (deferred consent) |
+| `OnResourceInteraction` | `Func?` | No | Resource `202` + `requirement=interaction` (URL + code) |
+| `OnApprovalPending` | `Func?` | No | Resource `202` + `requirement=approval` |
+| `TokenRefresher` | `ITokenRefresher?` | No | Auto-refresh before token expiry (JWT identity); omit for HWK |
+| `PollingTimeout` | `TimeSpan` | No | Max deferred polling time (default 5 minutes) |
### AAuthResourceOptions (AddAAuthResource)
diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md
index 57d9e6f..ec9f104 100644
--- a/docs/reference/dependency-injection.md
+++ b/docs/reference/dependency-injection.md
@@ -38,7 +38,7 @@ var key = AAuthKey.Generate(); // or load from persistent storage
builder.Services.AddAAuthAgent("signing-only", options =>
{
options.Key = key;
- options.UseHwk();
+ // No TokenRefresher set → the agent signs with HWK (pseudonymous) by default.
});
```
@@ -115,16 +115,13 @@ builder.Services.AddAAuthAgent("interactive", options =>
options.TokenRefresher = AgentProviderTokenRefresher.Create(apRefreshEndpoint, localKeyHandle)
.WithKeyStore(keyStore)
.Build();
- options.InteractionHandling = true;
- options.InteractionHandlingOptions = io =>
+ // A resource returning 202 + requirement=interaction surfaces here.
+ options.OnResourceInteraction = async (url, code, ct) =>
{
- io.OnInteractionRequired = async (url, code, ct) =>
- {
- // Present URL and code to user
- logger.LogInformation("Approve at {Url} with code {Code}", url, code);
- };
- io.PollingTimeout = TimeSpan.FromMinutes(3);
+ // Present URL and code to user
+ logger.LogInformation("Approve at {Url} with code {Code}", url, code);
};
+ options.PollingTimeout = TimeSpan.FromMinutes(3);
});
```
@@ -317,18 +314,12 @@ app.Run();
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| `Key` | `IAAuthKey` | required | Agent signing key (must have private component) |
-| `BaseAddress` | `Uri?` | `null` | Target resource URL |
-| `SignatureKeyProvider` | `ISignatureKeyProvider?` | `null` | Custom signature key provider |
-| `PersonServer` | `string?` | `null` | PS URL; with ChallengeHandling, enables challenge flow |
-| `ChallengeHandling` | `bool` | `false` | Enable challenge handling |
-| `ChallengeHandlingOptions` | `Action?` | `null` | Configure challenge handling behavior |
-| `InteractionHandling` | `bool` | `false` | Enable interaction handling |
-| `InteractionHandlingOptions` | `Action?` | `null` | Configure interaction handling behavior |
-| `TokenRefresher` | `ITokenRefresher?` | `null` | Auto-refresh before token expiry |
-| `RefreshThreshold` | `TimeSpan?` | `null` | Time before expiry to trigger refresh |
-| `Capabilities` | `string[]?` | `null` | Agent capabilities to advertise |
-| `InnerHandler` | `HttpMessageHandler?` | `null` | Custom inner HTTP handler |
-| `CallChainProvider` | `Func?` | `null` | Provider for upstream auth token (call chaining) |
+| `PersonServer` | `string?` | `null` | PS URL; with `TokenRefresher`, enables 401 challenge handling |
+| `OnInteractionRequired` | `Func?` | `null` | PS interaction during token exchange (deferred consent) |
+| `OnResourceInteraction` | `Func?` | `null` | Resource `202` + `requirement=interaction` (URL + code) |
+| `OnApprovalPending` | `Func?` | `null` | Resource `202` + `requirement=approval` |
+| `TokenRefresher` | `ITokenRefresher?` | `null` | Auto-refresh before token expiry (JWT identity); omit for HWK signing |
+| `PollingTimeout` | `TimeSpan` | 5 minutes | Max deferred polling time |
### AAuthResourceOptions
@@ -418,7 +409,8 @@ Server (`WithPersonServer`), and throws `InvalidOperationException` otherwise. S
`AddAAuthGovernance()` registers the in-memory mission storage seams as
singletons. It uses `TryAdd`, so register durable implementations first to
override them. The policy and user-channel seams (`IPermissionDecider`,
-`IAuditSink`, `IInteractionRelay`) are always supplied by the PS.
+`IAuditSink`, `IInteractionRelay`) default to conservative no-op implementations;
+a real PS overrides them.
```csharp
builder.Services.AddAAuthGovernance(); // InMemoryMissionStore + InMemoryMissionLog
diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md
index aac53db..0ab4a9a 100644
--- a/docs/server/mission-governance.md
+++ b/docs/server/mission-governance.md
@@ -68,7 +68,7 @@ decision to the seams:
```csharp
var app = builder.Build();
-app.MapAAuthGovernance(); // /mission, /permission, /audit, /interaction + poll route
+app.MapAAuthGovernance(); // /mission, /permission, /audit, /mission-interaction + poll route
// Optional: override the default paths.
app.MapAAuthGovernance(o =>
@@ -217,7 +217,7 @@ public sealed class MyPermissionDecider : IPermissionDecider
// Pre-approved tool → granted silently.
var blob = Mission.FromApprovalBytes(mission.Blob.Span);
- if (blob.ApprovedTools.Any(t => t.Name == context.Request.Action))
+ if (blob.ApprovedTools.Any(t => t.Name == context.Request.Action.Name))
{
return new PermissionDecision(PermissionOutcome.Granted, PermissionDecisionReason.ApprovedTool);
}
diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md
index d9360fb..3ec1111 100644
--- a/docs/workflows/mission-governed-access.md
+++ b/docs/workflows/mission-governed-access.md
@@ -101,11 +101,11 @@ other action goes to the PS, which prompts the user when it is out of mission.
```csharp
// Pre-approved tool → granted silently.
-var send = await session.RequestPermissionAsync("email.send");
+var send = await session.RequestPermissionAsync(new MissionAction("email.send"));
// Out-of-mission tool → the PS prompts the user (gate 3).
var delete = await session.RequestPermissionAsync(
- "files.delete",
+ new MissionAction("files.delete"),
description: "Remove the duplicate receipt the user flagged.");
if (!delete.IsGranted)
@@ -121,7 +121,7 @@ fire-and-forget.
```csharp
await session.RecordAuditAsync(
- "email.send",
+ new MissionAction("email.send"),
description: "Emailed the reconciliation summary to the user.",
result: new JsonObject { ["recipients"] = 1 });
```
From 71e0b1e085e9e151a305b958693ff56cd08451c8 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 05:11:58 +0000
Subject: [PATCH 18/24] =?UTF-8?q?fix(missions):=20align=20denial=20wire=20?=
=?UTF-8?q?code=20with=20spec=20(access=5Fdenied=20=E2=86=92=20denied)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
§Polling Error Codes defines the explicit-denial code as `denied`;
`access_denied` (OAuth/RFC 6749 vocabulary) appears nowhere in the AAuth
error tables. Rename every emitter, classifier, test, sample, narration,
and doc snippet to `denied` with no backward-compat alias, since every
emitter and classifier in this repo is ours and now round-trips `denied`
end-to-end.
Because the SDK's DeferredPoller already classifies the spec `denied`
polling code as PollingErrorException(Denied), the high-level interaction
wrappers (DeferredExchange.PollAsync, AccessServerClient.PollDeferredAsync)
now translate that into the semantic AAuthInteractionDeniedException,
preserving the public contract. The GuidedTour poll loop catches the same
typed exception to record its terminal denied step.
Resolves DEV-12. Build 0/0; AAuth.Tests 387/387; AAuth.Conformance 468/468;
deny-path e2e (sample-app + guided-tour) green.
---
.../implementation-plan.md | 5 ++-
.../issues-and-deviations.md | 2 +-
docs/advanced/interaction-chaining.md | 2 +-
samples/GuidedTour/README.md | 2 +-
samples/GuidedTour/TourSession.cs | 32 ++++++++++++-------
.../playwright-tests/deferred.spec.ts | 2 +-
.../playwright-tests/federated.spec.ts | 2 +-
.../playwright-tests/mission.spec.ts | 4 +--
samples/MissionAgent/Program.cs | 4 +--
samples/MockAccessServer/Program.cs | 4 +--
samples/MockPersonServer/ConsentStore.cs | 4 +--
.../MockPersonServer/FederatedPendingStore.cs | 2 +-
samples/MockPersonServer/Program.cs | 20 ++++++------
samples/MockPersonServer/README.md | 2 +-
samples/Orchestrator/Program.cs | 6 ++--
.../SampleApp/Components/Pages/Mission.razor | 8 ++---
.../playwright-tests/mission.spec.ts | 6 ++--
.../Access/AAuthAccessServerEndpoints.cs | 6 ++--
src/AAuth/Access/AccessServerClient.cs | 18 ++++++++---
src/AAuth/Access/IAccessPendingStore.cs | 2 +-
src/AAuth/Access/IAccessPolicy.cs | 4 +--
src/AAuth/Agent/AAuthInteractionExceptions.cs | 2 +-
src/AAuth/Agent/DeferredExchange.cs | 12 +++++--
src/AAuth/Agent/TokenExchangeClient.cs | 10 +++---
...hGovernanceApplicationBuilderExtensions.cs | 10 +++---
.../GovernanceDeferredConsentMapperTests.cs | 10 +++---
.../MockAccessServerKeycloakTests.cs | 6 ++--
.../Integration/MockAccessServerTests.cs | 4 +--
.../Integration/MockPersonServerTests.cs | 8 ++---
.../Integration/WhoAmIFlowTests.cs | 2 +-
30 files changed, 115 insertions(+), 86 deletions(-)
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
index 2f930eb..b91d85f 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
@@ -583,7 +583,10 @@ truth `src/AAuth/`.
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).
-- [ ] DEV-12 (`access_denied`→`denied`) decision returned by the user.
+- [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.
---
diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
index 1e93bfe..25b34aa 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
@@ -52,7 +52,7 @@ These judgment calls were made during Phase 1. **All confirmed by the user on
| DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"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."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) |
| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional |
| DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) |
-| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code is emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. The SDK round-trips internally (the PS emits and the agent's classifier read the same string), so it works SDK-to-SDK, but a spec-conformant external PS returning `denied` would not be recognized. **Why not fixed inline:** the convention spans ~30 sites including the **out-of-scope** AccessServer path (`AccessServerClient`, `IAccessPolicy`, `IAccessPendingStore`), shared agent classifiers (`TokenExchangeClient.IsAccessDenied`, `DeferredExchange`), conformance/integration tests, and GuidedTour narration. A correct fix is a coordinated rename to `denied` with `access_denied` kept only as a read-side backward-compat alias, done as its own focused change with full test updates — a major cross-cutting decision surfaced for user input per the Working Agreement, not rushed into this PR. | §Polling Error Codes (L2023) | needs-decision |
+| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code was emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. **Fix (user-approved full rename):** every site was renamed to `denied` with **no** backward-compat alias, since every emitter and classifier in this repo is ours and now round-trips `denied` end-to-end, keeping the system internally consistent and 100% spec-aligned. Scope covered: the SDK PS governance emits (`AAuthGovernanceApplicationBuilderExtensions`), the four-party AS path (`AAuthAccessServerEndpoints` 3 emits, `AccessServerClient.IsDeniedAsync` classifier — confirmed in-scope as those `403`/`AccessDecisionKind.Deny` responses are AAuth polling denials), the agent classifiers (`TokenExchangeClient.IsDeniedAsync`, `DeferredExchange` comments), interface/exception doc comments (`IAccessPolicy`, `IAccessPendingStore`, `AAuthInteractionExceptions`), all sample mocks (`MockPersonServer`, `Orchestrator`, `MockAccessServer`, `MissionAgent`), the SampleApp `Mission.razor` payload+narration, `GuidedTour` narration + classifier, all conformance/integration test asserts (incl. method `Pending_Returns403Denied_AfterDeny` and the Keycloak test emit), the Playwright specs, and `docs/advanced/interaction-chaining.md`. Spec is unaffected (`denied` is already the only denial code there). | §Polling Error Codes (L2023) | fixed (build 0/0) |
| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional |
| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) |
diff --git a/docs/advanced/interaction-chaining.md b/docs/advanced/interaction-chaining.md
index c4126c7..5ec093b 100644
--- a/docs/advanced/interaction-chaining.md
+++ b/docs/advanced/interaction-chaining.md
@@ -120,7 +120,7 @@ app.MapGet("/pending/{id}", async (HttpContext ctx, string id, PendingStore pend
catch (AAuthInteractionDeniedException)
{
pending.Remove(id);
- return Results.Json(new { error = "access_denied" }, statusCode: 403);
+ return Results.Json(new { error = "denied" }, statusCode: 403);
}
});
```
diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md
index 9061f46..8abec96 100644
--- a/samples/GuidedTour/README.md
+++ b/samples/GuidedTour/README.md
@@ -115,7 +115,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.
diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs
index 4ba0e49..b737e07 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;
@@ -930,7 +931,7 @@ public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
"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 `access_denied`.)"
+ "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 " +
@@ -1572,7 +1573,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,
@@ -1590,7 +1591,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.
///
@@ -1651,13 +1652,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;
@@ -1667,6 +1668,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
@@ -1769,13 +1779,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.",
@@ -1860,7 +1870,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" +
@@ -2795,7 +2805,7 @@ private Task StepMissionPollCreateAsync(CancellationToken ct) =>
"(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 access_denied`.",
+ "If the user clicks **Deny**, this step records `403 denied`.",
RequestLine = $"{last.RequestLine} → {_pendingUrl}",
RequestHeaders = last.RequestHeaders,
SignatureBase = capturedBase,
@@ -3046,7 +3056,7 @@ private async Task StepMissionElevatedExchangeAsync(CancellationToken ct)
"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 `access_denied`.",
+ "**Deny** would yield `denied`.",
RequestLine = $"{ex.RequestLine} → {_tokenEndpoint}",
RequestHeaders = ex.RequestHeaders,
RequestBody = PrettyJson(ex.RequestBody),
@@ -3076,7 +3086,7 @@ private Task StepMissionElevatedPollAsync(CancellationToken ct) =>
"`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 access_denied`.",
+ "`403 denied`.",
RequestLine = $"{last.RequestLine} → {_pendingUrl}",
RequestHeaders = last.RequestHeaders,
SignatureBase = capturedBase,
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.spec.ts b/samples/GuidedTour/playwright-tests/mission.spec.ts
index 46ecc65..49c3821 100644
--- a/samples/GuidedTour/playwright-tests/mission.spec.ts
+++ b/samples/GuidedTour/playwright-tests/mission.spec.ts
@@ -104,7 +104,7 @@ test.describe('Mission (Guided Tour)', () => {
expect(elevated.scope).toEqual(['whoami:elevated_scope']);
});
- test('deny at the elevated-scope gate yields access_denied', async ({ page, context }) => {
+ test('deny at the elevated-scope gate yields denied', async ({ page, context }) => {
await openTour(page);
await selectFlow(page, TourMode.Mission);
@@ -130,7 +130,7 @@ test.describe('Mission (Guided Tour)', () => {
await denyInPopup(elevatedPopup);
// 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/MissionAgent/Program.cs b/samples/MissionAgent/Program.cs
index dea050c..b257cca 100644
--- a/samples/MissionAgent/Program.cs
+++ b/samples/MissionAgent/Program.cs
@@ -241,7 +241,7 @@ await ScriptAsync(new JsonObject
// cover it. The PS cannot grant it silently: out-of-mission scopes are NOT
// auto-denied — the PS prompts the user (§Agent Token Request gate 3, §Scopes).
// Approve in the browser and the consent accrues to the mission; deny and the
-// exchange throws AAuthInteractionDeniedException (access_denied). Declaring
+// exchange throws AAuthInteractionDeniedException (denied). Declaring
// whoami:elevated_scope mission-approved (--mission-approved) makes this silent.
if (elevatedScopeMissionApproved)
{
@@ -255,7 +255,7 @@ await ScriptAsync(new JsonObject
}
catch (AAuthInteractionDeniedException)
{
- Console.WriteLine(" elevated scope : denied by the user (access_denied) — the gate-2 token is unaffected");
+ Console.WriteLine(" elevated scope : denied by the user (denied) — the gate-2 token is unaffected");
}
Section("6. Request a permission for a pre-approved tool — silent");
diff --git a/samples/MockAccessServer/Program.cs b/samples/MockAccessServer/Program.cs
index 1a2e97f..bbd3b0b 100644
--- a/samples/MockAccessServer/Program.cs
+++ b/samples/MockAccessServer/Program.cs
@@ -197,7 +197,7 @@
// -----------------------------------------------------------------------
// POST /interaction/deny — the stub AS consent screen's Deny button. Marks
-// the pending entry Denied so the agent's next poll receives 403 access_denied.
+// the pending entry Denied so the agent's next poll receives 403 denied.
// -----------------------------------------------------------------------
app.MapPost("/interaction/deny", async (HttpContext ctx) =>
{
@@ -374,7 +374,7 @@ public static string Denied(string agent, string resource, string scope) =>
"
Denied
"
+ $"
You denied {Enc(agent)}'s federated request for "
+ $"{Enc(resource)} at the Access Server. "
- + "The agent's next poll will receive 403 access_denied.
"
+ + "The agent's next poll will receive 403 denied."
+ "
You can close this tab.
");
public static string NotFound() =>
diff --git a/samples/MockPersonServer/ConsentStore.cs b/samples/MockPersonServer/ConsentStore.cs
index c247c48..38aaacf 100644
--- a/samples/MockPersonServer/ConsentStore.cs
+++ b/samples/MockPersonServer/ConsentStore.cs
@@ -45,7 +45,7 @@ public sealed record Entry(
{
///
/// True once the user has explicitly denied this request. The
- /// pending endpoint returns 403 access_denied in that case
+ /// pending endpoint returns 403 denied in that case
/// so the agent can distinguish denial from "unknown / expired".
///
public bool Denied { get; internal set; }
@@ -68,7 +68,7 @@ public Entry Add(string agent, string resource, string scope, string resourceTok
///
/// Mark an entry as denied. Unlike , the
/// entry stays in the store so the agent's poller gets a
- /// deterministic 403 access_denied rather than an ambiguous
+ /// deterministic 403 denied rather than an ambiguous
/// 404.
///
public bool Deny(string id)
diff --git a/samples/MockPersonServer/FederatedPendingStore.cs b/samples/MockPersonServer/FederatedPendingStore.cs
index 5d032ea..6a6430b 100644
--- a/samples/MockPersonServer/FederatedPendingStore.cs
+++ b/samples/MockPersonServer/FederatedPendingStore.cs
@@ -46,7 +46,7 @@ public sealed class FederatedPendingEntry
/// The AS-issued auth token, set once federation succeeds.
public string? AuthToken { get; set; }
- /// Relayed error code (e.g. access_denied) on failure.
+ /// Relayed error code (e.g. denied) on failure.
public string? Error { get; set; }
/// HTTP status to relay to the agent for .
diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs
index a0ae272..8513b43 100644
--- a/samples/MockPersonServer/Program.cs
+++ b/samples/MockPersonServer/Program.cs
@@ -356,7 +356,7 @@ static bool IsAdminAgent(string agentId) =>
}
catch (AAuthInteractionDeniedException)
{
- entry.Error = "access_denied";
+ entry.Error = "denied";
entry.ErrorStatus = StatusCodes.Status403Forbidden;
entry.Status = FederatedPendingStatus.Denied;
}
@@ -615,13 +615,13 @@ await missionLog.AppendAsync(new MissionLogEntry(
return Results.NotFound(new { error = "unknown_pending", id });
}
- // Explicit denial — return 403 access_denied so the agent can
+ // Explicit denial — return 403 denied so the agent can
// distinguish "user said no" from "timed out / unknown id".
if (entry.Denied)
{
ctx.Response.Headers["Cache-Control"] = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user denied this request" },
+ new { error = "denied", detail = "the user denied this request" },
statusCode: StatusCodes.Status403Forbidden);
}
@@ -648,7 +648,7 @@ await missionLog.AppendAsync(new MissionLogEntry(
// /token. While the PS's background FederateAsync drives the AS interaction
// to completion, returns 202 + the relayed AS interaction requirement. Once
// federation resolves it returns the AS-issued auth token (200) or the
-// relayed AS error (403 access_denied / 402 / 502).
+// relayed AS error (403 denied / 402 / 502).
// -----------------------------------------------------------------------
app.MapGet("/federated-pending/{id}", (HttpContext ctx, string id, FederatedPendingStore fedPending) =>
{
@@ -739,7 +739,7 @@ await missionLog.AppendAsync(new MissionLogEntry(
if (!script.ApproveMissionProposal)
{
- return Results.Json(new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden);
+ return Results.Json(new { error = "denied" }, statusCode: StatusCodes.Status403Forbidden);
}
// Interactive mode (§Mission Creation): mission approval is the most
@@ -806,7 +806,7 @@ await missionLog.AppendAsync(new MissionLogEntry(
{
ctx.Response.Headers["Cache-Control"] = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user declined this mission" },
+ new { error = "denied", detail = "the user declined this mission" },
statusCode: StatusCodes.Status403Forbidden);
}
@@ -1072,7 +1072,7 @@ await log.AppendAsync(new MissionLogEntry(
{
ctx.Response.Headers["Cache-Control"] = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user denied this request" },
+ new { error = "denied", detail = "the user denied this request" },
statusCode: StatusCodes.Status403Forbidden);
}
var token = IssueAuthToken(
@@ -1534,7 +1534,7 @@ await log.AppendAsync(new MissionLogEntry(
// Deny handler. Marks the pending entry as denied (rather than removing
// it) so the agent's next poll receives a deterministic
-// `403 access_denied` instead of an ambiguous `404 unknown_pending`.
+// `403 denied` instead of an ambiguous `404 unknown_pending`.
app.MapPost("/interaction/deny", async (HttpContext ctx, PendingStore pending, MissionPendingStore missionPending) =>
{
var code = (await ctx.Request.ReadFormAsync())["code"].ToString();
@@ -1556,7 +1556,7 @@ await log.AppendAsync(new MissionLogEntry(
+ ".badge .dot{width:.6rem;height:.6rem;border-radius:50%;background:#ddd6fe}"
+ "
Person Server — mission governance
"
+ "
Denied
"
- + $"
You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 access_denied.
"
+ + $"
You denied {System.Net.WebUtility.HtmlEncode(mission.AgentId)}'s mission request. The agent's next poll will receive 403 denied.
You denied {System.Net.WebUtility.HtmlEncode(entry.Agent)}'s request at the Person Server. The agent's next poll will receive 403 access_denied.
"
+ + $"
You denied {System.Net.WebUtility.HtmlEncode(entry.Agent)}'s request at the Person Server. The agent's next poll will receive 403 denied.
"
+ "
You can close this tab.
",
contentType: "text/html");
}).DisableAntiforgery();
diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md
index f2d484c..b779ac3 100644
--- a/samples/MockPersonServer/README.md
+++ b/samples/MockPersonServer/README.md
@@ -19,7 +19,7 @@ A minimal AAuth Person Server for end-to-end demos and integration tests.
- **Approve** (`POST /interaction/approve`, or `POST /admin/consent`
from a script) → next poll returns `200` with the `auth_token`.
- **Deny** (`POST /interaction/deny`) → next poll returns `403` with
- `{"error":"access_denied"}`.
+ `{"error":"denied"}`.
- No action → the agent's polling budget eventually expires.
- `GET /interaction` renders a tiny built-in consent page used by the
`GuidedTour` "Open consent page" button.
diff --git a/samples/Orchestrator/Program.cs b/samples/Orchestrator/Program.cs
index 7948296..05038bd 100644
--- a/samples/Orchestrator/Program.cs
+++ b/samples/Orchestrator/Program.cs
@@ -253,7 +253,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry)
// at the PS). Returns:
// * 202 + same requirement=interaction while still unconsented downstream
// * 200 + combined chain result once the downstream auth token resolves
-// * 403 access_denied if the user denied
+// * 403 denied if the user denied
// * 404 if the pending id is unknown
// -----------------------------------------------------------------------
app.MapGet("/pending/{id}", async (HttpContext ctx, string id, PendingStore pending) =>
@@ -281,7 +281,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry)
pending.Remove(id);
ctx.Response.Headers["Cache-Control"] = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user denied this request" },
+ new { error = "denied", detail = "the user denied this request" },
statusCode: StatusCodes.Status403Forbidden);
}
});
@@ -312,7 +312,7 @@ IResult ReEmitChainedInteraction(HttpContext ctx, PendingStore.Entry entry)
pending.Remove(id);
ctx.Response.Headers["Cache-Control"] = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user denied this request" },
+ new { error = "denied", detail = "the user denied this request" },
statusCode: StatusCodes.Status403Forbidden);
}
});
diff --git a/samples/SampleApp/Components/Pages/Mission.razor b/samples/SampleApp/Components/Pages/Mission.razor
index 3571bed..2c497c7 100644
--- a/samples/SampleApp/Components/Pages/Mission.razor
+++ b/samples/SampleApp/Components/Pages/Mission.razor
@@ -103,7 +103,7 @@ var who = await ok.Content.ReadFromJsonAsync<JsonObject>(); // the profi
// challenge -> exchange -> retry, but against an endpoint requiring
// `whoami:elevated_scope`. The mission's intent does not cover this
// scope, so the PS cannot grant it silently: it prompts the user.
-// A user deny throws AAuthInteractionDeniedException (access_denied);
+// A user deny throws AAuthInteractionDeniedException (denied);
// otherwise the consent accrues to the mission (§Scopes, gate 3).
var elevated = await ExchangeForScopeAsync(
$"{resourceOrigin}/protected_endpoint/elevated", mission, governanceOptions);
@@ -453,10 +453,10 @@ if (deleteInbox.IsGranted)
_steps.Add(new GateStep(3, "Resource token (elevated)", Prompted: true,
Outcome: "denied",
Summary: "This scope falls outside the mission's intent; the PS prompted the user, " +
- "who clicked Deny — so no auth_token was issued (access_denied).",
- Note: "Only an explicit user deny (or a terminated mission) yields access_denied; " +
+ "who clicked Deny — so no auth_token was issued (denied).",
+ Note: "Only an explicit user deny (or a terminated mission) yields denied; " +
"an out-of-mission scope on its own just prompts (§Scopes).",
- Payload: "{\n \"error\": \"access_denied\"\n}",
+ Payload: "{\n \"error\": \"denied\"\n}",
PayloadLabel: "Token exchange outcome"));
}
await InvokeAsync(StateHasChanged);
diff --git a/samples/SampleApp/playwright-tests/mission.spec.ts b/samples/SampleApp/playwright-tests/mission.spec.ts
index 301fc70..c930d0c 100644
--- a/samples/SampleApp/playwright-tests/mission.spec.ts
+++ b/samples/SampleApp/playwright-tests/mission.spec.ts
@@ -116,7 +116,7 @@ test.describe('Mission (SampleApp)', () => {
await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted');
});
- test('deny at the elevated-scope gate records access_denied without affecting the prior token', async ({ page, context }) => {
+ test('deny at the elevated-scope gate records denied without affecting the prior token', async ({ page, context }) => {
await page.goto('/mission');
await expect(page.locator('h2')).toContainText('Mission');
await waitForInteractive(page, 'button.btn-primary');
@@ -140,10 +140,10 @@ test.describe('Mission (SampleApp)', () => {
// an earlier token).
await expect(gateCard(page, 2).locator('.badge.bg-success').last()).toHaveText('granted');
- // Gate 3 — PROMPT, denied → access_denied.
+ // Gate 3 — PROMPT, denied → denied.
await expect(gateCard(page, 3).locator('.badge.bg-warning')).toHaveText('prompt');
await expect(gateCard(page, 3).locator('.badge.bg-danger')).toHaveText('denied');
- await expect(gateCard(page, 3)).toContainText('access_denied');
+ await expect(gateCard(page, 3)).toContainText('denied');
// Gate 5 — PROMPT, granted: denying gate 3 did not abort the mission.
await expect(gateCard(page, 5).locator('.badge.bg-success').last()).toHaveText('granted');
diff --git a/src/AAuth/Access/AAuthAccessServerEndpoints.cs b/src/AAuth/Access/AAuthAccessServerEndpoints.cs
index f21454a..c8fee2a 100644
--- a/src/AAuth/Access/AAuthAccessServerEndpoints.cs
+++ b/src/AAuth/Access/AAuthAccessServerEndpoints.cs
@@ -339,7 +339,7 @@ string Mint(
{
case AccessDecisionKind.Deny:
return Results.Json(
- new { error = "access_denied", detail = decision.Reason },
+ new { error = "denied", detail = decision.Reason },
statusCode: StatusCodes.Status403Forbidden);
case AccessDecisionKind.NeedsPayment:
{
@@ -425,7 +425,7 @@ string Mint(
}
case AccessPendingStatus.Denied:
return Results.Json(
- new { error = "access_denied", detail = entry.DenyReason },
+ new { error = "denied", detail = entry.DenyReason },
statusCode: StatusCodes.Status403Forbidden);
case AccessPendingStatus.Pending:
default:
@@ -525,7 +525,7 @@ string Mint(
case AccessDecisionKind.Deny:
pending.MarkDenied(entry.Id, decision.Reason ?? "access denied");
return Results.Json(
- new { error = "access_denied", detail = decision.Reason },
+ new { error = "denied", detail = decision.Reason },
statusCode: StatusCodes.Status403Forbidden);
case AccessDecisionKind.NeedsClaims:
default:
diff --git a/src/AAuth/Access/AccessServerClient.cs b/src/AAuth/Access/AccessServerClient.cs
index afb212f..19f57ee 100644
--- a/src/AAuth/Access/AccessServerClient.cs
+++ b/src/AAuth/Access/AccessServerClient.cs
@@ -203,12 +203,12 @@ public async Task FederateAsync(
else
{
// The push response itself carries the verdict (200
- // auth_token, 403 access_denied, or a structured error).
+ // auth_token, 403 `denied`, or a structured error).
response = pushResponse;
}
if (response.StatusCode == HttpStatusCode.Forbidden
- && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false))
+ && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false))
{
response.Dispose();
throw new AAuthInteractionDeniedException(
@@ -236,7 +236,7 @@ public async Task FederateAsync(
response = await PollDeferredAsync(pendingUrl, request.PollerOptions, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Forbidden
- && await IsAccessDeniedAsync(response, cancellationToken).ConfigureAwait(false))
+ && await IsDeniedAsync(response, cancellationToken).ConfigureAwait(false))
{
response.Dispose();
throw new AAuthInteractionDeniedException(
@@ -382,6 +382,14 @@ private async Task PollDeferredAsync(
return await new DeferredPoller(_signedClient, options)
.PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false);
}
+ catch (PollingErrorException ex) when (ex.ErrorCode == PollingErrorCode.Denied)
+ {
+ // §Polling Error Codes: `denied` (403) is an explicit denial. Surface
+ // the semantic interaction-denied exception so callers can distinguish
+ // it from a transport-level polling failure.
+ throw new AAuthInteractionDeniedException(
+ "The Access Server denied the request.", ex);
+ }
catch (TimeoutException ex)
{
throw new AAuthInteractionTimeoutException(
@@ -424,7 +432,7 @@ private static Uri ResolveSameOriginLocation(HttpResponseMessage response, Uri @
return pendingUrl;
}
- private static async Task IsAccessDeniedAsync(
+ private static async Task IsDeniedAsync(
HttpResponseMessage response, CancellationToken cancellationToken)
{
// Buffer the body so a subsequent ReadAuthTokenAsync still sees it,
@@ -448,7 +456,7 @@ private static async Task IsAccessDeniedAsync(
try
{
var json = JsonNode.Parse(body) as JsonObject;
- return (string?)json?["error"] == "access_denied";
+ return (string?)json?["error"] == "denied";
}
catch (System.Text.Json.JsonException)
{
diff --git a/src/AAuth/Access/IAccessPendingStore.cs b/src/AAuth/Access/IAccessPendingStore.cs
index 54b7bd7..afad308 100644
--- a/src/AAuth/Access/IAccessPendingStore.cs
+++ b/src/AAuth/Access/IAccessPendingStore.cs
@@ -43,7 +43,7 @@ public enum AccessPendingStatus
/// Approved — the next poll mints the auth token.
Allowed,
- /// Denied — the next poll returns 403 access_denied.
+ /// Denied — the next poll returns 403 denied.
Denied,
}
diff --git a/src/AAuth/Access/IAccessPolicy.cs b/src/AAuth/Access/IAccessPolicy.cs
index 3469e00..ea7a22f 100644
--- a/src/AAuth/Access/IAccessPolicy.cs
+++ b/src/AAuth/Access/IAccessPolicy.cs
@@ -10,7 +10,7 @@ namespace AAuth.Access;
/// Policy Decision Point for the AS token endpoint: given the verified request
/// context it returns an the
/// MapAAuthAccessServer host helper turns into the spec-mandated wire
-/// response (mint, 403 access_denied, 202 requirement=claims,
+/// response (mint, 403 denied, 202 requirement=claims,
/// 202 requirement=interaction, or 402 Payment Required). AAuth
/// crypto stays in the host; the policy only decides.
///
@@ -69,7 +69,7 @@ public enum AccessDecisionKind
/// Grant access — mint the auth token.
Allow,
- /// Deny access — 403 access_denied.
+ /// Deny access — 403 denied.
Deny,
/// An interactive user login/consent is required (§Trust Establishment).
diff --git a/src/AAuth/Agent/AAuthInteractionExceptions.cs b/src/AAuth/Agent/AAuthInteractionExceptions.cs
index 4277f7b..4548eb0 100644
--- a/src/AAuth/Agent/AAuthInteractionExceptions.cs
+++ b/src/AAuth/Agent/AAuthInteractionExceptions.cs
@@ -6,7 +6,7 @@ namespace AAuth.Agent;
///
/// Thrown when a deferred AAuth interaction terminates with explicit
/// user denial. Surfaced when the PS responds to a pending-URL poll
-/// with 403 and a body containing error: "access_denied".
+/// with 403 and a body containing error: "denied".
///
///
/// Distinct from a generic
diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs
index fd40a9a..928c861 100644
--- a/src/AAuth/Agent/DeferredExchange.cs
+++ b/src/AAuth/Agent/DeferredExchange.cs
@@ -44,7 +44,7 @@ internal sealed class DeferredExchangeOptions
///
/// Invoked after each poll in the interaction branch, before the loop
/// re-checks for a 202. Token exchange uses this to classify a polled
- /// 403 access_denied; the callback may throw. =
+ /// 403 denied; the callback may throw. =
/// no-op.
///
public Func? OnPolledResponse { get; init; }
@@ -197,7 +197,7 @@ internal async Task PostAsync(
response = await PollAsync(pendingUrl, options.PollerOptions, cancellationToken).ConfigureAwait(false);
- // Token exchange classifies a polled 403 access_denied here (only
+ // Token exchange classifies a polled 403 denied here (only
// after an interaction poll, matching the original placement).
if (options.OnPolledResponse is not null)
{
@@ -238,6 +238,14 @@ private async Task PollAsync(
return await new DeferredPoller(_signedClient, composed)
.PollAsync(pendingUrl, cancellationToken).ConfigureAwait(false);
}
+ catch (PollingErrorException ex) when (ex.ErrorCode == PollingErrorCode.Denied)
+ {
+ // §Polling Error Codes: `denied` (403) is an explicit user/approver
+ // denial. Surface the semantic interaction-denied exception so callers
+ // can distinguish it from a transport-level polling failure.
+ throw new AAuthInteractionDeniedException(
+ "The user denied the AAuth interaction request.", ex);
+ }
catch (TimeoutException ex)
{
throw new AAuthInteractionTimeoutException(
diff --git a/src/AAuth/Agent/TokenExchangeClient.cs b/src/AAuth/Agent/TokenExchangeClient.cs
index 8bec9d7..3b431e9 100644
--- a/src/AAuth/Agent/TokenExchangeClient.cs
+++ b/src/AAuth/Agent/TokenExchangeClient.cs
@@ -123,13 +123,13 @@ public async Task ExchangeAsync(
// Token exchange cannot complete consent without an interaction
// callback, so any deferred 202 with no callback fails fast.
RequireInteractionCallback = true,
- // §User Interaction: a user denial surfaces as 403 access_denied on
+ // §Polling Error Codes: a user denial surfaces as 403 `denied` on
// the poll. Classify it only after an interaction poll (matching the
// original placement) so a direct/clarification 403 stays a token error.
OnPolledResponse = async (resp, ct) =>
{
if (resp.StatusCode == HttpStatusCode.Forbidden
- && await IsAccessDeniedAsync(resp, ct).ConfigureAwait(false))
+ && await IsDeniedAsync(resp, ct).ConfigureAwait(false))
{
throw new AAuthInteractionDeniedException(
"The user denied the AAuth interaction request.");
@@ -169,16 +169,16 @@ private static IReadOnlyList InferCapabilities(
return capabilities;
}
- private static async Task IsAccessDeniedAsync(
+ private static async Task IsDeniedAsync(
HttpResponseMessage response, CancellationToken cancellationToken)
{
// Buffer the body so the subsequent ReadAuthTokenAsync (if we
- // decide it isn't access_denied) still sees it.
+ // decide it isn't a denial) still sees it.
var body = await DeferredExchange.BufferBodyAsync(response, cancellationToken).ConfigureAwait(false);
try
{
var json = JsonNode.Parse(body) as JsonObject;
- return (string?)json?["error"] == "access_denied";
+ return (string?)json?["error"] == "denied";
}
catch (System.Text.Json.JsonException)
{
diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
index 14dd9f7..46c6ee1 100644
--- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
+++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
@@ -107,7 +107,7 @@ private static async Task HandleMissionAsync(
{
case MissionApprovalOutcome.Declined:
return Results.Json(
- new { error = "access_denied", detail = decision.Message },
+ new { error = "denied", detail = decision.Message },
statusCode: StatusCodes.Status403Forbidden);
case MissionApprovalOutcome.Prompt:
@@ -117,7 +117,7 @@ private static async Task HandleMissionAsync(
{
// No user channel: a prompt cannot be resolved — decline.
return Results.Json(
- new { error = "access_denied" }, statusCode: StatusCodes.Status403Forbidden);
+ new { error = "denied" }, statusCode: StatusCodes.Status403Forbidden);
}
var parked = await store.ParkAsync(new DeferredConsent
{
@@ -324,7 +324,7 @@ await log.AppendAsync(new MissionLogEntry(
// Resolve a parked deferred consent once the user has decided (§Deferred
// Consent). Pending → 202 again; approved/declined → the final governance
- // response (mission blob / permission decision / access_denied).
+ // response (mission blob / permission decision / denied).
private static async Task HandlePendingAsync(
HttpContext ctx,
string id,
@@ -358,7 +358,7 @@ private static async Task HandlePendingAsync(
{
ctx.Response.Headers.CacheControl = "no-store";
return Results.Json(
- new { error = "access_denied", detail = "the user declined this mission" },
+ new { error = "denied", detail = "the user declined this mission" },
statusCode: StatusCodes.Status403Forbidden);
}
var proposal = entry.Proposal!;
@@ -375,7 +375,7 @@ private static async Task HandlePendingAsync(
return Results.Json(new { status = "ok" });
}
- // Permission: the endpoint always returns a decision (200), never access_denied.
+ // Permission: the endpoint always returns a decision (200), never a denial.
var request = entry.Permission!;
var granted = entry.Decision.Value;
if (request.Mission is not null)
diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
index 6e4ded2..55e761a 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
@@ -124,7 +124,7 @@ public async Task Mission_NoAgentToken_Unauthorized()
((IDisposable)app).Dispose();
}
- [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 access_denied")]
+ [Fact(DisplayName = "§Mission Creation — a declining approver yields 403 denied")]
public async Task Mission_DecliningApprover_Forbidden()
{
using var host = await BuildHostAsync(s =>
@@ -136,7 +136,7 @@ public async Task Mission_DecliningApprover_Forbidden()
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
var json = await ReadJson(response);
- Assert.Equal("access_denied", (string?)json?["error"]);
+ Assert.Equal("denied", (string?)json?["error"]);
await host.StopAsync();
}
@@ -176,7 +176,7 @@ public async Task Mission_Prompt_Parks202_ThenApprovalCompletes()
await host.StopAsync();
}
- [Fact(DisplayName = "§Deferred Consent — a declined mission poll resolves to 403 access_denied")]
+ [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 =>
@@ -197,7 +197,7 @@ public async Task Mission_Prompt_Declined_Forbidden()
using var done = await client.GetAsync("https://localhost" + location);
Assert.Equal(HttpStatusCode.Forbidden, done.StatusCode);
var json = await ReadJson(done);
- Assert.Equal("access_denied", (string?)json?["error"]);
+ Assert.Equal("denied", (string?)json?["error"]);
await host.StopAsync();
}
@@ -231,7 +231,7 @@ public async Task Permission_Prompt_Parks202_ThenGrant()
await host.StopAsync();
}
- [Fact(DisplayName = "§Deferred Consent — a declined permission poll resolves to a denied decision (200, not access_denied)")]
+ [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 =>
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..d12b600 100644
--- a/tests/AAuth.Tests/Integration/MockAccessServerTests.cs
+++ b/tests/AAuth.Tests/Integration/MockAccessServerTests.cs
@@ -184,7 +184,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 +199,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..c9bf122 100644
--- a/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
+++ b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
@@ -468,12 +468,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 +497,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;
From 9cab71fab5b3acdbe8861ae4e8cd72ad4dc9ef8c Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 16:51:56 +0000
Subject: [PATCH 19/24] feat(missions): add one-call Person Server SDK
(MapAAuthPersonServer)
Introduce a DI-friendly Person Server helper that mints AAuth person
tokens in a single mapped endpoint, routing on the resource token
audience for three-party (PS-issued) and four-party (trusted-AS
federation) flows.
- Person/AAuthPersonServerEndpoints.cs: MapAAuthPersonServer extension +
AAuthPersonServerOptions; carrier-type guard, mission three-gate
packaging, NeedsConsent -> 202 interaction requirement
- Person/IIdentityClaimsAsserter.cs: identity assertion seam
(Assert/Deny/NeedsConsent) with a default always-assert implementation
- Person/IPersonPendingStore.cs: pending interaction store for deferred
consent resolution
- Server/Governance/DelegateInteractionRelay.cs + DI extensions:
AddAAuthInteractionRelay lambda registration
- Wire MockPersonServer onto the SDK helper
- Conformance: Person/PersonServerMapperTests (8) covering both branches,
carrier guard, deny, deferred consent, and mission gates
---
samples/MockPersonServer/Program.cs | 8 +-
src/AAuth/Agent/DeferredExchange.cs | 13 +-
...hGovernanceApplicationBuilderExtensions.cs | 42 +-
...thGovernanceServiceCollectionExtensions.cs | 20 +
.../Person/AAuthPersonServerEndpoints.cs | 698 ++++++++++++++++++
src/AAuth/Person/IIdentityClaimsAsserter.cs | 171 +++++
src/AAuth/Person/IPersonPendingStore.cs | 235 ++++++
.../Governance/DelegateInteractionRelay.cs | 30 +
.../Governance/IDeferredConsentStore.cs | 8 +
.../GovernanceDeferredConsentMapperTests.cs | 133 +++-
.../Person/PersonServerMapperTests.cs | 335 +++++++++
.../Agent/InteractionChainingTests.cs | 9 +-
.../Integration/MockPersonServerTests.cs | 2 +-
13 files changed, 1691 insertions(+), 13 deletions(-)
create mode 100644 src/AAuth/Person/AAuthPersonServerEndpoints.cs
create mode 100644 src/AAuth/Person/IIdentityClaimsAsserter.cs
create mode 100644 src/AAuth/Person/IPersonPendingStore.cs
create mode 100644 src/AAuth/Server/Governance/DelegateInteractionRelay.cs
create mode 100644 tests/AAuth.Conformance/Person/PersonServerMapperTests.cs
diff --git a/samples/MockPersonServer/Program.cs b/samples/MockPersonServer/Program.cs
index 8513b43..9167a5e 100644
--- a/samples/MockPersonServer/Program.cs
+++ b/samples/MockPersonServer/Program.cs
@@ -207,14 +207,14 @@ static bool IsAdminAgent(string agentId) =>
{
return Results.Json(
new { error = "invalid_carrier_token", detail = $"expected {AAuthConstants.TokenTypes.AgentToken}, got {tokenType}" },
- statusCode: StatusCodes.Status401Unauthorized);
+ 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.Status401Unauthorized);
+ statusCode: StatusCodes.Status403Forbidden);
}
JsonObject? body;
@@ -705,12 +705,12 @@ await missionLog.AppendAsync(new MissionLogEntry(
var parsed = ctx.GetAAuthParsedKey()!;
if (ctx.GetAAuthTokenType() != AAuthTokenType.AgentToken)
{
- return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized);
+ return Results.Json(new { error = "invalid_carrier_token" }, 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.Status401Unauthorized);
+ return Results.Json(new { error = "invalid_carrier_token", detail = "missing sub" }, statusCode: StatusCodes.Status403Forbidden);
}
JsonObject? body;
diff --git a/src/AAuth/Agent/DeferredExchange.cs b/src/AAuth/Agent/DeferredExchange.cs
index 928c861..031bf66 100644
--- a/src/AAuth/Agent/DeferredExchange.cs
+++ b/src/AAuth/Agent/DeferredExchange.cs
@@ -179,8 +179,17 @@ internal async Task PostAsync(
{
var status = (int)response.StatusCode;
response.Dispose();
- throw new HttpRequestException(
- $"PS returned {status} (deferred response) but no onInteractionRequired callback was provided.");
+ // The PS deferred for user interaction but the agent supplied no
+ // interaction callback and did not declare the `interaction`
+ // capability — there is no channel to the user. Surface the
+ // terminal `user_unreachable` error (draft-02 §Token Endpoint
+ // Error Codes) so callers can branch on it, instead of a generic
+ // transport failure.
+ throw new AAuthTokenExchangeException(
+ new TokenErrorResponse(TokenErrorCode.UserUnreachable).ErrorCode,
+ $"PS returned {status} (deferred response) but no onInteractionRequired callback was provided.",
+ statusCode: 400,
+ isTerminal: true);
}
var interaction = requirement is null ? null : Interaction.FromRequirement(requirement);
diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
index 46c6ee1..d670265 100644
--- a/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
+++ b/src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs
@@ -80,7 +80,12 @@ private static async Task HandleMissionAsync(
var verification = ctx.GetAAuthVerification();
if (verification?.TokenType != AAuthTokenType.AgentToken || string.IsNullOrEmpty(verification.Agent))
{
- return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status401Unauthorized);
+ // The signature already verified (this is past the verification
+ // middleware); presenting a non-agent token is a semantic authorization
+ // refusal, not a signature-authentication failure, so it is a 403 — the
+ // §Error Responses 401/`Signature-Error` rule is reserved for the
+ // §Verification (Server) signature-failure steps.
+ return Results.Json(new { error = "invalid_carrier_token" }, statusCode: StatusCodes.Status403Forbidden);
}
var body = await ReadJsonAsync(ctx).ConfigureAwait(false);
@@ -291,6 +296,26 @@ await log.AppendAsync(new MissionLogEntry(
return Results.Json(new { answer = result.Answer ?? string.Empty });
case InteractionType.Completion:
+ // §Interaction Response: "The PS returns a deferred response while
+ // the user reviews." When the relay is still pending and a store is
+ // registered, park the completion and answer 202 + poll; the poll
+ // resolves to terminated (accepted) or active (follow-up). Without a
+ // store there is no user channel, so the relay's synchronous
+ // Accepted result is honored directly.
+ if (result.Pending)
+ {
+ var completionStore = ctx.RequestServices.GetService();
+ if (completionStore is not null)
+ {
+ var parkedCompletion = await completionStore.ParkAsync(new DeferredConsent
+ {
+ Kind = DeferredConsentKind.Completion,
+ Approver = request.Mission?.Approver ?? string.Empty,
+ Interaction = request,
+ }, ctx.RequestAborted).ConfigureAwait(false);
+ return DeferredAccepted(ctx, options, parkedCompletion.Id);
+ }
+ }
if (result.Accepted == true && request.Mission is not null)
{
await missions.SetStateAsync(request.Mission.S256, MissionState.Terminated).ConfigureAwait(false);
@@ -375,6 +400,21 @@ private static async Task HandlePendingAsync(
return Results.Json(new { status = "ok" });
}
+ if (entry.Kind == DeferredConsentKind.Completion)
+ {
+ // The user finished reviewing the completion summary (§Interaction
+ // Response). Accept → terminate the mission; follow-up/decline → the
+ // mission stays active.
+ var accepted = entry.Decision.Value;
+ var completionMission = entry.Interaction?.Mission;
+ if (accepted && completionMission is not null)
+ {
+ await missions.SetStateAsync(completionMission.S256, MissionState.Terminated).ConfigureAwait(false);
+ return Results.Json(new { mission_status = "terminated" });
+ }
+ return Results.Json(new { mission_status = "active" });
+ }
+
// Permission: the endpoint always returns a decision (200), never a denial.
var request = entry.Permission!;
var granted = entry.Decision.Value;
diff --git a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs
index c282f70..37fe943 100644
--- a/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs
+++ b/src/AAuth/DependencyInjection/AAuthGovernanceServiceCollectionExtensions.cs
@@ -1,5 +1,8 @@
using System;
+using AAuth.Agent.Governance;
using AAuth.Server.Governance;
+using System.Threading;
+using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection;
@@ -37,6 +40,23 @@ public static IServiceCollection AddAAuthGovernance(this IServiceCollection serv
return services;
}
+ ///
+ /// Register an backed by a
+ /// delegate, so a PS can supply its user channel with a lambda instead of a full
+ /// class (§Interaction Endpoint). Replaces any relay registered earlier (including
+ /// the no-op ).
+ ///
+ public static IServiceCollection AddAAuthInteractionRelay(
+ this IServiceCollection services,
+ Func> relay)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ ArgumentNullException.ThrowIfNull(relay);
+ services.RemoveAll();
+ services.AddSingleton(new DelegateInteractionRelay(relay));
+ return services;
+ }
+
///
/// Opt the governance mapper into the deferred-consent (202 poll) flow
/// for /
diff --git a/src/AAuth/Person/AAuthPersonServerEndpoints.cs b/src/AAuth/Person/AAuthPersonServerEndpoints.cs
new file mode 100644
index 0000000..105abaa
--- /dev/null
+++ b/src/AAuth/Person/AAuthPersonServerEndpoints.cs
@@ -0,0 +1,698 @@
+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);
+ return Results.Json(
+ new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message },
+ statusCode: StatusCodes.Status401Unauthorized);
+ }
+
+ // 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);
+ return Results.Json(
+ new { error = expired ? "expired_resource_token" : "invalid_resource_token", detail = ex.Message },
+ statusCode: StatusCodes.Status401Unauthorized);
+ }
+
+ 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/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/IDeferredConsentStore.cs b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
index be994af..9325a98 100644
--- a/src/AAuth/Server/Governance/IDeferredConsentStore.cs
+++ b/src/AAuth/Server/Governance/IDeferredConsentStore.cs
@@ -22,6 +22,14 @@ public enum DeferredConsentKind
/// 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,
}
///
diff --git a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
index 55e761a..9f9d504 100644
--- a/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
+++ b/tests/AAuth.Conformance/Missions/GovernanceDeferredConsentMapperTests.cs
@@ -104,7 +104,7 @@ public async Task Mission_DefaultApprover_ReturnsApprovedBlob()
await host.StopAsync();
}
- [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (401 invalid_carrier_token)")]
+ [Fact(DisplayName = "§Mission Creation — an agentless request is rejected (403 invalid_carrier_token)")]
public async Task Mission_NoAgentToken_Unauthorized()
{
var builder = WebApplication.CreateBuilder();
@@ -119,7 +119,7 @@ public async Task Mission_NoAgentToken_Unauthorized()
var response = await client.PostAsync("https://localhost/mission",
JsonContent(new JsonObject { ["description"] = "# Plan a trip" }));
- Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
await app.StopAsync();
((IDisposable)app).Dispose();
}
@@ -388,6 +388,135 @@ public async Task Interaction_PendingRelay_NoStore_Returns200()
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)
diff --git a/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs
new file mode 100644
index 0000000..bda1437
--- /dev/null
+++ b/tests/AAuth.Conformance/Person/PersonServerMapperTests.cs
@@ -0,0 +1,335 @@
+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 = "§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/Integration/MockPersonServerTests.cs b/tests/AAuth.Tests/Integration/MockPersonServerTests.cs
index c9bf122..9ed9d9a 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"]);
}
From ad3e9ae64de654fce544e2cf66b68239974ad445 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 16:52:02 +0000
Subject: [PATCH 20/24] docs(missions): document one-call Person Server API and
finalize plan
- token-issuance, configuration, dependency-injection: MapAAuthPersonServer
+ AAuthPersonServerOptions, IIdentityClaimsAsserter seam, interaction
relay registration, mission three-gate packaging
- error-handling: user_unreachable / no-interaction-capability section
- mission-governance, mission-governed-access, ps-asserted-access,
federated-access: one-call helper cross-links and deferred-completion note
- MockPersonServer README: SDK one-call-helper note
- plan: Phase 12 DoD ticks, DEV dispositions, research notes
---
.../implementation-plan.md | 248 +++++++++++++++++-
.../issues-and-deviations.md | 20 +-
.../research.md | 146 +++++++++++
docs/advanced/error-handling.md | 16 ++
docs/reference/configuration.md | 17 ++
docs/reference/dependency-injection.md | 35 +++
docs/server/mission-governance.md | 20 ++
docs/server/token-issuance.md | 102 +++++++
docs/workflows/federated-access.md | 7 +-
docs/workflows/mission-governed-access.md | 6 +
docs/workflows/ps-asserted-access.md | 9 +
samples/MockPersonServer/README.md | 8 +
12 files changed, 625 insertions(+), 9 deletions(-)
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
index b91d85f..a67891d 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
@@ -590,12 +590,254 @@ truth `src/AAuth/`.
---
+## 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.)_
+
+---
+
## Out of Scope
| Item | Reason |
|------|--------|
-| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` token claims, AS/MM fetch, resource enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` |
-| Implementing AS or MM as production SDK roles | Out of scope per mission research; mock servers only |
+| R3 (Rich Resource Requests) — models, RFC 8785 hasher, `r3_*` token claims, AS fetch, resource enforcement | Split into its own initiative — `.agent/plans/2026-06-06-r3-rich-resource-requests/` |
+| Mission Manager (MM) as a production SDK role | No MM party exists in the AAuth spec (parties are Agent / PS / Resource / AS). The AS already ships as a first-class role (`MapAAuthAccessServer`); the **PS** first-class mapper (`MapAAuthPersonServer`) moved **into scope** — Phase 12 W1. |
| Mission lifecycle beyond active/terminated (suspend/resume/revoke) | Deferred to companion spec (§Mission Management) |
-| `user_unreachable` (F5) and `prompt` finalization (F6) | Pending draft-02 publication |
+| `user_unreachable` PS **emit** (F5) and `prompt` finalization (F6) | Pending draft-02 publication. Phase 12 W2 lands the unblocked **agent-side** typed-exception classification now; the PS emit stays gated on draft-02 (DEV-14). |
| Payment settlement protocols (x402/MPP) | External; SDK only surfaces 402 + details |
diff --git a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
index 25b34aa..2eb790a 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/issues-and-deviations.md
@@ -43,21 +43,33 @@ These judgment calls were made during Phase 1. **All confirmed by the user on
|----|-------|------|---------|--------|--------|
| DEV-1 | 1 | Resource mapper | `MapAAuthGovernance` resolved `PermissionOutcome.Prompt` as a denial — no built-in deferred (202) user-consent channel. | §Permission Endpoint (deferred consent) | resolved (Phase 2, D3 — `IDeferredConsentStore` seam + `AddAAuthDeferredConsent()`; mapper parks `Prompt` and answers 202 + poll route. Store is opt-in so the existing `Prompt`→denied default is preserved. Interactive browser page stays a sample concern.) |
| DEV-2 | 1 | Resource mapper | Mission-creation endpoint not mapped by `MapAAuthGovernance`; approval-blob building + proposal approval stayed PS-side (`MissionApproval` was sample-local). | §Mission Creation, §Mission Approval | resolved (Phase 2, D3 — `MissionApprovalBuilder` + `IMissionApprover`/`DefaultMissionApprover` promoted into the SDK; `MapAAuthGovernance` maps the mission endpoint, persists the `StoredMission`, and emits the `AAuth-Mission` header. MockPersonServer now uses `MissionApprovalBuilder`.) |
-| DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. | §Interaction Endpoint | intentional |
+| DEV-3 | 1 | Default seams | `DefaultInteractionRelay` has no user channel: questions get an empty answer and completion is treated as not-accepted. A real PS must override it. Documented behavior, not a spec violation. **Phase 12 (W5):** the seam now has an ergonomic lambda channel — `DelegateInteractionRelay` + `AddAAuthInteractionRelay((request, ct) => …)` lets a PS supply its user channel as a one-liner (replacing the no-op default via `RemoveAll`). The no-op default stays for an unconfigured PS. | §Interaction Endpoint | fixed (Phase 12; build 0/0, conformance 480) |
| DEV-4 | 4 | MockPersonServer | Phase 4 listed "swap MockPersonServer's hand-wired `/mission` `/permission` `/audit` `/mission-interaction` + pending routes for `MapAAuthGovernance()`." Kept the hand-wired endpoints instead. They are bound to the deterministic `MissionConsentScript` (`/admin/*` scripting the e2e suite drives), the `MissionPolicyStore` that decides silent-vs-prompt **token exchange** at `/token`, and the interactive browser `/interaction` consent page. `MapAAuthGovernance()` targets the *non-interactive default* PS; reproducing this rich behavior through `IMissionApprover` + a custom `IDeferredConsentStore` is a large rewrite of a spec-conformant, e2e-green file for **zero spec/behavior gain**, against DC6 (no regressions). Consistent with **DEV-1**'s decision that the interactive browser page "stays a sample concern." The agent-facing call-sites (MissionAgent, Mission.razor, GuidedTour) ARE migrated to the new session API; server-side request parsing still uses `GovernanceEndpoints` + `MissionApprovalBuilder`. | §Mission Creation, §Permission Endpoint, §Audit Endpoint (deferred consent) | intentional |
| DEV-5 | 4 | WhoAmI | Phase 4 listed "WhoAmI — resource governance builder for mission-aware challenge." No such builder exists or was introduced (Phase 3 built the **agent** session surface, not a resource-side one). `ChallengeOptions { MissionAware = true }` IS the canonical, spec-correct resource-side seam (§Terminology: a *mission-aware resource* includes the mission object from the `AAuth-Mission` header in the resource tokens it issues), already used by `WhoAmI`. No agent-style manual `MissionClaim` threading exists on the resource side. No change required. | §Mission Context at Resources, §Terminology (mission-aware resource) | intentional |
| DEV-6 | 5 | SDK deferred exchange | Building the Phase 5 combined page surfaced two real SDK bugs in the clarification→interaction escalation path (`DeferredExchange.cs`). **Bug 1:** after a clarification round, the poller did not stop on a subsequent interaction `202` (the `stopOnInteraction` flag was not threaded through `PollAsync`/`ComposePollerOptions`), so the SDK kept polling instead of surfacing the user-approval gate. **Bug 2:** when a polled interaction `202` omitted the `Location` header, `ResolveLocation` returned null instead of falling back to the last pending URL, dropping the interaction URL the UI needs. Both fixed in the SDK and covered by `ChallengeClarificationSeamTests` (4/4). | §Clarification Chat, §Interaction Endpoint | fixed (Phase 5; conformance 4/4, build 0/0) |
| DEV-7 | 5 | SampleApp Blazor page | `MissionCallChain.razor` originally spawned a per-second `PeriodicTimer`/`Task.Run` poll-counter that called `InvokeAsync(StateHasChanged)` while parked on a prompt. Because the approval popup leaves the main tab backgrounded, Chromium throttles it and stops ACKing SignalR render batches; the timer filled the circuit's unacked-batch buffer (default max 10) and **paused all rendering ~120 s** until it drained — an e2e timeout. Root cause is a Blazor Server anti-pattern (background-timer `StateHasChanged` on a backgroundable tab), not the protocol. **Fix:** removed the cosmetic timer entirely; the polling banner is surfaced by a single `StateHasChanged` with a static spinner. (`Mission.razor` keeps its `ct`-bound timer and passes; left untouched to avoid regression risk.) | (sample/UI only) | fixed (Phase 5) |
| DEV-8 | 5 | e2e spec timing | `mission-call-chain.spec.ts`'s `approvePrompt` asserted an **exact** transient `toHaveCount(2)` after the step-2 approval. Unlike `mission.spec.ts` — where every gate parks on the next prompt so the count settles — step 3 here is **silent and final**: it advances with no gate and appends its card immediately, and Blazor **coalesces** the step-2 and step-3 `StateHasChanged` into one render batch (the DOM jumps 1→3, never showing 2). The exact count was therefore racy by construction; the page behaviour is correct (forcing artificial render flushes into product code to satisfy a test would be the anti-pattern). **Fix:** the helper now waits for the just-approved step's card via `expect(stepCard(page, expectedCards)).toBeVisible()` (i.e. ≥ expectedCards), matching the helper's own documented intent ("reach expectedCards"). The strict final `toHaveCount(3)` and all per-card content assertions are unchanged; `mission.spec.ts` is untouched. | (e2e test only) | fixed (Phase 5; two consecutive clean full-suite runs) |
| DEV-9 | 10 | SDK governance mapper | The Phase 10 independent spec-compliance review found a genuine non-compliance (NC-1): in `MapAAuthGovernance`'s interaction handler, the `interaction`/`payment` branch returned `200 {status:"ok"}` unconditionally and never read `InteractionRelayResult.Pending`, so a relay that signalled `Pending = true` could not drive the spec-mandated `202` + poll loop. §Interaction Response: *"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."* **Fix:** the handler now, when the relay returns `Pending` **and** an `IDeferredConsentStore` is registered, parks the interaction (new `DeferredConsentKind.Interaction`) and answers `202` with a poll `Location` (mirroring the permission/mission prompt path); the poll resolves to `200 {status:"ok"}` once the user completes. Without a store it degrades to a synchronous `200` (no user channel), consistent with the permission `Prompt`-without-store fallback. The agent side already polled `202`s correctly (`DeferredExchange`), so no client change was needed. Covered by 4 new `GovernanceDeferredConsentMapperTests`. | §Interaction Response, §Deferred Responses | fixed (Phase 10; conformance 12/12, build 0/0) |
-| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. Left as-is to avoid a relay-contract change that would risk the completion flow (DC6). | §Interaction Response | intentional |
+| DEV-10 | 10 | SDK governance mapper | Secondary observation from the Phase 10 review: the `completion` interaction branch resolves synchronously via the blocking relay (`InteractionRelayResult.Accepted`) rather than returning a deferred response while the user reviews (§Interaction Response: *"The PS returns a deferred response while the user reviews."*). Unlike NC-1 there is **no `Pending`-style field being dropped** — the relay contract for completion is intentionally synchronous (the relay blocks until the user accepts/declines), so this is a design simplification rather than an unwired contract. A real PS whose completion review is asynchronous can model it on its own relay; the default mapper's blocking model is spec-tolerable. **Phase 12 (W3):** the `completion` branch now honours `InteractionRelayResult.Pending` — when the relay defers **and** an `IDeferredConsentStore` is registered, the mapper parks a new `DeferredConsentKind.Completion` entry and answers `202` + poll `Location` (the user reviews the summary asynchronously), resolving to `200` on accept; without a store it degrades to the synchronous `Accepted` model. Symmetric with the DEV-9 interaction/payment fix. Covered by `GovernanceDeferredConsentMapperTests` (16/16). | §Interaction Response | fixed (Phase 12; conformance 480, build 0/0) |
| DEV-11 | 11 | Docs & code snippets | The Phase 11 docs-vs-SDK grounding review (subagent) found doc snippets that would not compile against the current SDK: (a) `mission-governance-clients.md`, `mission-governed-access.md` passed **bare strings** to `RequestPermissionAsync`/`RecordAuditAsync`, which take a `MissionAction` (no implicit `string` conversion exists) — CS1503; (b) `server/mission-governance.md`'s `MyPermissionDecider` compared `t.Name == context.Request.Action` (string vs `MissionAction`) — CS0019; (c) `dependency-injection.md` + `configuration.md` documented `AAuthAgentOptions` members that do not exist (`UseHwk()`, `InteractionHandling`, `InteractionHandlingOptions`, `BaseAddress`, `ChallengeHandling*`, `RefreshThreshold`, `Capabilities`, `InnerHandler`, `CallChainProvider`, `SignatureKeyProvider`) and omitted the real ones (`OnResourceInteraction`, `OnApprovalPending`, `PollingTimeout`); (d) `error-handling.md`'s `TokenErrorCode` listing omitted `MissionTerminated`; (e) `server/mission-governance.md` comment said the interaction route is `/interaction` (actual default `/mission-interaction`); (f) `dependency-injection.md` claimed the PS-side seams are "always supplied by the PS" whereas `AddAAuthGovernance` registers no-op defaults via `TryAdd`. **Fix:** all corrected against `src/AAuth/` (the source of truth — samples already used `new MissionAction(...)`/`tool.ToAction()` and build 0/0). | (docs only — grounded against SDK) | fixed (Phase 11) |
| DEV-12 | 11 | SDK denial wire code | The Phase 11 SDK-vs-spec review (subagent, S-1) found that the deferred-poll **denial** code was emitted/recognized as `access_denied` (OAuth/RFC 6749 vocabulary), but §Polling Error Codes defines the explicit-denial code as **`denied`** (`` `denied` | 403 | User or approver explicitly denied the request ``) — `access_denied` appears nowhere in the AAuth error tables. **Fix (user-approved full rename):** every site was renamed to `denied` with **no** backward-compat alias, since every emitter and classifier in this repo is ours and now round-trips `denied` end-to-end, keeping the system internally consistent and 100% spec-aligned. Scope covered: the SDK PS governance emits (`AAuthGovernanceApplicationBuilderExtensions`), the four-party AS path (`AAuthAccessServerEndpoints` 3 emits, `AccessServerClient.IsDeniedAsync` classifier — confirmed in-scope as those `403`/`AccessDecisionKind.Deny` responses are AAuth polling denials), the agent classifiers (`TokenExchangeClient.IsDeniedAsync`, `DeferredExchange` comments), interface/exception doc comments (`IAccessPolicy`, `IAccessPendingStore`, `AAuthInteractionExceptions`), all sample mocks (`MockPersonServer`, `Orchestrator`, `MockAccessServer`, `MissionAgent`), the SampleApp `Mission.razor` payload+narration, `GuidedTour` narration + classifier, all conformance/integration test asserts (incl. method `Pending_Returns403Denied_AfterDeny` and the Keycloak test emit), the Playwright specs, and `docs/advanced/interaction-chaining.md`. Spec is unaffected (`denied` is already the only denial code there). | §Polling Error Codes (L2023) | fixed (build 0/0) |
-| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Left as-is (LOW); changing the status/shape would churn our own just-written conformance tests for no protocol-observable gain. | §Error Responses | intentional |
-| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking) |
+| DEV-13 | 11 | SDK mission endpoint | Phase 11 review (S-2): `HandleMissionAsync`'s carrier-type guard returns `401 {error:"invalid_carrier_token"}` (JSON) when a non-agent token is presented. §Error Responses states *"A `401` response from any AAuth endpoint uses the `Signature-Error` header."* This is a **semantic** token-type check (the signature itself already verified), not a signature-authentication failure, so the spec's 401/`Signature-Error` rule is a poor fit; the behavior is pinned by two existing conformance/integration tests (`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). **Phase 12 (W4):** resolved by moving the guard to `403 {error:"invalid_carrier_token"}` — a valid-signature authorization failure, not a `401` authentication failure, so the spec's `401`/`Signature-Error` rule no longer applies. The two pinning tests were updated and `MockPersonServer` flipped its four `invalid_carrier_token` guards 401→403 to match. | §Error Responses | fixed (Phase 12; unit 387, conformance 480) |
+| DEV-14 | 11 | SDK token error code | Phase 11 review (S-3): `TokenErrorCode.UserUnreachable` (`user_unreachable`, 400) is **forward-looking** — it is defined in `upcoming-changes-02.md` (F5), not yet in the authoritative `draft-hardt-oauth-aauth-protocol.md`. The code comment cites draft-02, which is the agreed direction. Acceptable; tracked until draft-02 lands. **Phase 12 (W2):** the agent-side classification now ships — `DeferredExchange`'s no-callback deferred path throws a **terminal** `AAuthTokenExchangeException(user_unreachable, statusCode:400, isTerminal:true)` instead of hanging, documented in `error-handling.md`. The **PS wire-emit** of `user_unreachable` stays gated on draft-02 (this entry remains open until then). | §Token Endpoint Error Codes; upcoming-changes-02 (F5) | intentional (forward-looking; W2 agent-side landed Phase 12) |
+| DEV-15 | 12 | PS one-call mapper scope | Phase 12 W1 was originally scoped (in the plan's Implementation Decisions) to package only the **three-party PS-asserted** mint behind `MapAAuthPersonServer`, deferring four-party federation and the mission three-gate mechanics to keep the first cut small. **User-directed expansion (2026-06-07):** include **both** the three-party mint **and** four-party federation branches now, and fold the mission three-gate token-issuance mechanics (gate-1 terminated rejection, gate-2 prior-consent silent grant, gate-3 asserter prompt) into the mapper — while the interactive consent/clarification UI stays host-mapped (the asserter's `NeedsConsent` emits `202 requirement=interaction` and delegates the page to the host, consistent with DEV-1/DEV-4). Recorded in `implementation-plan.md` (W1 scope-guard revision + spec anchor). Spec basis: §Overview L162 (PS-asserted claims+consent), §PS-AS Federation L1466 (aud-keyed two-branch routing), §Agent Token Request / §Resource Tokens (prior-consent silent issuance). Covered by `PersonServerMapperTests` (8/8). | §Overview, §PS-AS Federation, §Agent Token Request | fixed (Phase 12; user-directed, spec-grounded; conformance 480, build 0/0) |
## Notes
- Known spec-alignment findings from research (F1–F6) are tracked in
[research.md](research.md) Part F; only deviations discovered **during
implementation** are logged here.
+- **Scheduled for Phase 12 (2026-06-07 deep review, [research.md](research.md) Part H):**
+ DEV-3 (W5 `DelegateInteractionRelay`), DEV-10 (W3 deferred completion), DEV-13
+ (W4 carrier-type 401→403), and DEV-14 (W2 agent-side typed exception now; PS emit
+ still gated on draft-02). A new additive item — `MapAAuthPersonServer` (W1) — is
+ added as the PS first-class role. Statuses flip to `fixed` as each item lands.
+- **Phase 12 dispositions (landed):** DEV-3 → fixed (W5), DEV-10 → fixed (W3),
+ DEV-13 → fixed (W4) — these three flipped from `intentional` once the Part H
+ review reclassified them as worth doing. DEV-14 stays forward-looking (W2 added
+ the agent-side classification; PS wire-emit awaits draft-02). DEV-15 records the
+ user-directed W1 scope expansion (both branches + mission three-gate in the
+ mapper). All Phase 12 work: solution build 0/0; unit 387; conformance 480.
diff --git a/.agent/plans/2026-06-06-mission-api-refactor/research.md b/.agent/plans/2026-06-06-mission-api-refactor/research.md
index fbb4a60..2f9d056 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/research.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/research.md
@@ -483,3 +483,149 @@ edit time.
> A secondary observation — completion review resolves synchronously rather than
> deferring (§Interaction Response L1212) — is logged as **DEV-10** (intentional:
> the completion relay contract is synchronous, no dropped `Pending` signal).
+
+---
+
+## Part H — Post-rename improvement backlog (deep review, 2026-06-07)
+
+After the DEV-12 `access_denied`→`denied` rename landed, the user asked for a deep
+review of the remaining SDK-improvement / spec-shape items, each grounded against
+the AAuth spec and the SDK source. Five items were confirmed for delivery (see
+Phase 12 in [implementation-plan.md](implementation-plan.md)). This section is the
+research backing — current state and the spec gap per item; no task lists.
+
+> **Correction to an earlier confabulation.** An interim chat note claimed the SDK
+> had "no production AS / Mission-Manager roles." That is wrong on both counts and
+> is corrected here:
+> - The **Access Server is already a first-class SDK role** — `MapAAuthAccessServer`
+> ([../../../src/AAuth/Access/AAuthAccessServerEndpoints.cs](../../../src/AAuth/Access/AAuthAccessServerEndpoints.cs))
+> with `AAuthAccessServerOptions`, an `IAccessPolicy` decision seam, and an
+> `IAccessPendingStore` deferral seam.
+> - There is **no "Mission Manager" party** anywhere in the spec or code. The four
+> AAuth parties are Agent, Person Server (PS), Resource Server, and Access Server
+> (§Terminology L105 / §Roles L142). "Missions" are an orthogonal governance
+> feature, not a party.
+> The real additive gap is the **Person Server**, which has no one-call mapper.
+
+### H.1 — `MapAAuthPersonServer` (additive; largest item)
+
+**Spec.** §Incremental adoption (L162) makes the PS the issuer of the three-party
+PS-asserted auth token and the PS half of four-party federation. §Auth Token
+Delivery defines the 7-step verification a PS runs on an AS-issued token before
+returning it.
+
+**Current SDK.** Every primitive exists; there is **no mapper**:
+
+- `AuthTokenBuilder` ([../../../src/AAuth/Tokens/AuthTokenBuilder.cs](../../../src/AAuth/Tokens/AuthTokenBuilder.cs)) — mints the auth token (collapsed PS+AS case).
+- `AuthTokenResponseValidator.ValidateAsync` ([../../../src/AAuth/Tokens/AuthTokenResponseValidator.cs](../../../src/AAuth/Tokens/AuthTokenResponseValidator.cs)) — the §Auth Token Delivery 7-step check.
+- `AccessServerClient` ([../../../src/AAuth/Access/AccessServerClient.cs](../../../src/AAuth/Access/AccessServerClient.cs)) — the PS→AS federation leg (push / poll / claims).
+- `TokenVerifier.VerifyResourceTokenAsync` — resource-token verification.
+
+**Gap.** `MockPersonServer`'s `/token` ([../../../samples/MockPersonServer/Program.cs](../../../samples/MockPersonServer/Program.cs))
+hand-assembles ~350 lines of orchestration: resource-token verification → the
+four-party federation branch (`aud` ≠ PS → forward to a trusted AS via
+`AccessServerClient` with `OnInteractionRequired` / `OnClaimsRequired`) → the
+three-party collapsed mint → mission gate → consent gate. The AS has a one-call
+`MapAAuthAccessServer` with an `IAccessPolicy` seam; the PS is the **only** server
+role without the equivalent. Proposed seam: `IIdentityClaimsAsserter` (mirrors
+`IAccessPolicy`) returning the directed `sub` + asserted claims and a
+silent/consent/deny decision, plus a PS-side pending/consent store mirroring
+`IAccessPendingStore`. The SDK keeps all crypto (verification, federation, mint,
+§Auth Token Delivery); the PS only decides identity + consent. **Effort: large.**
+The interactive `MockPersonServer` consent page + `MissionConsentScript` stay
+hand-wired (same rationale as DEV-4 / DEV-1: interactive browser flows are a sample
+concern); the mapper targets the non-interactive default PS.
+
+### H.2 — `user_unreachable` emit path (F5 / DEV-14)
+
+**Spec.** The authoritative draft (L1213) still says the PS returns
+`interaction_required` when it cannot reach the user and the agent lacks the
+`interaction` capability. `upcoming-changes-02.md` §2 (L32–47) splits this into two
+codes: `interaction_required` (202, non-terminal, carries a URL) vs
+`user_unreachable` (400, terminal hard-stop).
+
+**Current SDK — agent side is already forward-compatible.**
+
+- `TokenErrorCode.UserUnreachable` exists with both-direction wire mapping
+ ([../../../src/AAuth/Errors/TokenError.cs](../../../src/AAuth/Errors/TokenError.cs)).
+- `AAuthTokenExchangeException.IsTerminalCode` ([../../../src/AAuth/Errors/AAuthTokenExchangeException.cs](../../../src/AAuth/Errors/AAuthTokenExchangeException.cs))
+ already treats everything except `server_error` as terminal, so `user_unreachable`
+ surfaces as a terminal typed exception when a PS emits it.
+
+**Gap.** No PS emits `user_unreachable`, and the agent's no-callback path throws a
+**generic** `HttpRequestException` instead of a typed terminal exception —
+`DeferredExchange.PollAsync` ([../../../src/AAuth/Agent/DeferredExchange.cs](../../../src/AAuth/Agent/DeferredExchange.cs),
+the `RequireInteractionCallback && OnInteractionRequired is null` branch). The
+agent-side typed-exception classification is **unblocked** and can land now; the PS
+**emit** (return `400 user_unreachable` when no channel and no `interaction`
+capability) stays gated on draft-02, since emitting it today would diverge from the
+authoritative L1213 wording.
+
+### H.3 — deferred `completion` review (DEV-10)
+
+**Spec.** §Interaction Response (L1212): *"For `completion` type, the PS presents
+the summary to the user. The user either accepts … or responds with follow-up
+questions via clarification … The PS returns a deferred response while the user
+reviews."*
+
+**Current SDK.** `HandleInteractionAsync` ([../../../src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs](../../../src/AAuth/DependencyInjection/AAuthGovernanceApplicationBuilderExtensions.cs),
+the `Completion` arm) resolves synchronously off `result.Accepted` and returns
+`mission_status: terminated|active` immediately. The `interaction`/`payment` arm
+right below it already honors `result.Pending` → parks on `IDeferredConsentStore` →
+202 + poll (the DEV-9 fix); completion simply never consults `Pending`.
+
+**Gap.** `InteractionRelayResult` ([../../../src/AAuth/Server/Governance/IInteractionRelay.cs](../../../src/AAuth/Server/Governance/IInteractionRelay.cs))
+already carries `Pending`, and `DeferredConsentKind` already exists. The completion
+arm can reuse the exact park-and-poll machinery the interaction arm uses; only the
+`switch` arm is synchronous. Add a `Pending` check (new
+`DeferredConsentKind.Completion` or reuse `Interaction`), park + 202 + poll when a
+store is registered, and degrade to today's synchronous 200/`active` when no store
+(consistent with the permission/interaction fallback). **Effort: small-medium;**
+relay contract unchanged (sync relays keep using `Accepted`).
+
+### H.4 — `Signature-Error` on the mission 401 (DEV-13)
+
+**Spec.** §Error Responses (L1998): *"A `401` response from any AAuth endpoint uses
+the `Signature-Error` header."* L2108 scopes the 401 to a **signature-verification**
+failure.
+
+**Current SDK.** `HandleMissionAsync`'s carrier-type guard returns
+`401 {error:"invalid_carrier_token"}` as JSON with **no** `Signature-Error` header
+when a non-agent token is presented — the signature already verified; this is a
+semantic token-type rejection. The `SignatureError` helper + `SignatureErrorCode`
+enum exist ([../../../src/AAuth/Errors/SignatureError.cs](../../../src/AAuth/Errors/SignatureError.cs))
+but `invalid_carrier_token` is not a signature-error code.
+
+**Gap / options.** Either (A) keep 401 and add `Signature-Error: invalid_jwt`
+(satisfies L1998 literally, but the JWT was valid — just the wrong type), or
+(B) return **403** with the JSON `invalid_carrier_token` body (a 403 authorization
+refusal is not bound by the 401/`Signature-Error` rule, and a token-type mismatch is
+genuinely an authz decision). **Lean: Option B** — the more defensible reading,
+avoids overloading `invalid_jwt`. Trivial code; pinned by two existing tests
+(`GovernanceDeferredConsentMapperTests`, `MockPersonServerTests`). Lowest protocol
+value of the five.
+
+### H.5 — ergonomic `IInteractionRelay` default (DEV-3)
+
+**Spec.** §Interaction Endpoint expects the PS to relay to a user channel; a no-op
+default is spec-legal (it just cannot reach anyone).
+
+**Current SDK.** `DefaultInteractionRelay` ([../../../src/AAuth/Server/Governance/DefaultInteractionRelay.cs](../../../src/AAuth/Server/Governance/DefaultInteractionRelay.cs))
+returns empty answer / `Accepted=false` / `Pending=false`. Correct and documented;
+a real PS overrides it.
+
+**Gap (ergonomics, not compliance).** Overriding requires a whole `IInteractionRelay`
+class — there is no lightweight delegate option, unlike the agent side which uses
+`Func<>` callbacks throughout. Add a `DelegateInteractionRelay` (or an
+`AddAAuthGovernance(relay: async req => …)` overload) so a PS can supply a lambda.
+No spec change. **Effort: tiny; pure polish.**
+
+### Disposition summary
+
+| Item | Type | Spec anchor | Blocked? | Effort |
+|------|------|-------------|----------|--------|
+| H.1 `MapAAuthPersonServer` | additive role | §PS-asserted access, §Auth Token Delivery | no | large |
+| H.2 `user_unreachable` emit | correctness | upcoming-changes-02 §2 | emit gated on draft-02; agent classify unblocked | small |
+| H.3 deferred completion | correctness | §Interaction Response L1212 | no | small-medium |
+| H.4 `Signature-Error` 401 | correctness (cosmetic) | §Error Responses L1998 | no | trivial |
+| H.5 relay delegate default | ergonomics | §Interaction Endpoint | no | tiny |
diff --git a/docs/advanced/error-handling.md b/docs/advanced/error-handling.md
index b538dc3..ffd34e8 100644
--- a/docs/advanced/error-handling.md
+++ b/docs/advanced/error-handling.md
@@ -150,6 +150,22 @@ if (!response.IsSuccessStatusCode)
}
```
+### No interaction capability (`user_unreachable`)
+
+Deferred consent assumes the agent can reach the user. When the exchange resolves
+to a `202` deferred requirement but the agent declared **no** interaction
+capability (no `OnInteractionRequired` callback was supplied), there is no channel
+to drive the consent to a verdict, so the SDK does not hang or poll forever — it
+raises a **terminal** `AAuthTokenExchangeException` with
+`ErrorCode = "user_unreachable"`, `StatusCode = 400`, and `IsTerminal = true`.
+Treat it as a configuration signal: supply an interaction callback (interactive
+agent) or accept that the request cannot complete unattended.
+
+> **Forward-looking (draft-02).** The PS *emitting* `user_unreachable` on the wire
+> is a draft-02 addition; today the SDK classifies the unreachable-user case
+> agent-side. The `TokenErrorCode.UserUnreachable` code is already modelled so
+> adopters can pattern-match on it now.
+
## Polling Errors (Deferred Consent)
When polling a pending URL during deferred consent.
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
index 1725395..eb642a8 100644
--- a/docs/reference/configuration.md
+++ b/docs/reference/configuration.md
@@ -37,6 +37,23 @@ All configurable options across the AAuth .NET SDK, grouped by component.
| `AuthorizationEndpoint` | `string?` | `null` | AS authorization URL |
| `RevocationEndpoint` | `string?` | `null` | Revocation endpoint URL |
+### AAuthPersonServerOptions (via MapAAuthPersonServer)
+
+| Property | Type | Default | Description |
+|----------|------|---------|-------------|
+| `Issuer` | `string` | — (required) | HTTPS URL of this PS (`iss` of minted auth tokens) |
+| `SigningKeys` | `IReadOnlyDictionary` | — (required) | Key-id → signing key map (published at the PS JWKS) |
+| `TokenPath` | `string` | `/token` | Token endpoint path |
+| `PendingPathPrefix` | `string` | `/pending` | Deferred-consent poll path prefix |
+| `DefaultScope` | `string` | `whoami` | Scope assumed when the resource token omits one |
+| `InteractionPath` | `string` | `/interaction` | Path the host maps for the consent page |
+| `TrustedAccessServers` | `IReadOnlyCollection?` | `null` | AS URLs the PS will federate to; `null`/empty ⇒ three-party only |
+
+The helper resolves `IIdentityClaimsAsserter` and `IPersonPendingStore` from DI
+(and the `IMissionStore` / `IMissionLog` mission primitives when a request carries
+a `mission` claim). See
+[Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver).
+
## Token Builders
### ResourceTokenBuilder
diff --git a/docs/reference/dependency-injection.md b/docs/reference/dependency-injection.md
index ec9f104..cf91cfa 100644
--- a/docs/reference/dependency-injection.md
+++ b/docs/reference/dependency-injection.md
@@ -420,5 +420,40 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
```
+The user channel can also be supplied as a lambda instead of a full class, via
+`AddAAuthInteractionRelay(...)` (backed by `DelegateInteractionRelay`). It removes
+any previously registered relay (including the no-op default) and registers the
+delegate-backed one:
+
+```csharp
+builder.Services.AddAAuthInteractionRelay((request, ct) =>
+ Task.FromResult(new InteractionRelayResult { Accepted = true }));
+```
+
See [Mission Governance (Server)](../server/mission-governance.md) for the seams
and the decision model.
+
+### Person Server side: the token-issuance seams
+
+The one-call PS issuer `MapAAuthPersonServer` resolves two seams from DI — the
+identity/consent decision (`IIdentityClaimsAsserter`) and the deferred-consent
+park store (`IPersonPendingStore`):
+
+```csharp
+builder.Services.AddSingleton(
+ new DefaultIdentityClaimsAsserter("user-42")); // swap in a real asserter
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+app.MapAAuthPersonServer(new AAuthPersonServerOptions
+{
+ Issuer = psIssuer,
+ SigningKeys = new Dictionary { [PsKid] = psKey },
+ TrustedAccessServers = trustedAccessServers, // omit ⇒ three-party only
+});
+```
+
+When the resource token carries a `mission` claim, the helper also resolves the
+`IMissionStore` / `IMissionLog` primitives registered by `AddAAuthGovernance()`.
+See [Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver).
+
diff --git a/docs/server/mission-governance.md b/docs/server/mission-governance.md
index 0ab4a9a..b7970ce 100644
--- a/docs/server/mission-governance.md
+++ b/docs/server/mission-governance.md
@@ -36,6 +36,20 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
```
+For a lightweight user channel you can supply the relay as a lambda instead of a
+full `IInteractionRelay` class, via `AddAAuthInteractionRelay(...)` (backed by
+`DelegateInteractionRelay`). It replaces any relay registered earlier, including
+the no-op default:
+
+```csharp
+builder.Services.AddAAuthInteractionRelay(async (request, ct) =>
+{
+ // request.Type is question | completion | interaction | payment
+ var accepted = await myUserChannel.AskAsync(request, ct);
+ return new InteractionRelayResult { Accepted = accepted };
+});
+```
+
| Seam | Default (`AddAAuthGovernance`) | Who owns it |
|------|--------------------------------|-------------|
| `IMissionStore` | `InMemoryMissionStore` | SDK default; swap for durable storage |
@@ -83,6 +97,12 @@ the proposal to `IMissionApprover`, persists the resulting `StoredMission`, and
emits the `AAuth-Mission` response header. Reach for the manual mapping below only
when an endpoint needs behavior the seams do not express.
+> **Carrier-type guard.** The governed endpoints require the request to carry the
+> expected token type. When the wrong carrier is presented (e.g. an auth token
+> where the mission flow expects an agent token), the mapper refuses with `403`
+> `invalid_carrier_token` — an authorization failure on a valid signature, not a
+> `401` authentication failure.
+
## Parsing requests by hand
When a PS maps its own endpoints, `GovernanceEndpoints` maps request bodies to the
diff --git a/docs/server/token-issuance.md b/docs/server/token-issuance.md
index 1d00cc2..167b871 100644
--- a/docs/server/token-issuance.md
+++ b/docs/server/token-issuance.md
@@ -203,6 +203,108 @@ resource token the recipient MAY constrain `mission.approver` via
For the full PS-side evaluation of mission context, see
[Mission Governance (Server)](mission-governance.md).
+## One-Call Person Server (`MapAAuthPersonServer`)
+
+The builders above are the primitives. The whole Person Server token-endpoint
+pipeline also ships as a single host helper, `MapAAuthPersonServer` — the PS
+counterpart to [`MapAAuthAccessServer`](../workflows/federated-access.md#access-server-side-code).
+One call publishes the `/.well-known/aauth-person.json` metadata + JWKS, verifies
+the RFC 9421 request signature, verifies the presented `resource_token`, and then
+routes on the resource token's `aud` (§PS-AS Federation):
+
+- **`aud` = this PS** → three-party (PS-asserted): mint the auth token directly
+ (`dwk=aauth-person.json`, `iss`=PS).
+- **`aud` = a trusted Access Server** → four-party (federated): forward a signed
+ PS→AS request via `AccessServerClient` and return the AS-issued auth token after
+ the §Auth Token Delivery check.
+
+The host owns all AAuth crypto; the identity and consent decision is delegated to
+a pluggable `IIdentityClaimsAsserter`.
+
+```csharp
+using AAuth.Person;
+
+// The identity/consent seam (the PS counterpart to IAccessPolicy) and the store
+// that parks deferred consent decisions.
+builder.Services.AddSingleton(new DefaultIdentityClaimsAsserter("user-42"));
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+// One call maps /.well-known + JWKS, request-signature verification,
+// POST /token, and GET /pending/{id}.
+app.MapAAuthPersonServer(new AAuthPersonServerOptions
+{
+ Issuer = psIssuer,
+ SigningKeys = new Dictionary { [PsKid] = psKey },
+ DefaultScope = "whoami",
+ TrustedAccessServers = trustedAccessServers, // omit ⇒ three-party only
+});
+```
+
+### AAuthPersonServerOptions Properties
+
+| Property | Required | Default | Description |
+|----------|:--------:|---------|-------------|
+| `Issuer` | Yes | — | HTTPS URL of this PS (`iss` of minted auth tokens) |
+| `SigningKeys` | Yes | — | `kid → AAuthKey` map published at the PS JWKS |
+| `TokenPath` | No | `/token` | The token endpoint path |
+| `PendingPathPrefix` | No | `/pending` | The deferred-consent poll path prefix |
+| `DefaultScope` | No | `whoami` | Scope assumed when the resource token omits one |
+| `InteractionPath` | No | `/interaction` | Path the host maps for the consent page |
+| `TrustedAccessServers` | No | `null` | Access Server URLs the PS will federate to; `null`/empty ⇒ three-party only |
+
+### The `IIdentityClaimsAsserter` seam
+
+The asserter is the only PS-specific decision the helper cannot make for you —
+it returns the directed `sub` (plus optional `tenant` / `roles` / `groups` /
+additional claims) and the consent verdict. It mirrors `IAccessPolicy` on the AS
+side:
+
+```csharp
+public interface IIdentityClaimsAsserter
+{
+ Task AssertAsync(
+ IdentityAssertionRequest request, CancellationToken cancellationToken = default);
+}
+```
+
+The host maps the returned `IdentityAssertion` to the spec wire response:
+
+| `IdentityAssertion` | Wire response |
+| --- | --- |
+| `IdentityAssertion.Assert(sub, …)` | mint the auth token (three-party) / push the claims (four-party) |
+| `IdentityAssertion.Deny(reason)` | `403 denied` |
+| `IdentityAssertion.NeedsConsent()` | `202` + `AAuth-Requirement: requirement=interaction` + `Location` (poll `GET /pending/{id}`) |
+
+When the asserter returns `NeedsConsent()`, the helper parks the request and
+returns the `202`; the host's own interaction page (mapped at `InteractionPath`)
+collects the user's decision and resolves the parked entry via
+`IPersonPendingStore.MarkAllowed(...)` / `MarkDenied(...)`, after which the
+polling agent receives the minted token (or `403`). The consent UI stays a host
+concern — the SDK only owns the protocol mechanics.
+
+The shipped [`DefaultIdentityClaimsAsserter`](../../samples/MockPersonServer/)
+asserts a fixed directed `sub` with no prompt (a non-interactive demo PS); a
+production PS swaps in an implementation that derives the principal's directed
+identity and consent decision.
+
+### Mission three-gate packaging
+
+When the resource token carries a `mission` claim, `MapAAuthPersonServer` packages
+the mission three-gate token-issuance mechanics around the asserter, using the
+`IMissionStore` / `IMissionLog` primitives registered by
+[`AddAAuthGovernance()`](mission-governance.md):
+
+1. **Terminated mission** → `403 mission_terminated` (the asserter is never consulted).
+2. **Prior consent on record** for the `(resource, scope)` → silent mint, no prompt.
+3. **Otherwise** → the asserter decides (`Assert` mints + records the grant;
+ `NeedsConsent` parks the `202`).
+
+The interactive consent/clarification screen remains host-mapped; the helper only
+owns the terminated-rejection and prior-consent-silent-grant mechanics. See
+[Mission Governance (Server)](mission-governance.md) for the full model.
+
## Further Reading
- [Verification Middleware](verification-middleware.md) — signature verification before token logic
diff --git a/docs/workflows/federated-access.md b/docs/workflows/federated-access.md
index a67d24a..cbc0358 100644
--- a/docs/workflows/federated-access.md
+++ b/docs/workflows/federated-access.md
@@ -111,6 +111,11 @@ var authToken = await accessServerClient.FederateAsync(aud, new AccessServerRequ
return Results.Json(new { auth_token = authToken });
```
+> Both branches above — the three-party mint and the four-party federation — are
+> packaged in the one-call host helper `MapAAuthPersonServer` (set
+> `TrustedAccessServers` to enable the federation branch). See
+> [Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver).
+
## Access-Server-Side Code
The Access Server is the fourth party. The whole token-endpoint pipeline ships
@@ -145,8 +150,6 @@ The helper resolves `IAccessPolicy` and `IAccessPendingStore` from DI. The
policy returns one of `Allow` / `Deny` / `NeedsInteraction` / `NeedsClaims` /
`NeedsPayment`; the helper maps those to a minted auth token, `403`, or a `202`
that parks the decision and advertises the requirement to the PS.
-
-
### The `dwk=aauth-access.json` tell
The auth token's `dwk` (discovery well-known) claim points at
diff --git a/docs/workflows/mission-governed-access.md b/docs/workflows/mission-governed-access.md
index 3ec1111..236dc09 100644
--- a/docs/workflows/mission-governed-access.md
+++ b/docs/workflows/mission-governed-access.md
@@ -136,6 +136,12 @@ bool terminated = await session.ProposeCompletionAsync(
"Reconciled 24 receipts (2 duplicates removed) and emailed the summary.");
```
+If the PS's interaction relay cannot reach the user synchronously, it returns
+`InteractionRelayResult { Pending = true }`; the governance mapper then answers the
+completion proposal with a deferred `202` + poll `Location` (§Deferred Consent), and
+the agent's `InteractionClient` polls until the user accepts or declines — the same
+park-and-poll mechanics used for deferred permission consent.
+
After termination, any further governed request returns `403 mission_terminated`,
surfaced to the agent as `AAuthMissionTerminatedException` (see
[Error Handling](../advanced/error-handling.md#mission-termination)).
diff --git a/docs/workflows/ps-asserted-access.md b/docs/workflows/ps-asserted-access.md
index 0d9dbea..49f6cb1 100644
--- a/docs/workflows/ps-asserted-access.md
+++ b/docs/workflows/ps-asserted-access.md
@@ -151,6 +151,15 @@ See [Dependency Injection](../reference/dependency-injection.md) for full option
- **Autonomous**: PS has standing consent → returns auth token immediately (step 3→4)
- **Deferred**: PS requires user approval → returns 202 + pending URL → agent polls (see [Deferred Consent](deferred-consent.md))
+## Person-Server-Side
+
+The PS half of this flow (steps 3–4) ships as the one-call host helper
+`MapAAuthPersonServer`: it publishes the PS metadata + JWKS, verifies the request
+signature and the presented `resource_token`, delegates the identity + consent
+decision to a pluggable `IIdentityClaimsAsserter`, and mints the auth token (or
+parks a `202` deferred consent). See
+[Token Issuance → One-Call Person Server](../server/token-issuance.md#one-call-person-server-mapaauthpersonserver).
+
## Error Scenarios
| Status | Header/Token | Cause |
diff --git a/samples/MockPersonServer/README.md b/samples/MockPersonServer/README.md
index b779ac3..44745c2 100644
--- a/samples/MockPersonServer/README.md
+++ b/samples/MockPersonServer/README.md
@@ -51,6 +51,14 @@ The PS only federates to Access Servers listed in
`MockPersonServer:TrustedAccessServers`; any other `aud` is rejected with
`untrusted_access_server` (403).
+> **SDK one-call alternative.** Both branches above — the three-party collapsed
+> mint and the four-party federation routing — are packaged by the SDK host helper
+> [`MapAAuthPersonServer`](../../docs/server/token-issuance.md#one-call-person-server-mapaauthpersonserver),
+> with the identity/consent decision delegated to an `IIdentityClaimsAsserter`.
+> This sample keeps the endpoints hand-wired so it can render its own interactive
+> consent / mission screens; a non-interactive PS can adopt the one-call helper
+> directly.
+
## Agent governance (missions)
Beyond minting tokens, this PS doubles as the **contextual policy point** for
From ef7f5f89ee5be622984b8a031525f296a48dbf1e Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 18:03:24 +0000
Subject: [PATCH 21/24] feat(samples): add combined Mission Call Chain flow to
GuidedTour
Adds a TourMode.MissionCallChain flow that demos one mission governing both
a clarified elevated-scope grant (Clarification Chat) and a silent
mission-forwarded call chain through the Orchestrator to WhoAmI (Call
Chaining + Mission Log). Mirrors SampleApp's MissionCallChain flow.
- Engine: 4 new step methods (clarification exchange/answer, forwarded
chain, mission log) + 14-step plan dispatch in TourSession.
- UI: 8th flow option, lanes, and description in Tour.razor.
- Snippets: 4 illustrative SDK snippets in CodeSnippets.
- e2e: mission-call-chain.spec.ts (happy + deny paths); picker.spec
updated to expect 8 flows.
---
samples/GuidedTour/CodeSnippets.cs | 56 +++
.../GuidedTour/Components/Pages/Tour.razor | 28 +-
samples/GuidedTour/README.md | 9 +-
samples/GuidedTour/TourOptions.cs | 1 +
samples/GuidedTour/TourSession.cs | 461 +++++++++++++++++-
.../mission-call-chain.spec.ts | 151 ++++++
.../playwright-tests/picker.spec.ts | 7 +-
tests/e2e/helpers/tour.ts | 5 +
8 files changed, 707 insertions(+), 11 deletions(-)
create mode 100644 samples/GuidedTour/playwright-tests/mission-call-chain.spec.ts
diff --git a/samples/GuidedTour/CodeSnippets.cs b/samples/GuidedTour/CodeSnippets.cs
index bfbf73f..7a4097f 100644
--- a/samples/GuidedTour/CodeSnippets.cs
+++ b/samples/GuidedTour/CodeSnippets.cs
@@ -361,4 +361,60 @@ internal static class CodeSnippets
// gate 5 delete_inbox action . PROMPT (out of scope)
// The PS is the policy-enforcement point; the resource stays oblivious.
""";
+
+ // ── Combined mission + call chain (§Clarification Chat, §Call Chaining) ──
+
+ public const string MissionChainClarify = """
+ // Requesting whoami:elevated_scope is OUT of the mission's intent, so the
+ // PS opens a clarification chat BEFORE asking the user to decide.
+ var session = governance.MissionSessionFor(mission);
+ var authToken = await session.ExchangeAsync("https://ps.example", resourceToken,
+ new TokenExchangeRequest
+ {
+ // The SDK surfaces the PS's question and lets the agent answer.
+ OnClarificationRequired = (q, _) =>
+ Task.FromResult(ClarificationResponse.Respond(
+ "This mission needs the full account history to triage the inbox.")),
+ OnInteractionRequired = SurfaceToUser,
+ });
+ // Raw HTTP: POST /token → 202 + AAuth-Requirement: requirement=clarification
+ // + { clarification: "Why does this mission need…?" }
+ """;
+
+ public const string MissionChainAnswer = """
+ // Answer the PS's question on the mission-pending URL. The PS records the
+ // exchange in the mission log and readies the user's decision.
+ using var req = new HttpRequestMessage(HttpMethod.Post, missionPendingUrl);
+ req.Content = JsonContent.Create(new
+ {
+ clarification_response =
+ "This mission needs the full account history to triage the inbox.",
+ });
+ var resp = await signedClient.SendAsync(req); // → 204 No Content
+ // Now the agent surfaces {ps}/interaction?code={pendingId} for the user.
+ """;
+
+ public const string MissionChainForward = """
+ // The SAME mission now governs a multi-agent CALL CHAIN. WithMission binds
+ // the AAuth-Mission header; WithChallengeHandling threads the silent
+ // in-scope exchange; the Orchestrator forwards the mission downstream.
+ using var client = new AAuthClientBuilder(key)
+ .As("https://ps.example", agentId).WithKid(kid)
+ .WithPersonServer("https://ps.example")
+ .WithMission(mission)
+ .WithChallengeHandling() // (Orchestrator, orchestrate) is in scope
+ .Build();
+ var resp = await client.GetAsync("https://orchestrator.example/mission");
+ // 200: { chain, upstream, orchestrator, downstream } — downstream is
+ // WhoAmI's mission-bound /jwt/mission result. NO prompt: every hop in scope.
+ """;
+
+ public const string MissionChainLog = """
+ // DEMO-ONLY: read the mission's auditable trail by its s256 (§Mission Log).
+ var resp = await client.GetAsync($"https://ps.example/admin/mission-log/{s256}");
+ var log = await resp.Content.ReadFromJsonAsync();
+ foreach (var e in log.Entries)
+ Console.WriteLine($"{e.Kind} {e.Resource} {e.Scope} granted={e.Granted}");
+ // The 'clarification' entry records the question + the agent's answer.
+ """;
}
diff --git a/samples/GuidedTour/Components/Pages/Tour.razor b/samples/GuidedTour/Components/Pages/Tour.razor
index 2f514cd..3dc9ab6 100644
--- a/samples/GuidedTour/Components/Pages/Tour.razor
+++ b/samples/GuidedTour/Components/Pages/Tour.razor
@@ -41,6 +41,7 @@
+
@if (Session.Mode == TourMode.Identity)
@@ -166,6 +167,20 @@
creating the mission, the out-of-mission scope, and the out-of-scope action — everything in between flows without friction.
break;
+ case TourMode.MissionCallChain:
+
+ Signing mode: Agent Token (sig=jwt) — one durable
+ mission governs two very different kinds of access. An
+ out-of-mission elevated scope (whoami:elevated_scope) first triggers a
+ clarification chat — the PS asks why, the agent answers — and only
+ then prompts the user to approve. After that, a mission-forwarded call chain
+ (Agent → Orchestrator → WhoAmI) flows silently: because both hops
+ (orchestrate, whoami) are in the mission's scope, the Orchestrator
+ forwards the AAuth-Mission header downstream and no prompt is needed.
+ Two human approvals (mission creation, the clarified elevated scope) frame an
+ otherwise-silent multi-agent chain, and the PS's mission log records it all.
+
+ break;
}
@@ -200,8 +215,8 @@
Lanes="@ActiveLanes"
IsPolling="@Session.IsPolling"
PollCount="@Session.PollCount"
- LoopGhostStep="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? new SequenceDiagram.GhostStep(Session.PollStepNumber, $"polling /pending/{{id}}…", Actor.Agent, Session.PollLoopTarget) : null)"
- LoopAroundStepNumber="@((Session.IsDeferredMode || Session.IsFederatedMode || Session.IsCallChainPending || Session.IsMissionMode) ? 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()" />
@@ -259,9 +274,18 @@
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 :
Session.IsMissionMode ? MissionLanes : null;
diff --git a/samples/GuidedTour/README.md b/samples/GuidedTour/README.md
index 8abec96..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. Seven 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
@@ -44,6 +44,13 @@ available, switchable at runtime from the topbar **Mode** picker:
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
diff --git a/samples/GuidedTour/TourOptions.cs b/samples/GuidedTour/TourOptions.cs
index 207a8dd..69ee843 100644
--- a/samples/GuidedTour/TourOptions.cs
+++ b/samples/GuidedTour/TourOptions.cs
@@ -20,6 +20,7 @@ public enum TourMode
CallChain,
Federated,
Mission,
+ MissionCallChain,
}
///
diff --git a/samples/GuidedTour/TourSession.cs b/samples/GuidedTour/TourSession.cs
index b737e07..c947fb0 100644
--- a/samples/GuidedTour/TourSession.cs
+++ b/samples/GuidedTour/TourSession.cs
@@ -61,6 +61,16 @@ public sealed class TourSession : IAsyncDisposable
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? _clarificationAnswer;
+ 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;
@@ -156,6 +166,15 @@ public SigningMode SigningMode
///
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 .
@@ -182,6 +201,15 @@ public SigningMode SigningMode
/// 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
@@ -206,6 +234,7 @@ 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;
@@ -225,6 +254,7 @@ 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;
@@ -339,6 +369,32 @@ public IReadOnlyList Plan
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),
@@ -383,7 +439,10 @@ public IReadOnlyList Plan
/// The step number at which user approval occurs in deferred mode.
public int UserApprovalStepNumber =>
- IsMissionMode
+ IsMissionCallChainMode
+ ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreateApprovalStep
+ : MissionChainElevatedApprovalStep)
+ : IsMissionMode
? (Steps.Count <= MissionHop1PollStep ? MissionHop1ApprovalStep
: Steps.Count <= MissionHop2PollStep ? MissionHop2ApprovalStep
: MissionHop3ApprovalStep)
@@ -393,7 +452,10 @@ public IReadOnlyList Plan
/// The step number at which polling occurs in deferred mode.
public int PollStepNumber =>
- IsMissionMode
+ IsMissionCallChainMode
+ ? (Steps.Count <= MissionChainCreatePollStep ? MissionChainCreatePollStep
+ : MissionChainElevatedPollStep)
+ : IsMissionMode
? (Steps.Count <= MissionHop1PollStep ? MissionHop1PollStep
: Steps.Count <= MissionHop2PollStep ? MissionHop2PollStep
: MissionHop3PollStep)
@@ -418,6 +480,15 @@ public IReadOnlyList Plan
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
@@ -433,7 +504,7 @@ public IReadOnlyList Plan
/// and the UI should expose the "Approve as user" action button.
///
public bool AwaitingUserApproval =>
- (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode)
+ (IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode)
&& Steps.Count + 1 == UserApprovalStepNumber && !_userApproved;
/// The user-facing interaction URL captured during step 7 (deferred only).
@@ -519,6 +590,10 @@ private void ResetTimeline()
_missionResponseBody = null;
_missionEndpoint = null;
_permissionEndpoint = null;
+ _missionPendingId = null;
+ _clarificationQuestion = null;
+ _clarificationAnswer = null;
+ _missionChainResponseBody = null;
}
///
@@ -715,6 +790,47 @@ 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)
@@ -864,7 +980,7 @@ private void RefreshAgentToken()
///
public Task RecordUserApprovalOpenedAsync(CancellationToken ct = default)
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode)) { return Task.CompletedTask; }
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode)) { return Task.CompletedTask; }
if (Steps.Count + 1 != UserApprovalStepNumber)
{
throw new InvalidOperationException(
@@ -904,6 +1020,46 @@ 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;
@@ -1013,6 +1169,39 @@ 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
@@ -1703,7 +1892,7 @@ private async Task RunPendingPollAsync(
///
public Task StartPendingPollAsync()
{
- if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode) || _pendingUrl is null)
+ if (!(IsDeferredMode || (IsFederatedMode && _federatedPending) || IsCallChainPending || IsMissionMode || IsMissionCallChainMode) || _pendingUrl is null)
{
return Task.CompletedTask;
}
@@ -1726,6 +1915,12 @@ public Task StartPendingPollAsync()
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
@@ -1748,6 +1943,8 @@ public Task StartPendingPollAsync()
missionCreatePoll ? StepMissionPollCreateAsync(ct)
: missionElevatedPoll ? StepMissionElevatedPollAsync(ct)
: missionPermissionPoll ? StepMissionPollPermissionAsync(ct)
+ : missionChainCreatePoll ? StepMissionPollCreateAsync(ct)
+ : missionChainElevatedPoll ? StepMissionElevatedPollAsync(ct)
: hop2 ? StepCallChainPollHop2Async(ct)
: StepPollPendingAsync(ct);
await poll.ConfigureAwait(false);
@@ -3271,6 +3468,260 @@ private void StepMissionInspectResult()
});
}
+ 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.";
+ _clarificationAnswer = answer;
+
+ 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
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/picker.spec.ts b/samples/GuidedTour/playwright-tests/picker.spec.ts
index 78a5291..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 seven 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 seven 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(7);
+ await expect(flow.locator('option')).toHaveCount(8);
await expect(flow.locator('option')).toContainText([
'Bootstrap',
'Identity-based',
@@ -20,6 +20,7 @@ test('flow picker offers all seven flows and reacts to selection', async ({ page
'Call Chain',
'Federated (Four-Party)',
'Mission (PS-Governed)',
+ 'Mission + Call Chain',
]);
// Signing-mode picker only appears for the Identity flow.
diff --git a/tests/e2e/helpers/tour.ts b/tests/e2e/helpers/tour.ts
index 4fae430..12c9480 100644
--- a/tests/e2e/helpers/tour.ts
+++ b/tests/e2e/helpers/tour.ts
@@ -23,6 +23,7 @@ export const TourMode = {
CallChain: 'CallChain',
Federated: 'Federated',
Mission: 'Mission',
+ MissionCallChain: 'MissionCallChain',
} as const;
export type TourMode = (typeof TourMode)[keyof typeof TourMode];
@@ -55,6 +56,10 @@ const PLAN_STEPS: Record = {
// 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. */
From 31712675550da82f567766bc7be355f75cc6c191 Mon Sep 17 00:00:00 2001
From: Dasith Wijes
Date: Sun, 7 Jun 2026 18:03:29 +0000
Subject: [PATCH 22/24] docs(missions): record Phase 13 GuidedTour Mission Call
Chain plan
---
.../implementation-plan.md | 83 +++++++++++++++++++
1 file changed, 83 insertions(+)
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
index a67891d..021b4df 100644
--- a/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
+++ b/.agent/plans/2026-06-06-mission-api-refactor/implementation-plan.md
@@ -832,6 +832,89 @@ PS-asserted (three-party) and federated (four-party) issuer the spec defines.
---
+## 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 `