M2-14: Acting-for binding (SR9)¶
Status: ready | Depends on: M2-09 | Milestone: M2
Goal¶
An agent that uses the server API inside a run presents the run's invocation token in the request header Invocation-Token; the server checks it on every request (signature, audience, run active) and binds the request to the person who triggered the run, so that the agent has at most that person's rights at that moment: every action that reads content or writes into a channel is decided on the intersection of the agent's and the person's effective rights, and Scope gives every list, counter, snapshot and catch-up of that request only channels both may read. Only the exceptions of 07 "SR9" (replies in the run's thread, patches of the own run, run.finish, interaction.respond, agent_session.report) use the agent's own rights, and only for the own thread and run. Without the header the agent keeps its own rights and the console shows hint H1. This closes the confused deputy over the server API (T-AI-02, T-AI-04, SEC-AI-3): a person with trigger on Support can no longer make an agent that also reads Engineering summarise Engineering for them, and a revocation stops the agent's tool calls at once. After this ticket AZ-M44, AZ-M45 and AZ-P11 are green (M2 exit criterion 3).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria 3 and 10, Test strategy, Red team first paragraph: "confused deputy through the server API", Risks: prompt injection) and the block introduction "Block agents and extensions"; rows M2-06 to M2-09 (what exists), M2-14 (this ticket), M2-10, M2-12, M2-13, M2-15, M2-19 (operations of the SR9 exceptions and of run reads, built by others), M2-29, M2-30, M2-31 (SDKs and conformance suite), M3-14 (search in a run); M1 rows M1-28, M1-49, M1-70.- Dependencies: the header names M2-09. Through it this ticket also builds on M2-08, M2-07, M2-06, M2-05, M2-04 and M2-02 and on the M1 tickets M1-12, M1-13, M1-18, M1-24, M1-25, M1-28, M1-29, M1-32, M1-33, M1-34, M1-44, M1-45, M1-48, M1-49, M1-51 to M1-56, M1-60, M1-61, M1-63 to M1-65 (all implied, so not in the header). M1-70 is not in the closure: do not rely on
services/server/test/leakor the SQL visibility lint of M1-70; use them only if they are on the base branch. M1-71 is in the closure (through M2-02 and M1-69), so its shared test harnessservices/server/test/harnessis on the base branch. docs/spec/07-permissions.md: 1 "Principles" (points 5 and 7); 2.1, 2.3, 2.7 "Owner", 2.9 "Agents as principals" (point 6); 3 "Principal context" (rowacting_for); 4.1 "Decisions and reasons" (rowsacting_for_insufficient,invocation_token_invalid); 4.2 "Flow" (step 4if p.acting_for, step 5, the audit paragraph, filter mode); 4.3; "SR5 Administration only by humans with confirmation"; "SR7 Disabled means no permissions"; "SR9 Acting-for binding (confused deputy)" completely; 6 "Read horizon of agents" (H1); 7.2, 7.3, 7.4 "Query pattern" (rule 1:Scopewithacting_for); 8.1 "Delivery filter" (row "Agent consumers"); 8.2 "Immediate effect on revocation" (point 4); 9.1 "Who has access?" (via_agent); 10 "Examples"; 11.1 rows AZ-M44, AZ-M45; 11.2 rows AZ-P8, AZ-P11; 12 (R-RE-06).docs/spec/05-actions.md(core contract, read only): 2.2 "Target of a resource", 2.3 "Effective level" (point 1: owner rights only in a session, with the exception of R-AZ-29; point 6: acting-for binding), 2.5 "Decision", 2.7 "Effect (effect)", tables 4.6, 4.7, 4.9 and 4.10 (rowsmessage.create,run.update,run.finish,interaction.respond,agent_session.report,run.start,run.token.renew,run.context.read,agent.events.consume), 5 "Agent invocationagent.invoke" (claimssub,run_id,thread_id,trigger_kind; the paragraph "Check without a request"); 8 (R-AZ-14, R-AZ-29).docs/spec/00-glossary.md: row "Acting-for binding" (acting_for).docs/spec/09-agents.md: 4.5 "Invocation token" (header, claims, server check), 4.5.1 "Fresh token for a waiting run" (step 1 checks without expiry), 4.7 "Adapter operations", 5.1 "State machine" (active states).docs/spec/06-api.md: 3 "Authentication per principal kind" (row Agent), 4 "Authorization in the API", 5 "Errors" (generated table, rowinvocation_token_invalid), 7 "Idempotency".docs/spec/02-architecture.md: 4.1 "Rules" (rule 4: source ports), 4.3 "Ports, events and sources per module" (rowsidentity,runs), 4.4 "Allowed dependencies", 4.5 "Cross-cutting ports" (kernel/actor,kernel/authorize), 5 "Layers within a module", 6 "Code layout" (list of product-name places;test/integration), 9.1 "Chain in the api node" (step 7), 9.2, 9.3 "Proof of the check", 11.3 "Permission cache".docs/spec/04-sync-protocol.md: 2.2 "Envelope" (rule 3: no tokens in payloads), 8.1 "Basic rule", 15.1 to 15.3.docs/spec/08-identity.md: 7.1 "Overview" (row Invocation token), 12 "API tokens for agents, apps and tools".docs/spec/11-extensions.md: 11.2 "Hand-written layer" (rowsinvocation,runs).docs/spec/14-search.md: 4.3 "Permission filter" point 5 (search in a run, built by M3-14).docs/spec/19-security.md: SEC-AI-3, SEC-LOG-2, SEC-AUD-5.docs/api/openapi.yaml:components.securitySchemesbearerAuthanddpopAuth;components.parameters(style ofIdempotencyKey);x-problem-codesofProblem(invocation_token_invalid,forbidden,not_found); responsesForbidden,NotFound; operationsgetMessage,createMessage,finishRun,awaitRunInput,startRun,resumeRun,renewInvocationToken,pollAgentEvents,getSnapshot,listEvents,listMyMentions,listMyReadStates,listSpaces,listSpaceChannels,listChannelThreads,listThreadMessages,listThreadRuns,listActiveRuns,explainChannelAccess,createGrant,revokeGrant,setChannelOperator; and, if enabled on the base branch,patchRun,reportAgentSession,respondToInteraction,getRunContext.- ADRs 0008, 0013, 0017, 0018, 0021, 0022, 0023, 0053, 0067.
docs/DECISIONS.md: D-RE-03, D-W-15, D-W-19, D-W-100; section 10 for the format of new rows (D-M2-14-<n>).docs/security/threat-model.md: TB4, T-AI-01, T-AI-02, T-AI-04, T-TK-05.docs/RISKS.md: RK-01. No item ofdocs/OPEN-QUESTIONS.mdconcerns this ticket.docs/observability/signals.md:bothy.authz.decisions,bothy.authz.errors(no new metric here).docs/operations/configuration.md:agent.invocation_token_ttl,auth.clock_leeway,authz.snapshot_cache_max_entries,audit.deny_aggregate_window(no new key).- Briefs: M1-18 (
StageAuthenticate,actor.Credentials, check logRecordCheckwithCheckScope,httpservertest), M1-24 (auditing.Recorder,audit_details.v1), M1-28 (actor.ActingFor{UserID, RunID, ThreadID ids.ID; JTI string}, theActingForintersection ineffectiveandScope, reasonacting_for_insufficientfor visibility, AZ-P8 reference, matrix world fakes ininternal/authz/adapters/fake), M1-34 (internal/identity/app/authenticator.go, the authenticator never rejects on flags), M1-44 (API token path, no cache of token state), M1-49 (via_agent,explainChannelAccess), M1-63 (Scopein snapshot and catch-up,unread_threads), M1-65 (authorize.Delivery(ctx)), M2-06 (app.InvocationTokens.VerifywithVerifyInvocation{Audience, RunID, IgnoreExpiry}; Design note 1: "M2-14 adds the port with its own DTOs"), M2-07 (REST poll in delivery mode), M2-08 (Design note 4: the re-check principal of the triggering person), M2-09 (Decision.Detail, the mapping ofinvocation_token_invalidwithreasonequal toDetail, the test world with a run of Alex). - Code on the base branch:
internal/kernel/actor,internal/kernel/authorize,internal/kernel/auditing,internal/kernel/clock,internal/kernel/errs,internal/authz(domain, app, fakes),internal/identity(port,app/authenticator.go),internal/runs(M2-05 to M2-09: port,app/invocation.go, repository),internal/platform/httpserverandhttpservertest,internal/platform/pg/pgtest,internal/identity/adapters/identitytest,internal/wire,architecture/modules.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- For every action of an agent in a run that reads content or writes into a channel:
eff(agent in run, target) = eff(agent, target) ∩ eff(triggering person, target), where∩is the lower level and the intersection of the capabilities (07 "SR9", 07 4.2 step 4, 05 2.3 point 6, 00 "Acting-for binding", SEC-AI-3, R-RE-06). - The agent presents the checked invocation token of the run in a request header whose name 06 fixes (07 "SR9"); the server checks it on every request (signature,
aud, run active) and computes the person's rights at that moment, never from the token, so a revocation applies at once to the agent's tool calls (07 "SR9", 07 3 rowacting_for, 07 8.2 point 4). - Only the agent's rights apply to replies in the run's thread, patches of the own run,
run.finish,interaction.respondandagent_session.report, and only for the own thread and run (07 "SR9"; PLAN row M2-14). Scopecuts its lists with those of the triggering person; every list, counter, the snapshot and the catch-up of an agent in a run contain only channels the person may read too (07 7.4 rule 1, D-W-19; PLAN row M2-14).- A target that is invisible only because of the intersection answers
404exactly like an unknown ID, with deny reasonacting_for_insufficientin the audit; a requirement that fails only because of the intersection answers403 forbiddenwithreason: acting_for_insufficient(07 4.1, 4.2 step 5, R-AZ-01, AZ-M44). - Without a token the agent acts with its own rights; the console shows hint H1 for the triggering people (07 6, 9.1
via_agent, AZ-M45). - Deliveries to an agent consumer are judged by the agent's own grants (07 8.1 row "Agent consumers").
- Every denial of a request is audited (07 4.2, R-AZ-04); a token never appears in logs, traces, audit details, problem bodies or events (SEC-LOG-2, SEC-AUD-5, 04 2.2 rule 3).
Scope¶
All paths under services/server/ unless they start with docs/. Names of M2-05 to M2-09 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
internal/authz/domain/actingfor.go: the closed mode tableActingForModeOf(a authorize.Action) ActingForModewith the modesActingForBound,ActingForOwnException,ActingForUnbound(Design note 5), and the pure functionActingForApplies(mode ActingForMode, af actor.ActingFor, path Path, r authorize.Resource, delivery bool) bool(whether the intersection applies).Pathstands for the resolved path type of M1-28's domain.internal/authz/domain/testdata/actingfor_modes.golden: the exception and unbound action ids, one per line.internal/runs/domain/actingfor.go: pureActingForVerdict(c InvocationClaims, run RunBinding, found bool, agentID ids.ID) (ActingForResult)withRunBinding{ID, AgentID, TriggeredBy, ThreadID ids.ID; State string}andActingForResult{Status string; UserID, RunID, ThreadID ids.ID; JTI string}(Design note 3). Domain imports onlykernel-core, so the status is a runs-local string type with the three values of note 2.internal/runs/app/actingfor.go: use caseResolveActingFor(ctx context.Context, agentID ids.ID, token string) (ActingForResult, error)overInvocationTokens.Verify(M2-06) and the repository read of note 3c.internal/runs/adapters/postgres/queries/run_binding.sql:GetRunBinding :oneselectingid, agent_id, triggered_by, thread_id, stateof one run by primary key; regenerate sqlc.internal/runs/adapters/actingfor/source.go: typeSourcethat implementsidentity.ActingForSourcestructurally (onlykerneltypes in the signature) by calling the use case and mappingActingForResulttoactor.ActingFor.test/integration/actingfor/(componenttestsupport): the cross-module integration tests of this ticket and the golden filetestdata/sr9_scope_operations.golden.
Change:
internal/kernel/actor:ActingForgainsStatus ActingForStatuswith the constantsActingForBound("bound"),ActingForRunInactive("run_inactive"),ActingForInvalid("invalid"); the zero value (emptyStatus) means "no header". Add(ActingFor) Present() bool.CredentialsgainsInvocationTokens []string(every value of the header). Doc comments name 07 "SR9" and Design note 2.internal/platform/httpserver:StageAuthenticatefillsCredentials.InvocationTokensfromr.Header.Values("Invocation-Token"); the header stays out of every log and span (the allowlist of SEC-LOG-2 must not gain it; add it to the denylist test of the access log if one exists).internal/identityport: declareActingForSource { ResolveActingFor(ctx context.Context, agentID ids.ID, token string) (actor.ActingFor, error) }(02 4.1 rule 4, source port of a lower module).internal/identity/app/authenticator.go: the steps of Design note 2.internal/authz:decide.go,effective.go,visibility.go,app/authorizer.go,app/scope.go,app/cache.gofor Design note 4; the AZ-P8 slow reference gains SR9 (Design note 4i); the matrix world fakes gain a helper that builds an agent principal withActingFor.internal/wire: bindruns/adapters/actingfor.Sourceas theidentity.ActingForSourceof the authenticator, with the compile-time assertionvar _ identity.ActingForSource = (*actingfor.Source)(nil)ininternal/wire(Design note 3h).docs/api/openapi.yaml:components.parameters.InvocationToken(name: Invocation-Token,in: header,schema: {type: string}, description per Design note 1); one sentence in the descriptions ofbearerAuthanddpopAuthnaming the header and SR9; theinvocation_token_invalidentry ofx-problem-codesextended for the header with the reasonsinvalidandrun_inactiveand itsclient_actionfor both. Thenmake api-lint,make api-checks,make api-tables(regenerates the table of 06 5) and the server code generation.docs/spec/06-api.md(English): 3 row "Agent", column "Headers per request": "within a run additionallyInvocation-Token: <invocation_token>(07 SR9)"; 4 a new point 8 "Acting-for binding" with the rules of Design notes 2, 4 and 5 in two to four sentences.docs/spec/07-permissions.md(English): 3 rowacting_fornames the header; 4.1 rowsinvocation_token_invalid(also every request withInvocation-Token,reasoninvalidorrun_inactive) andacting_for_insufficient(both decisions as built); 4.2 step 1 the two status checks and step 4 the exceptions, the two unbound actions and the delivery mode; "SR9" one paragraph with the binding check of note 3, the exception rule of note 5, the triggering person's rights of note 4e and the residuals of note 9.docs/spec/02-architecture.md(English): 4.3 rowidentity"declaresActingForSource(invocation token of the request, SR9)", rowrunscolumn "Implements sources"identity.ActingForSource; 9.1 step 7 "and the invocation token fromInvocation-Tokenthroughidentity.ActingForSource(SR9)".docs/spec/11-extensions.md(English): 11.2 rowruns"setsInvocation-Tokenon every request during a run (07 SR9);403 invocation_token_invalidwithreason: run_inactiveends the work on the run".docs/spec/09-agents.md(English): 4.7 one sentence that every request of the adapter inside a run carriesInvocation-Token(07 SR9).docs/security/threat-model.md: add this ticket's test names to the test column of T-AI-02 and T-AI-04.- If
tools/lint/sql_visibility.yaml(M1-70) is on the base branch: addGetRunBindingto its exception list with the reason of note 3f. - READMEs of
authz,identity,runsandplatform/httpserver; package doc ofkernel/actor;services/server/CHANGELOG.md;docs/DECISIONS.mdsection 10 rowsD-M2-14-<n>for every Ruling below.
This ticket creates no migration, enables no operation in api/operations.yaml, adds no configuration key, no event, no job, no metric and no new top-level make target.
Out of scope¶
- The SDKs setting the header on every request of a run (07 "SR9": "The SDKs ... automatically bind the token ..."): M2-29 (Python) and M2-30 (TypeScript); the reference adapters: M2-33 and M2-34. This ticket defines the header and its answers.
- Search of an agent in a run (14 4.3 point 5, D-W-19): M3-14; it relies on the
Scopeintersection made exact here. - End-to-end tests of the exceptions on routes enabled by other tickets:
patchRunand the gateway oprun.update(M2-10),reportAgentSession(M2-12),respondToInteraction(M2-15), agent replies withMessageCreate.run_id(M2-19), and the bound readgetRunContext(M2-13). This ticket decides them inauthz(unit tests) and adds the end-to-end case only for those already enabled on the base branch (Tests). - Attachments of an agent reply (
file.upload, M3): see the spec gap in the report. - The AZ-P5 property test and the AZ-P12 lint (M1-70); the conformance suite with the fake agent (M2-31).
- Token issuing and verification (M2-06), the delivery re-check principal (M2-08), the run lifecycle and the cancel on revocation (M2-09).
- "Who has access" itself (M1-49) and its console screens (M6).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-14-acting-for; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(authz): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip. - Stop only processes you started, by PID. Temporary directories only via
mktemp -d, removed at the end. - Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
- Never lower a threshold, disable a gate or delete a test.
- If the core contract lacks something this ticket needs, stop with
Verdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - The same holds for M2-05 to M2-09: their merged names win over the expected names here (for example the request principal type
actor.Actororactor.Principal, the verifierInvocationTokens.Verify,Decision.Detail); list each difference. If the merged M2-08 has no re-check principal for the triggering person (its Design note 4), build the triggering person's snapshot with the semantics of note 4e here and say so in the report. - No real agent runtime and no model call: the adapter in the tests is plain Go HTTP code with an agent API token; no third-party service.
- Time only through
kernel/clock; tests move a fake clock, they never sleep for a window. - Invocation tokens and API tokens never appear in logs, audit details, problem bodies, event payloads or test failure messages (print a hash prefix of the
jtiif needed).
Design notes¶
-
Header (Ruling for docs/DECISIONS.md; 07 "SR9" leaves the name to 06, which has none). Name
Invocation-Token, value the compact JWS exactly as delivered inEventEnvelope.invocation_token. No product name (02 6 lists the only places for it, and M1-46 tests that list), noX-prefix, likeIdempotency-KeyandCorrelation-Id. It is declared once ascomponents.parameters.InvocationTokenand named in the descriptions ofbearerAuthanddpopAuth, not as a parameter of each operation: likeAuthorizationit is an input of step 7 of 02 9.1 for every route, and the schema validation of step 13 ignores undeclared headers. The DPoP proof does not cover it; it needs no binding of its own because it never adds a right (note 3e). -
Authentication (02 9.1 step 7,
internal/identity/app/authenticator.go), after the existing paths of M1-34 and M1-44: a. No value ofInvocation-Token:ActingForstays zero. b. Values but noAuthorizationheader:401 unauthenticated(a run binding needs an authenticated agent; this counts as failed credentials forrl.public_authlike every other401of this step, 02 9.1). c. More than one value, an empty value, or a principal whose kind is notagent(people with a session, personal tokens, apps):ActingFor{Status: invalid}without calling the source; the token'saudcan only name an agent (05 5). d. Exactly one value and an agent:ActingForSource.ResolveActingFor(ctx, agentID, token); its result goes into the actor. An error of the source (database) is503 unavailable, never an actor without the binding (07 1 point 5, fail closed). e. The authenticator never rejects because of the status;authorizedecides and audits (note 4), as M1-34 does for session and device flags. No cache of the result: a revocation and the end of a run must count on the next request in every process (M1-44, AZ-P9). -
Resolution in
runs(app/actingfor.go, pure rules indomain/actingfor.go): a.InvocationTokens.Verify(ctx, token, VerifyInvocation{Audience: agentID, IgnoreExpiry: true})(M2-06 note 5:alg,typ,kidof purposeinvocation, signature,iss,aud,iat). Any error givesinvalid. b. Claims withoutrun_id(trigger_kind = autocomplete, andcommand.invokedwhen the owner of the control command had no active run in the thread; 05 5, R-AZ-25) giveinvalid. Ruling for docs/DECISIONS.md: SR9 binds a run, and "run active" cannot hold without one. c.GetRunBinding(run_id): no row,agent_idnot the agent,triggered_bynotsub, orthread_idnot the claimthread_idgiveinvalid. d. Statequeued,runningorwaiting_input(the active states, 09 5.1, R-DM-14) givesbound;completed,failed,cancelledgiverun_inactive. Both returnUserID = runs.triggered_by,RunID,ThreadID = runs.thread_id(unchanged by a thread move) andJTI;invalidreturns zero IDs. e. Ruling for docs/DECISIONS.md: expiry and the currency of thejtiare not checked. 07 "SR9" names signature,audand an active run; a run outlivesagent.invocation_token_ttl; every token of a run names the same person (runs.triggered_by); and the token never adds a right (the intersection only narrows, the exceptions only keep the agent's own rights), so a leaked or older token gives its holder nothing the agent token alone would not give. f.GetRunBindingreadsrunswithout the visible-channel predicate: it loads one run by primary key for authentication, before anyauthorize, and nothing of it leaves the server (07 7.4 rule 1 exception "resolution forauthorize"). Name it with this reason in therunsREADME and, if present, intools/lint/sql_visibility.yaml. g. The read uses the pool, no unit of work and no lock; the request's own unit of work starts later. h. Ruling for docs/DECISIONS.md:identitydeclares the source port (the authenticator owns the actor, 02 4.3),runsimplements it inadapters/actingforwith onlykerneltypes in the signature, andinternal/wireholds the compile-time assertion; no new module edge in 02 4.4 ormodules.yaml(M2-06 note 1: the consumer outsiderunsgets a port with its own DTOs). -
Decision in
authz(07 4.2; pure indomain, facts inapp): a. Liveness and pending checks of step 1 stay first. b. Statusinvalid: every action, andScope, ends withforbidden, reasoninvocation_token_invalid,Detailinvalid, before the path is resolved, so an unknown and an invisible ID answer alike. c. Statusrun_inactive: every action except the unbound ones of note 5 ends the same way withDetailrun_inactive;Scopereturns that error. Ruling for docs/DECISIONS.md: a failed binding check is a failed token check (07 "SR9": "The token is checked on every request"), answered with the existing problem code and a newreason, so the adapter learns that the run ended. This includes the exceptions: after its run ended,finishRunwith the header answers403withrun_inactiveinstead of M2-09's idempotent answer; without the header M2-09's answers stay. d. Statusbound: resolve the path, thenActingForAppliesdecides whether the intersection of M1-28 applies: not in delivery mode (note 6), not for unbound actions, not for an own exception (note 5); otherwise yes. e. The triggering person's rights (core contract: 05 section 5 "Check without a request" with 05 2.3 points 1 and 6, R-AZ-29; the marker extension and the cache key are a ruling for docs/DECISIONS.md): computed like the re-check principal of M2-08, a principal of kinduserwith the person's ID and no session, device, token,view_asoracting_for; liveness from the principal status only (SR7:disabled,deletedandpendingpeople have no rights, so every bound action endsacting_for_insufficient); owner status from the current owner list without a session. Reason: the run exists becauseagent.invokeaccepted the person with their rights, and without owner status every run of an owner (AZ-M19, everypersonalandhostagent) could not even read its own context. The snapshot cache key keeps this variant apart from every token principal of the same person (M2-08 note 4). Extend the marker of M2-08 so it is allowed for snapshots of anActingForperson as well as for the actions it already admits (R-AZ-29). f. Reasons: a target that is visible with the agent's own rights but not with the intersection givesnot_foundwithacting_for_insufficient(M1-28 builds this); a requirement that holds with the agent's own rights but fails with the intersection givesforbiddenwithacting_for_insufficientinstead of the atom's reason (07 4.1: "not_foundorforbidden"). If the agent alone already fails, the agent's own reason stays. g. Mapping:Decision.Errof M2-09 mapsforbiddenwithinvocation_token_invalidto the problem codeinvocation_token_invalidwithreasonequal toDetail;forbiddenwithacting_for_insufficientmaps to403 forbiddenwithreason: acting_for_insufficient(M1-28 row "every other reason");not_foundnever carries a reason. h.Scope(07 7.4 rule 1): with statusboundand outside delivery mode,ChannelIDsandSpaceIDsare the intersection of the agent's and the person's lists,FullyVisibleSpaceIDsthe spaces fully visible to both,InstanceWidetrue only if both are; ascending order; the check log records(a, allow)as before. i. AZ-P8: the slow reference of M1-28 gains SR9 written independently from this note (mode table, exceptions, statuses, delivery mode, the person's rights of 4e), and the random worlds gain agents withActingForprincipals. -
Mode table (Ruling for docs/DECISIONS.md; 07 "SR9" names the exceptions partly in prose and limits SR9 to actions that read content or write into a channel, without a closed list):
ActingForOwnException:message.create("replies in the thread of the run"),run.update("patches to its own run"),run.finish(alsoawaitRunInput),interaction.respond,agent_session.report. "Own" means: the resolved thread of the resource equalsActingFor.ThreadID, and for a resource of kindrunalsor.IDequalsActingFor.RunID(sorun.finishon an earlier run of the same thread is bound). Then only the agent's rights apply; otherwise the action is bound.ActingForUnbound:run.start(alsoresumeRun) andrun.token.renew. They neither read content nor write into a channel; their own token in the body is checked by 09 4.5 and 4.5.1, and renewal step 4 repeatsagent.invokefor the person (M2-09). With a header they answer exactly as without one, also with statusrun_inactive(so 09 "409already_started" and D-W-15 stay true); statusinvalidstill denies (note 4b).ActingForBound: every other catalogue action. A new action is bound until its ticket classifies it (fail closed). Actions without a path in the channel tree (agent.heartbeat,agent.events.consume,agent.triggerers.read,self.read,instance.read) are bound too; the intersection changes nothing for them because their requirement needs no level on a path, and a test pins that. The golden file holds the two non-default sets;TestActingForModes_Goldencompares, andTestActingForExceptionsNamedInChapter07reads the "SR9" section ofdocs/spec/07-permissions.mdand asserts that every backticked action id of its exception sentence is inActingForOwnException.
-
Delivery mode (Ruling for docs/DECISIONS.md, 07 8.1 row "Agent consumers": "like people, according to their own grants"): in
authorize.Delivery(ctx)decisions andScopeignoreActingForcompletely. The REST poll of M2-07 admits events in delivery mode with the request principal, so a header onpollAgentEventsmust not narrow the consumer.Filter(ctx)(per-element checks of lists,listEvents) stays bound. -
Audit (07 4.2, R-AZ-04, SEC-AUD-5). Denials are recorded by the authz layer as for every request, aggregated per
audit.deny_aggregate_window. Ruling for docs/DECISIONS.md: a denial whose principal carriesActingForhas the detailsacting_for_user_idandrun_id(UUIDs; absent forinvalid) and, forinvocation_token_invalid,token_verdictwith theDetailcode (the key M2-09 uses). Allows write nothing new. Filters write nothing (AZ-P13). -
Gateway (Ruling for docs/DECISIONS.md). SR9 binds REST requests only. The gateway has no request header, and an agent's gateway ops are patches of the own run (
run.update, an exception), acknowledgements (ack) and autocomplete answers, none of which reads content in a run's name. Gateway connections therefore carry noActingFor. -
What SR9 does not cover (write it into the
authzREADME and the SR9 paragraph of 07): the server cannot tell a request inside a run that omits the header from one outside a run (AZ-M45); the SDKs and reference adapters close this by always sending it (M2-29, M2-30, M2-33, M2-34). Knowledge a runtime keeps across runs is outside the API (H1, T-AI-01). An owner who triggered with a personal API token (no owner rights at acceptance) is intersected as owner (the residual named in R-AZ-29 and M2-08 note 4). -
Idempotency and concurrency. Authentication reads the run before the idempotency stage (02 9.1 steps 7 and 10); a replay returns the stored answer of the same agent as M1-20 defines. A run that ends between authentication and the handler is decided with the status read at authentication; the next request sees the new state. No lock is taken.
-
Performance. Each request with the header costs one Ed25519 verification and one primary-key read; the intersection costs one more snapshot lookup in the cache. Add
BenchmarkAuthorizeCachedActingFornext toBenchmarkAuthorizeCached(reportsp99-ns, asserts nothing). -
Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 3b, 3e, 3h, 4c (with the new
reasonrun_inactive), 4e (only the marker extension and the cache key; the rule itself is R-AZ-29), 5, 6, 7, 8. Facts already in the spec (the intersection, the exception list,acting_for_insufficientfor both decisions,Scopewithacting_for) are not repeated there.
Tests (write them first)¶
Unit (internal/authz, fake sources and the matrix world of 07 10 and 11.1 from M1-28; Lotse additionally has read on space Engineering where a case says so):
TestActingForModes_GoldenandTestActingForExceptionsNamedInChapter07(note 5).TestAZM44_ActingForInsufficient: Lotse withActingForAlex (statusbound, run in Support/Allgemein), Lotse hasreadon Engineering:message.readon a message in Engineering/Projekt-A givesnot_foundwithacting_for_insufficient;Scope(message.read)contains no channel of Engineering.TestAZM45_WithoutRun: the same withoutActingForgivesallow.TestActingFor_ForbiddenReason: Alexreadand Lotsewriteon Engineering/Werkzeuge:message.createin a thread there givesforbiddenacting_for_insufficient; Alex and Lotse bothread:forbiddenlevel_insufficient.TestActingFor_Exceptions: for each ofmessage.create,run.update,run.finish,interaction.respond,agent_session.report: on the own thread (and own run) after Alex lost every grant,allowwith Lotse's rights; on a thread in a channel Alex cannot read,not_foundacting_for_insufficient;run.finishon an earlier run of the same thread is bound.TestActingFor_Unbound:run.startandrun.token.renewgive the same decision and reason with and withoutActingFor, for statusesboundandrun_inactive.TestActingFor_StatusInvalid: a bound, an exception, an unbound action andScopegiveforbiddeninvocation_token_invalidwithDetailinvalid; an unknown resource ID gives the same decision as a known one.TestActingFor_StatusRunInactive: bound and exception actions andScopegiveinvocation_token_invalidwithDetailrun_inactive; unbound actions as without.TestActingFor_DeliveryModeIgnores: inDelivery(ctx)decisions andScopeequal those withoutActingFor; inFilter(ctx)they are intersected.TestActingFor_OwnerTriggerer: the Owner triggers; the intersection equals Lotse's own rights; afterowner.removethe person's grants alone count; the cache entry of this snapshot differs from the Owner's personal-token principal.TestActingFor_TriggererInactive: Alexdisabledorpending: every bound actionnot_foundacting_for_insufficient.TestActingFor_FreshRights: a revocation of G-1 (new rights version) makes the next decision deny without any change ofActingFor.TestActingFor_PathlessActionsUnchanged: foragent.heartbeat,agent.events.consume,agent.triggerers.read,self.read,instance.readthe decision with and withoutActingForis equal.TestScope_ActingForIntersection: each ofChannelIDs,SpaceIDs,FullyVisibleSpaceIDs,InstanceWideintersected, ascending; a channel created in Engineering without a new rights version stays out.TestDecisionErr_ActingFor: problem code andreasonfor bothDetailvalues;acting_for_insufficientas403 forbiddenwithreason;not_foundwithout reason.TestAZP11_ActingForProperty(AZ-P11): random worlds from a seededmath/rand/v2source (seed printed on failure, overridable withBOTHY_TEST_SEED) with people, agents, groups, grants with expiries, operators, runs and statuses; for every content read (message.read,thread.read,channel.read,run.read,message.revisions.read) anallowfor an agent with aboundActingForimplies that the person may read the same path, and for every channel write (message.createoutside the own thread,message.update,message.delete,reaction.add,thread.create,thread.close) that the person has the required level and capabilities there;Scopeof the agent is a subset of the person's; decisions equal the extended AZ-P8 reference (note 4i).TestAZP8_CacheMatchesReferencestays green with the SR9 dimension.
Unit (internal/identity, fake source):
TestAuthenticator_InvocationHeader: no header gives zeroActingFor; an agent with one value calls the source with its ID and the token and copies the result; a person with a session, a personal token and an app token with the header giveinvalidwithout a source call; two values and an empty value giveinvalidwithout a call; the header withoutAuthorizationgives401 unauthenticated; a source error gives503 unavailable.
Unit (internal/runs):
TestActingForVerdict: no run, other agent,subnottriggered_by, otherthread_id, claims withoutrun_id:invalid;queued,running,waiting_input:boundwithUserID,RunID,ThreadID,JTI;completed,failed,cancelled:run_inactive.TestResolveActingFor:Verifyis called with the agent as audience andIgnoreExpiry; a bad signature, the token of another agent, an access token (at+jwt) and a terminal-type JWS giveinvalid; an expired token of an active run and an older (renewed) token of the same run givebound.
Integration (test/integration/actingfor; PostgreSQL 18 via pgtest, real modules through internal/wire, HTTP through the real chain with httpservertest of M1-18, people and agent tokens from the identity test helpers, fake clock; world as M2-09's: Robin is the Owner, space Support with channels Allgemein and Eskalation operated by Lotse (organization, chat) with G-5 (write, upload on Support), Alex with G-1 (trigger on Support), spaces Engineering (forum Projekt-A with a post of Robin, text channel Werkzeuge) and Marketing (Texter); Lotse gets read on space Engineering through createGrant by Robin; Alex posts in Support/Allgemein, the run is delivered through pollAgentEvents and started with startRun, so Lotse holds Alex's token T):
TestAZM44_EndToEnd:getMessageof the Projekt-A post withInvocation-Token: Tgives404with the same status, body and headers as a random UUIDv7; one aggregated deny entrymessage.readwithacting_for_insufficient,acting_for_user_idAlex andrun_id.TestAZM45_EndToEnd: the same request without the header gives200;explainChannelAccessof Engineering/Projekt-A by Robin lists Alex with levelnoneand avia_agentsource naming Lotse (hint H1).TestAZP11_WritesEndToEnd: Robin gives Lotsewriteon Engineering/Werkzeuge;createMessagethere with T gives404while Alex has no grant and403 forbiddenwithreason: acting_for_insufficientafter Robin gives Alexreadthere; without the header201. Robin lowers G-1 toread(revoke and new grant): Lotse's reply in the run's own thread with T gives201(exception), a message into another thread of Support/Allgemein with T gives403 forbiddenwithreason: acting_for_insufficient.TestSR9_EveryScopedList: a test-only decorator around the wiredkernel/authorize.Authorizerrecords which operation calledScope. Every enabled GET operation ofapi/operations.yamlthat an agent may call is requested as Lotse without and with T (path parameters from a fixture map: Engineering space, Projekt-A channel and thread, the post, Lotse's own IDs, and Robin's run in Engineering/Werkzeuge named at the end of this item). For every operation that calledScope: with T no response body (all pages) contains an ID of Engineering (space, channels, threads, messages, runs); without the header at least the operationslistSpaces,getSnapshot,listEvents,listMyMentionsandlistActiveRunsdo contain one (the test is not vacuous); path-addressed requests on Engineering answer with T like an unknown ID. The set of operations that calledScopeequalstestdata/sr9_scope_operations.golden; a new list operation fails the test until it is added. Robin triggers Runner in Engineering/Werkzeuge first, so an active run exists there, and mentions Lotse in Projekt-A, so a mention exists.TestSR9_UnreadCounters:getSnapshotof Lotse without the header lists threads of Engineering inunread_threads; with T it lists none of them, andspaces,channelsandactive_runscontain nothing of Engineering. If the merged snapshot computes no unread threads for agents (no read states of their own), assert that and the other members, and say so in the report; the counters of people are covered by M1-63.TestSR9_FinishOwnRunAfterRevocation: Robin revokes G-1 (the run getsrun.cancel_requestedwithaccess_revoked, M2-09);getMessageof a Support message outside the run's thread with T gives404;finishRun(cancelled, access_revoked)with T gives200(exception).TestSR9_ForeignRunIsBound: Robin sets Lotse as operator of Engineering/Werkzeuge (setChannelOperator) and triggers it there (run R2, started by Lotse with its own token of R2);finishRun(completed)of R2 with Alex's T in the header gives404like an unknown run; without the header200.TestSR9_RunInactive: afterfinishRun(completed)of Alex's run,getMessageof a Support message with T gives403 invocation_token_invalidwithreason: run_inactive;startRunof that run with T in the header and the body gives409 already_startedas without the header.TestSR9_InvalidHeader: a token with a changed signature byte, Texter's token, an access token of Alex, a token withtrigger_kind = autocompleteissued throughInvocationTokens.Issue, two header values and an empty value: each403 invocation_token_invalidwithreason: invalidongetMessage; Alex's own session with T gives the same; T withoutAuthorizationgives401 unauthenticated; the deny entries carrytoken_verdictinvalidand no token.TestSR9_ExpiredTokenStillBinds: after the clock passesexpof T plusauth.clock_leeway, T still binds (Projekt-A404, own thread200).TestSR9_RevocationImmediate: twoAuthorizerinstances with warm caches on the same database (REST mode); afterrevokeGrantof G-1 both deny Lotse's request with T at once.TestPollAgentEvents_HeaderDoesNotNarrowConsumer: amessage.createdin Projekt-A reaches Lotse throughpollAgentEventswith T in the header exactly as without it (07 8.1).TestStartRun_HeaderUnaffected:startRun,resumeRunandrenewInvocationTokenanswer with T in the header exactly as M2-09's tests expect without it.TestSR9_ExceptionRoutesEnabled: for each ofpatchRun,reportAgentSession,respondToInteractionthat is enabled on the base branch, the own-run case with T after Alex lost every grant succeeds and the foreign-run case gives404; forgetRunContext, if enabled, T after Alex lost every grant gives404. List the routes not yet enabled in the report.TestSR9_TokenNeverLogged: with the log capture ofhttpservertest, every request above: the token, its signature segment and its payload segment appear in no log record, span attribute, problem body oraudit_eventsrow.TestWire_ActingForSource: the wired authenticator uses therunssource; the server starts.
Contract: problems of the integration tests validate against Problem and carry the code and reason named above; no JSON null.
Benchmark: BenchmarkAuthorizeCachedActingFor reports p99-ns (note 11).
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/authz/... ./internal/identity/... ./internal/runs/... ./internal/kernel/... ./internal/platform/httpserver/... ./internal/wire/... ./test/integration/actingfor/...cd services/server && go test -race -run '^TestAZ' -v ./internal/authz/... ./test/integration/actingfor/...cd services/server && go test -run '^$' -bench 'BenchmarkAuthorizeCached' -benchtime 20000x ./internal/authz/appmake codegen-check,make arch,make api-lint,make api-checks,make api-tables,make register-check,make semgrepmake docsmake check
Acceptance criteria¶
- AZ-M44: Lotse in Alex's run reading Engineering/Projekt-A gets
404identical to an unknown ID, audited withacting_for_insufficient; green at decision level and end to end. - AZ-M45: without the header Lotse reads it (
200), and "Who has access" shows Alex withvia_agent(hint H1). - AZ-P11: no agent in a run reads or writes content the triggering person may not read or write (property test with the SR9 reference, write and read cases end to end).
- The exceptions of 07 "SR9" apply only on the own thread and run;
run.startandrun.token.renewanswer as without the header; a failed requirement caused only by the intersection gives403withreason: acting_for_insufficient. -
Scopegives an agent in a run only channels the person may read too, for every list operation that callsScope(golden list), including the unread counters ofgetSnapshot. - The token is checked on every request: an invalid token gives
403 invocation_token_invalidinvalid, a token of an ended runrun_inactive; an expired token of an active run still binds; a revocation counts on the next request in every process. - Consumer deliveries ignore the header (07 8.1).
-
Invocation-Tokenis in openapi.yaml, 06, 07, 02, 09 and 11; the problem table is regenerated; no token in logs, traces, audit or problems. -
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
- READMEs of
authz,identity,runsandplatform/httpserverupdated (the header, the authentication steps, the source port, the mode table, the delivery-mode rule, the query exception of note 3f, the residuals of note 9); package doc ofkernel/actor. - Rulings of note 12 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 02, 06, 07, 09, 11,openapi.yamland the threat model of the Scope made in the same change. services/server/CHANGELOG.mdentry under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN) with attention to the M2 red team item "confused deputy through the server API": tokens of other agents, ended runs, autocomplete tokens, owner triggers, the exceptions on foreign threads, lists and counters; onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check, thep99-nsof both benchmarks with the machine description. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the exception routes not yet enabled on the base branch, and the SDK side of the header).
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least:
file.uploadfor attachments of a reply in the own thread, and the binding of autocomplete answers without a run (note 3b), each with a concrete proposal for the owner.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.