M2-06: Invocation token with EdDSA and JWKS¶
Status: ready | Depends on: M2-05 | Milestone: M2
Goal¶
After this ticket the module runs issues and verifies invocation tokens (invariant I3): compact JWS with the header alg = EdDSA, kid and typ = agent-invocation+jwt and exactly the claims of 05 section 5, signed with the active server key of purpose invocation (M1-32), so that every adapter can check them through the public JWKS (getJwks) but none can forge one (R-SY-08, ADR 0023, L24). Every call issues a new token with a new jti; nothing is stored. A scheduled key rotation fails no token at any moment, and an emergency rotation withdraws the old key on the server at once. A reference verifier, written independently with the Go standard library, performs the adapter check of 09 4.5 "Check in the adapter" and rejects every token with another alg or typ. Delivery (M2-08) puts the tokens into envelopes and stores their jti; run start and renewal (M2-09) use the server-side verifier built here. Without this ticket no adapter could tell a decision of the server from a forged event, and I3 (the adapter compares its actual billing mode with the claim) would have nothing to compare against.
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 items 1 and 10, Test strategy, Red team first paragraph: "forging or reusing the invocation token", Risks), block "agents and extensions", rows M2-05, M2-06, M2-07, M2-08, M2-09, M2-14, M2-15, M2-16, M2-17, M2-29, M2-31; section 18.1 row 5 and section 18.2 row "I3 signed invocation token, check in the adapter"; V2-02 (addsdev_environment_idlater).docs/spec/09-agents.md: 4.5 "Invocation token" completely (Header, Claims, Check in the adapter points 1 to 10, thereasontable, "Keys and rotation"); 4.5.1 "Fresh token for a waiting run" (step 1 checks the signature without expiry); 4.3 "Connection and cursor" (acceptance andackafter the check, newest token replaces the stored one); 4.4 "Events to the adapter" (which events carry a token); 10.4 "Execution"; 10.5 "Autocomplete"; 16.1 "Test doubles"; 16.2 "Required scenarios" (AG-08, AG-22, AG-28); 21 "Configuration values of this chapter".docs/spec/05-actions.md: 5 "Agent invocationagent.invoke" (header and the normative claim table); 4.10 "Agents, commands, runs, interactions" (rowsrun.start,run.token.renew).docs/spec/04-sync-protocol.md: 2.2 "Envelope" (invocation_tokenis a delivery field), 15.2, 15.3 "Invocation and run start", 15.6 "Webhook as transport", 15.7 "Autocomplete", 17 "Decisions of this chapter" (R-SY-07, R-SY-08, R-SY-10, R-SY-17, R-SY-19).docs/spec/08-identity.md: 7.1 "Overview" (row Invocation token), 7.2 "Access token" (typ = at+jwt,auth.clock_leewayforexp,nbf,iat).docs/spec/03-data-model.md(core contract, read only):server_signing_keys;runs(invocation_jti,started_jti,renewed_from_jti,trigger_kind,triggering_command_id).docs/spec/10-backend-policy.md: 3 "Invariants" (I3), 7 "Adapter check (I3)" (an unknown actual mode never equals the claim).docs/spec/07-permissions.md: "SR9 Acting-for binding (confused deputy)" (the token in later API requests, M2-14), the rowinvocation_token_invalidof the problem code table, 11.2 "Required tests" (AZ-P6).docs/spec/06-api.md: 5 "Errors" (rowinvocation_token_invalid).docs/spec/19-security.md: 9 "Logs without secrets and without content" (SEC-LOG-2: invocation tokens never appear), 10 "Secrets" (signing key row, SEC-SEC-3).docs/spec/02-architecture.md: 4.4 "Allowed dependencies" (rowruns, reason "sign the token"), 4.5 "Cross-cutting ports" (kernel/jose,kernel/ids,kernel/clock,kernel/errs), 5 "Layers within a module", 6 "Code layout" (test helpers ininternal/<module>/adapters/<module>test, cross-module tests intest/).docs/spec/11-extensions.md: 11.2 "Hand-written layer" (rowinvocation: the SDK verifier that M2-29 and M2-30 build after this reference).docs/spec/23-quality.md: "Documentation in code".docs/spec/24-terminal.md: 5.3 "Terminal session token" (only its headertyp = terminal-session+jwt, which an invocation verifier must reject).docs/api/openapi.yaml: operationsgetJwks(schemaJwks, media typeapplication/jwk-set+json),getDiscovery(instance_id,jwks_uri),startRun,resumeRun,renewInvocationToken; schemasEventEnvelope(invocation_token),AgentInvocationPayload,AgentFollowupPayload,InteractionCreatedPayload,CommandInvokedPayload,CommandAutocompleteRequestedPayload,RunStart,InvocationTokenRenew,InvocationTokenGrant,Level,BillingMode,ToolClass,SigningKeyPurpose; problem codeinvocation_token_invalidinx-problem-codesofProblem.- ADRs: 0023 (signed invocation token, its "Confirmation"), 0066 (signing keys), 0067 (JOSE profile with the standard library), 0052 (one envelope; tokens are never stored), 0053 (no JSON
null), 0026 (SDKs), 0008 (module rules). docs/DECISIONS.md: D-L24, D-I3, D-W-15, D-W-44, D-W-58, D-W-100, section 10 "Rulings of the build milestones" (row formatD-M2-06-<n>).docs/security/threat-model.md: TB4, the row "Forged invocation to the adapter", T-TK-04, T-TK-05.docs/RISKS.md: RK-09.docs/operations/configuration.md:agent.invocation_token_ttl,auth.clock_leeway,agent.jwks_max_age,signing_key.invocation_rotation_interval,signing_key.cache_ttl,signing_key.rotation_check_interval,run.queued_max_age(server);adapter.invocation_token_leeway,adapter.jwks_refresh_min_interval(adapter).docs/observability/signals.md: it names no metric for issuing or verifying tokens; the only invocation metric,bothy.agent.invocation.denied, belongs to M2-08.- M1 briefs and merged code this ticket builds on: M1-32 (
instance.KeyswithSigner,VerificationKey,PublicKey,JWKS,Rotate;instance.Signer;instance.PublicKey; jobinstance.key_rotation; handler ofgetJwks), M1-33 (internal/kernel/jose:ParseCompactJWS,JWS.Verify,SignCompact,jose.Signer; test helperidentitytest.SignJWS), M1-34 (access tokens withtyp = at+jwt, the adapter frominstance.Signertojose.Signer), M1-15 (instance.State.Load,Record.InstanceID), M1-12 (pgtest), M1-18 (httpservertest), M1-22 (egress.NewLoopback, Semgrep rulebothy-go-egress-only), M1-07 and M1-08 (configuration schema and reference), M1-09 (redacting secret type), M1-51 (agent principals). Already on the base branch:internal/kernel/ids(ids.ID,ids.NewGenerator),internal/kernel/clock(clock.Clock,clock.NewFake),internal/kernel/errs(errs.CodeInvocationTokenInvalid,WithReason),internal/runs/README.md; the module skeleton and runs of M2-05. - Implied dependencies: the header names only M2-05. M1-32, M1-33, M1-34, M1-15, M1-12, M1-18, M1-22 and M1-51 are in its closure (M2-05, M2-04, M1-56 and so on down to M1-35, M1-34 and M1-32) and therefore merged before this brief starts.
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, permission 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:
- Header exactly
alg = EdDSA,kid(key ID of the signing key) andtyp = agent-invocation+jwt; explicit typing keeps every other JWS of the server (access tokensat+jwt, terminal session tokensterminal-session+jwt) from passing as an invocation token (05 section 5; 09 4.5 "Header"; RFC 8725 sections 3.1 and 3.11; 08 7.2; 24 5.3 "Terminal session token"). - Claims exactly as the normative list of 05 section 5, with the origins of 09 4.5:
iss=instance.instance_id;aud= principal ID of the agent;sub= principal ID of the triggering human;jti= new UUIDv7;iat,exp=iat+agent.invocation_token_ttl;trigger_kind(message,followup,interaction,command,autocomplete);run_id, absent fortrigger_kind = autocompleteand forcommand.invokedwhen the owner of the control command has no active run in the thread (05 section 5, R-AZ-25; this issuer refuses that second case until M2-16 adds it, Design note 2); exactly one triggering objectmessage_id,interaction_idorcommand_id;thread_id,channel_id,space_id;level;billing_mode;tool_class;dev_environment_idonly in the dev environment milestone (V2-02, never set here);permission_version. A schema test holds the claims equal to 05 section 5 (ADR 0023 "Confirmation"). - Signed with the
activekey of purposeinvocation(server_signing_keys, EdDSA only by the check of 03) throughinstance.Keys(M1-32, ADR 0066); the JWS is assembled throughinternal/kernel/jose(ADR 0067). A new token on every call (R-SY-07); the token itself is never stored, cached, logged or put into the event log; only itsjtigoes back to the caller forruns.invocation_jti(R-SY-17, ADR 0052, 04 15.3 step 2). - Rotatable: during a scheduled rotation (
signing_key.invocation_rotation_interval; anextkey published at leastagent.jwks_max_agebefore it becomesactive; the old keyretiringuntilagent.invocation_token_ttlplusauth.clock_leewayhave passed) no token fails at an adapter or at the server inside its validity; after an emergency rotation (mode = emergency) the old key verifies nothing on the server (09 4.5 "Keys and rotation", 19 section 10, AG-22). - The adapter check of 09 4.5 "Check in the adapter" exists as a reference verifier; a token with another
algor anothertypis rejected by it (PLAN row M2-06; 09 4.5 points 1 and 2). - Server-side verification for
startRun,resumeRunandrenewInvocationToken(used by M2-09):alg,typ,kidof purposeinvocation, signature,iss,aud, run; expiry optional (09 4.5.1 step 1). Every failure is403 invocation_token_invalidwithreasoninvalid, orexpiredwhen onlyexphas passed (06 5 "Errors"; 07 problem code table; 09 4.5reasontable). - A token never appears in logs, traces, metrics, audit details, events or error responses (SEC-LOG-2, SEC-SEC-3).
Scope¶
All paths under services/server/ unless noted.
Create:
internal/runs/domain/invocation.go: the pure token rules (Design notes 1 to 3 and 5): typesTriggerKind,InvocationClaims,InvocationGrant,InvocationExpectation{Issuer, Audience, RunID ids.ID; IgnoreExpiry bool}; constants for the header values;NewInvocationClaims(g InvocationGrant, jti ids.ID, now time.Time, ttl time.Duration) (InvocationClaims, error);(InvocationClaims) Payload() ([]byte, error);ParseInvocationToken(token string) (ParsedInvocationToken, error);CheckInvocationClaims(c InvocationClaims, e InvocationExpectation, now time.Time, leeway time.Duration) error. Imports onlykernel-corepackages (02 section 5).internal/runs/app/invocation.go: the serviceInvocationTokenswithIssueandVerify(Design notes 4 and 5), its config structInvocationTokenConfig{TTL, ClockLeeway time.Duration}and the value typeInvocationToken(Design note 8).internal/runs/adapters/runstest/refverifier.go(create the packagerunstestif M2-05 did not): the reference verifierReferenceVerifier(Design note 6) with its own unit tests inrefverifier_test.go.test/integration/invocationtoken/(componenttestsupport): the cross-module integration tests of this ticket (realinstance.Keys, the realgetJwkshandler, the reference verifier, two server processes).- A binary exclusion test in
internal/runs/adapters/runstestthat runsgo list -deps ./cmd/bothyand fails ifrunstestappears (the pattern of M1-33 foridentitytest).
Change:
internal/wire: constructapp.InvocationTokensonce per process where therunsservices of M2-05 are built, withinstance.Keys,instance.State, the process clock, the ID generator andInvocationTokenConfigfromagent.invocation_token_ttlandauth.clock_leeway(both keys are already in the schema; add the wiring only if missing). No HTTP route, no subscription, no job.internal/runs/README.md(English, like the existing file): section on invocation tokens (claims per trigger kind, issuing, verification order, error reasons, what is never stored or logged, the reference verifier and its use by later tickets and SDKs).docs/adr/0023-signed-invocation-token-and-check-in-the-adapter.md: name this ticket's tests in "Confirmation" (schema test, reference verifier, AG-22 token level); thenmake adr-index.docs/security/threat-model.md: add the reference verifier and server verifier tests of this ticket to the test column of T-TK-05 next to AZ-P6.docs/DECISIONS.mdsection 10: one row per ruling of Design note 11 (D-M2-06-1and following).services/server/CHANGELOG.mdunder "Unreleased".
This ticket adds no migration, enables no operation in api/operations.yaml, adds no configuration key, writes no event, no audit entry and no job, and records no metric: issuing a token is part of a delivery whose audit and metrics belong to M2-08, and the signal catalogue names no token metric.
Out of scope¶
- Fresh token per delivery in the envelope (
invocation_token), storing thejtiinruns.invocation_jti,billing_mode_expected,agent.invokewith I2, SR2 and SR3 before delivery, the eventsagent.invocationandagent.followup: M2-08. startRun,resumeRun,renewInvocationToken, thesupersededreason (currentjti, consumed token,runs.renewed_from_jti),409 already_started,billing_mode_mismatchandtool_class_mismatchat the server, AG-08 (run level), AG-28, AG-29, AG-31, AG-32: M2-09.- The token in API requests of an agent during a run (
acting_for, SR9) and any port that exposes verification to other modules: M2-14. - Tokens for
interaction.created,command.invokedandcommand.autocomplete_requesteddeliveries: M2-15, M2-16 and M2-17 (they callIssue). - The SDK verifiers (
invocationmodule of 11 11.2) and AZ-P6: M2-29 and M2-30; reference adapters: M2-33 and M2-34. - AG-22 at run level (scheduled and emergency half with real deliveries,
agent.ack_timeoutandrun.start): M2-31, which runs AG-01 to AG-30 (M2 exit criterion 1). - Signing keys, their states, the JWKS operation, the rotation job and
rotateSigningKey: M1-32 (merged). Terminal session tokens (purposeterminal): M2-44.dev_environment_id: V2-02. - Delivery to agents over webhooks (04 15.6, fresh token per delivery): not owned by this ticket, and the plan names no owner yet; list it as a follow-up in the report if it is still open.
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-06-invocation-token; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(runs): ...) 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(the key store directory of the tests with mode 0700), removed at the end. - Never read or print secrets; test signing keys are generated by the tests through M1-32 and never leave the temporary directory. No network calls except package downloads for pinned tools and Go modules. No real agent runtime and no model call: nothing in this ticket starts one.
- No new third-party Go module: tokens go through
internal/kernel/jose(ADR 0067), the reference verifier uses the standard library only. - The reference verifier builds no HTTP client, transport or dialer (Semgrep rule
bothy-go-egress-onlycoversrunstest, which is not a_test.gofile); callers pass the client. - 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>.
Design notes¶
-
Placement and layers (02 sections 4.4 and 5). Domain
internal/runs/domain/invocation.goholds the claim rules, encoding, parsing and claim checks and imports onlykernel-core(kernel/jose,kernel/ids,kernel/errs). The app serviceinternal/runs/app/invocation.gouses the port ofinstance(allowed edgeruns -> instance, reason "sign the token") and no adapter. The level, billing mode and tool class are string types local to the runs domain with exactly the values of the openapi enumsLevel,BillingModeandToolClass; the domain imports noagentstype. Ruling for docs/DECISIONS.md: the service stays inside the module (no type in the root packageinternal/runs) because every consumer of this milestone up to M2-17 is insideruns; a ticket with a consumer in another module (M2-14) adds the port with its own DTOs (02 section 5 rule 1). -
Claims per trigger kind. The issuer accepts an
InvocationGrant{AgentID, TriggeredBy ids.ID; TriggerKind; RunID, MessageID, InteractionID, CommandID ids.ID; ThreadID, ChannelID, SpaceID ids.ID; Level, BillingMode, ToolClass string; PermissionVersion int64}and builds the claims after this table (05 section 5:run_idabsent fortrigger_kind = autocompleteand forcommand.invokedwithout an active run of the owner, R-AZ-25; "message_idorinteraction_idorcommand_id"; 09 4.4; 03runs.triggering_command_idrefers tocommands(id)):trigger_kindrun_idtriggering object (exactly one) source of the object messagerequired message_idtriggering message followuprequired message_idthe follow-up message ( AgentFollowupPayload.message_id)interactionrequired interaction_idthe interaction commandrequired command_idthe command ( commands.id)autocompleteabsent command_idthe command of the request ( CommandAutocompleteRequestedPayload.command_id)iss,aud,sub,jti,iat,exp,trigger_kind,thread_id,channel_id,space_id,level,billing_mode,tool_classandpermission_versionare always present;dev_environment_idis never written here (V2-02). A grant that breaks the table (missing or extra run or object, unknown enum value, zero ID, negativepermission_version) is a programming error of the caller:Issuereturns an internal error and signs nothing (fail closed). Ruling for docs/DECISIONS.md: the object column forfollowupandautocomplete. A control command (09 10.4,commands.control = true:command.invokedwith a token, "No run") gets a token withoutrun_idwhen its owner has no active run in the thread (05 section 5, R-AZ-25); M2-16 adds that grant (fieldControl), and until then this issuer refuses acommandgrant withoutRunID(fail closed), so thecommandrow above stays as it is. An autocomplete request outside a thread never needs a token withoutthread_id(05 section 5 rowthread_id, R-AZ-26, D-W-104): in a text channel the main thread is the place, and in a forum channel without a thread the server asks no agent and issues no token. So the issuer keeps refusing a zerothread_idfor every trigger kind. -
Encoding. Ruling for docs/DECISIONS.md:
- Header JSON exactly
{"alg":"EdDSA","kid":<KeyID() of the signer>,"typ":"agent-invocation+jwt"}, no other member. If the signer reports another algorithm thanEdDSA,Issuefails with an internal error (03 allows onlyEdDSAforinvocation). - Payload: one JSON object, members in the order of the table of 05 section 5; IDs as canonical lower-case UUID strings;
iatandexpas NumericDate integers in seconds (RFC 7519 section 2),iat= issuing time truncated to whole seconds,exp=iat+agent.invocation_token_ttltruncated to whole seconds;auda single string, never an array;permission_versiona JSON integer; absent claims are omitted (omitzero), nevernull(ADR 0053). - The compact form comes from
jose.SignCompactwith an adapter frominstance.Signer(context-bound) tojose.Signer; reuse the adapter M1-34 built for access tokens if it is exported, otherwise add a small unexported one in the app package.
- Header JSON exactly
-
Issue(ctx, g InvocationGrant) (IssuedInvocationToken, error)withIssuedInvocationToken{Token InvocationToken; JTI ids.ID; KeyID string; IssuedAt, ExpiresAt time.Time}: a. Build and validate the claims (note 2) withjtifrom the ID generator (UUIDv7, R-DM-01) andnowfrom the clock. b.issisRecord.InstanceIDfrominstance.State.Load; load it once per process and keep it (M1-15:instance_idnever changes); a failed load is returned and retried on the next call. c.Keys.Signer(ctx, <purpose invocation>)returns theactivekey, read fresh on each call (M1-32), so a promotion in another process takes effect on the next token. d. Sign and return.ExpiresAtfeedsInvocationTokenGrant.expires_atin M2-09.Issuestarts no transaction and takes no lock; it may run inside the caller's unit of work (M2-08). Its only database access is the signer's read of the active key row (and the firstLoad). Errors of the key store pass through with the kind M1-32 gives them (503 unavailablewhen the key store is unusable). -
Verify(ctx, token string, v VerifyInvocation) (domain.InvocationClaims, error)withVerifyInvocation{Audience ids.ID; RunID ids.ID; IgnoreExpiry bool}, in this order; every failure in steps a to f givesreasoninvalid: a. Parse throughParseInvocationToken:jose.ParseCompactJWS(token, []string{"EdDSA"}, "agent-invocation+jwt")plus a byte-exact comparison oftyp(09 4.5 point 2 says "exactly"; no case folding, noapplication/prefix) and a non-emptykid. The profile of ADR 0067 already rejectsnone,HS*,crit,jku,x5u,x5candzip; a headerjwkis never used. b. Key:Keys.VerificationKey(ctx, <purpose invocation>, kid). Not found covers an unknownkid, aretiredkey and a key of another purpose, so a JWS signed with anaccess_tokenorterminalkey never verifies here even if its header claimed the invocation type (if the mergedVerificationKeydoes not filter by purpose, comparePublicKey.PurposeinVerify).nextkeys are accepted because the cached state of one process may lag a promotion in another by up tosigning_key.cache_ttl; nonextkey ever signs. c. Signature throughkernel/josewith that key. d. Payload: decode intoInvocationClaims; members required by note 2 present and of the right JSON type; enums known. Ruling for docs/DECISIONS.md: unknown members are ignored (RFC 7519 section 4), so a token of a newer server version stays verifiable during an upgrade; the issuer still writes exactly the set of 05. e.issequals the owninstance_id;audequalsv.Audience; ifv.RunIDis set,run_idequals it (09 4.5reasontable: "run wrong"). f.iatnot later than now plusauth.clock_leeway;expafteriat. g. Unlessv.IgnoreExpiry: now at or afterexpplusauth.clock_leewaygivesreasonexpired(RFC 7519 section 4.1.4: the current time must be before the expiration time). Ruling for docs/DECISIONS.md: the server appliesauth.clock_leewayto its own invocation tokens as 08 defines it forexpandiat; the retiring window of 09 4.5 "Keys and rotation" point 2 already contains this margin, so a token accepted here always has a published key. Soexpiredcomes only when everything else holds (09 4.5: "exppassed, otherwise valid and current"). Whether thejtiis current or consumed (superseded) is M2-09's step afterVerify.IgnoreExpiryservesrenewInvocationToken(09 4.5.1 step 1) and later SR9 (07: "signature,aud, run active"). Errors:errs.New(errs.CodeInvocationTokenInvalid, <detail>).WithReason("invalid" | "expired"), kind forbidden (403). The detail names the failed step and at most thekid, never the token or a segment of it. -
Reference verifier (PLAN "reference verifier"). Ruling for docs/DECISIONS.md: it is test-support code in
internal/runs/adapters/runstestthat implements the adapter check of 09 4.5 independently of the server path, with the Go standard library only (encoding/base64withRawURLEncodingin strict mode,encoding/json,crypto/ed25519), not withinternal/kernel/jose, so that a defect shared by issuer and server verifier cannot hide (the same idea as G10's "independent verification" for webhooks, 06 15). ADR 0067 governs the JWS paths of the server; this code stands in for the external adapter and never enters the binary.- API:
NewReferenceVerifier(ReferenceOptions{JWKSURL string; Client *http.Client; Clock clock.Clock; Issuer, AgentID ids.ID; Leeway time.Duration /* adapter.invocation_token_leeway */; RefreshMinInterval time.Duration /* adapter.jwks_refresh_min_interval */}) (*ReferenceVerifier, error);Check(ctx, token string, e ReferenceExpectation) (ReferenceClaims, error)withReferenceExpectation{RunID, ThreadID, MessageID, InteractionID, CommandID ids.ID; ActualBillingMode, ActualToolClass string}(zero values are not compared); countersFetches()andForcedReloads()for the tests. Every option is required; the verifier has no default value for a configuration key. - JWKS: fetched with
GETfromJWKSURLthrough the given client; cached for themax-ageof the response'sCache-Control(absent: refetch on the next use); on akidmissing from the cache it reloads once, and at most once perRefreshMinInterval(09 4.5 point 3). Only keys withkty = OKP,crv = Ed25519and a 32-bytexare usable;alganduse, when present, must beEdDSAandsig. - Checks in 09 order, with this precedence: points 1 and 2 (
algexactlyEdDSA,typexactlyagent-invocation+jwt, nocrit), 3 (kidknown, after at most one reload, elseErrRefUnknownKey: the adapter neither accepts nor acknowledges), 4 (signature,iss,aud), 6 (run_id,thread_idand the triggering object equal the expectation), each failureErrRefInvalid; then 8 (billing_modeequals the actual mode; an actual modeunknownnever matches, 10 section 7) asErrRefBillingModeMismatchand 10 (actual tool class not abovetool_classin the orderchat<scoped<host) asErrRefToolClassMismatch; then 5 (expired when now is at or afterexpplusLeeway, aloneErrRefExpired;iatlater than now plusLeewayisErrRefInvalid); then 7 (jtiseen before and not yet expired:ErrRefReplay, kept untilexpplusLeeway). Point 9 (allow list of triggerers viaagent.triggerers.read, 17.7) is not part of it: it belongs to adapters that keep such a list and to the SDK (M2-29). - It is the oracle for later tickets: M2-08 and M2-09 check delivered tokens with it in their Go tests; the SDK verifiers of M2-29 and M2-30 must agree with it on the cases of this ticket's tests.
- API:
-
Rotation (AG-22). This ticket relies on M1-32 for the key states, the publication of a
nextkey at leastagent.jwks_max_agebefore use, theretiringwindow (agent.invocation_token_ttlplusauth.clock_leeway), the per-process cache withsigning_key.cache_ttland the emergency transition; it adds nothing to them and proves at token level that they suffice: an adapter whose JWKS cache honoursCache-Control: max-age(=agent.jwks_max_age) never meets an unknownkidduring a scheduled rotation, and a second server process verifies every new token at once because it already knows the key asnext. Ruling for docs/DECISIONS.md: this ticket proves AG-22 at token level (scheduled: no token is rejected inside its validity by any adapter or server process; emergency: the old key is rejected at once on the server and a new token verifies after at most one reload); the run-level scenario of both halves is M2-31's. Two gaps of the spec that can still fail runs are recorded in the report and not built around: the renewal of an expired token after its key wentretired(09 4.5.1 step 1 has no time bound, the retiring window ends afteragent.invocation_token_ttlplusauth.clock_leeway,run.queued_max_ageis longer), and a token of the old key that an adapter with a still-valid JWKS cache accepted and acknowledged before an emergency rotation. -
Secret handling (SEC-LOG-2, SEC-SEC-3).
InvocationTokenwraps the compact string;String,GoString,Format,LogValue,MarshalTextandMarshalJSONrender[redacted](the behaviour of the redacting secret type of M1-09); onlyReveal() stringreturns the value, and M2-08 calls it when it fills the envelope. Logs and errors of this ticket may namekidandjti, never the token, its signature or its payload.Verifyreceives the token as a plain string from a request body (M2-09) and copies it nowhere. -
Configuration: no new key.
agent.invocation_token_ttlandauth.clock_leewaycome throughInvocationTokenConfigfrominternal/wire. The adapter keysadapter.invocation_token_leewayandadapter.jwks_refresh_min_intervalare options of the reference verifier, set by the tests; the server never reads them. 09 4.5 "Keys and rotation" point 2 requiresadapter.invocation_token_leewaynot aboveauth.clock_leeway; the AG-22 test sets it equal toauth.clock_leewayto test the edge. -
Concurrency and cost.
IssueandVerifyare safe for concurrent use; the cachedinstance_idis guarded by async.Once-like pattern that allows a retry after an error. Issuing costs one signature and one indexed read; this matters forperf.invocation_delivery_p99, which M2-36 measures. -
Rulings for
docs/DECISIONS.md(one row each with reason): note 1 (service inside the module, no root port yet); note 2 (triggering object forfollowupandautocomplete; incomplete grants fail closed); note 3 (header with exactly three members, NumericDate seconds with truncation,audas a string, absent claims omitted); note 5 (purpose-bound key lookup withnextaccepted, unknown claims ignored, byte-exacttyp,auth.clock_leewayon the server, everyinvalidcause beforeexpired); note 6 (independent reference verifier with the standard library, precedence of its results); note 7 (AG-22 split between this ticket and M2-31); note 8 (redacting token type).
Tests (write them first)¶
Unit (internal/runs/domain):
TestInvocationClaims_MatchSpec(the schema test of ADR 0023): readsdocs/spec/05-actions.mdsection 5 anddocs/spec/09-agents.md4.5 (locate the repository root by walking up to the directory that containsdocs/spec), extracts the backticked names of the first column of both claim tables and asserts that both sets are equal and equal the JSON member names ofInvocationClaims; asserts that the header constants equalEdDSAandagent-invocation+jwtas both chapters state.TestInvocationClaims_ShapePerTriggerKind: for each of the five kinds,Payload()decoded withUseNumberhas exactly the members of note 2; every ID is a canonical lower-case UUID string;iatandexpare integers withexp - iatequal to the TTL in seconds;level,billing_modeandtool_classtake only the enum values read fromdocs/api/openapi.yaml(Level,BillingMode,ToolClass); no member isnull;dev_environment_idis absent.TestNewInvocationClaims_PresenceRules: table with a missingrun_idfor each kind exceptautocomplete, arun_idwithautocomplete, no triggering object, two triggering objects, the wrong object for the kind, zerothread_id,channel_idorspace_id, an unknown enum value, a negativepermission_version: each returns an error.TestParseInvocationToken_HeaderRules:algother thanEdDSA(none,HS256,ES256,RS256,Ed25519,eddsa, missing) andtypother thanagent-invocation+jwt(missing,JWT,at+jwt,terminal-session+jwt,application/agent-invocation+jwt,Agent-Invocation+JWT, with a trailing space), emptykid,critpresent: each rejected.TestCheckInvocationClaims_Order: a token that is expired and has a foreignaudgivesinvalid, notexpired;iatexactly at now plus leeway passes and one second later fails;expplus leeway one second after now passes and exactly now givesexpired, so a key that wentretiredafter its window never had a valid token left.FuzzParseInvocationToken: seed corpus of a valid token and every case above; the seeds run in everygo test; the parser never panics and never returns a token whose header breaks note 5a.
Unit (internal/runs/app, fake instance.Keys and instance.State, fake clock):
TestIssue_SignsWithInvocationKey: the fake records that only the purposeinvocationis requested; the headerkidequals the signer'sKeyID();issequals the instance ID; two calls give two different UUIDv7jti;ExpiresAt - IssuedAtequalsagent.invocation_token_ttltruncated to seconds;instance.State.Loadruns once for many calls.TestIssue_RefusesIncompleteGrant: a grant ofTestNewInvocationClaims_PresenceRulesgives an error and the fake signer was never called.TestIssue_SignerAlgMustBeEdDSA: a signer reportingES256gives an internal error.TestVerify_Reasons: bad signature, unknownkid, foreignaud, foreigniss, wrongrun_idgiveerrs.CodeInvocationTokenInvalidwithreasoninvalidand kind forbidden; an expired, otherwise valid token givesreasonexpired; withIgnoreExpirythe same token returns its claims; withIgnoreExpirya bad signature is stillinvalid.TestVerify_UnknownClaimIgnored: a token with an extra member verifies and returns the known claims.TestInvocationToken_Redacted:fmtwith%v,%+v,%#v,slogwith a JSON handler andjson.Marshalof a struct holding the token show[redacted];Reveal()returns the compact string; errors ofIssueandVerifyand every captured log record contain neither the token nor its signature segment.
Unit (internal/runs/adapters/runstest, a JWKS served by httptest on 127.0.0.1 port 0, keys generated in the test):
TestReferenceVerifier_AcceptsValidToken: a token built by the server path of this ticket passesCheckwith all expectations set.TestReferenceVerifier_RejectsAlgAndTyp(plan acceptance test): everyalgandtypcase ofTestParseInvocationToken_HeaderRules, each with a valid Ed25519 signature over its own header where the algorithm allows it, plusHS256keyed with the bytes of the published public key (algorithm confusion) andnonewith an empty signature: each givesErrRefInvalid, neverErrRefExpiredor success, and leaves thejtistore unchanged.TestReferenceVerifier_Points: foreigniss, foreignaud, changed payload byte,run_id,thread_idor triggering object different from the expectation giveErrRefInvalid; actual billing mode different orunknowngivesErrRefBillingModeMismatch; actual tool classhostwith claimscopedgivesErrRefToolClassMismatch, actualchatwith claimscopedpasses; an expired token with a billing mismatch gives the mismatch, notErrRefExpired; the same valid token twice givesErrRefReplaythe second time.TestReferenceVerifier_JWKSCache: no fetch beforemax-agehas passed; one fetch after; an unknownkidcauses exactly one forced reload andErrRefUnknownKeyif the key is still missing; a second unknownkidwithinRefreshMinIntervalcauses no fetch.TestRunstest_NotInBinary:go list -deps ./cmd/bothydoes not listrunstest.
Integration (test/integration/invocationtoken; PostgreSQL 18 through pgtest; real instance.Keys of M1-32 with a key store in a mktemp -d directory of mode 0700, built as M1-32's tests build it; controllable clock; the real getJwks handler of M1-32 served on 127.0.0.1 port 0 through httpservertest of M1-18 or the way M1-32's TestGetJwks_HTTP serves it; the reference verifier gets the loopback client of egress.NewLoopback (M1-22) or the test server's client; an agent principal of M1-51, the people Alex and Sam, a queued run of M2-05 in a thread of the space Support):
TestInvocationToken_RoundTrip:IssuethenVerifyin the same process andCheckby the reference verifier succeed; the headerkidis thekidof theactiverow of purposeinvocationinserver_signing_keys.TestInvocationToken_ForeignPurposeKey: a JWS with the invocation header and valid claims signed throughinstance.Keys.Signerwith the purposeaccess_token(published in the same JWKS) isinvalidat the server; a JWS with the access-token header (typ = at+jwt) signed with theaccess_tokenkey (built withidentitytest.SignJWSor the issuer of M1-34) is rejected byVerifyand by the reference verifier.- AG-22 (scheduled half, 09 16.2: "no failed runs when scheduled"),
TestAG22_ScheduledRotationFailsNoToken: two server processes A and B (owninstance.KeysandInvocationTokenseach, same database; A runs the function of the jobinstance.key_rotationat every step, not the River runtime); test values foragent.invocation_token_ttl,auth.clock_leeway,agent.jwks_max_age,signing_key.invocation_rotation_interval,signing_key.cache_ttlandsigning_key.rotation_check_intervalset through the configuration withsigning_key.cache_ttlandagent.invocation_token_ttlplusauth.clock_leewaybelowagent.jwks_max_age, which is belowsigning_key.invocation_rotation_interval; three reference verifiers withLeewayequal toauth.clock_leeway: X fetched the JWKS just before thenextkey was created, Y just after it was published, Z fetches for the first time at the first token after the promotion. Step the clock bysigning_key.rotation_check_intervalthrough two complete rotations ofinvocation. At every step A issues a token; X, Y and Z check it at issue time and one second beforeexpplusLeeway; B'sVerifychecks it at issue time and one second beforeexpplusauth.clock_leeway. At the last step before each promotion, the token signed with the old key is also checked at its last valid moment by a verifier that fetched the JWKS at that moment. Asserts: noinvalid, no unknownkid, noexpiredinside validity anywhere; every token carries thekidthat wasactiveat its issue time;ForcedReloads()stays zero for X, Y and Z (every reload comes frommax-age); B never misses akid. - AG-22 (emergency half at token level),
TestAG22_EmergencyRotationTokenLevel: afterinstance.Keys.Rotatewithmode = emergencyby an owner actor with fresh step-up (the builders M1-32'sTestEmergencyRotation_*use), a token signed before isinvalidin A at once and in B at the latest aftersigning_key.cache_ttl; a reference verifier whose JWKS cache was fetched after the rotation returnsErrRefUnknownKeyfor it after exactly one forced reload and fetches nothing more withinadapter.jwks_refresh_min_interval; a token issued after the rotation carries the newkidand passes A, B and the reference verifier, the latter after at most one forced reload. TestInvocationToken_LogCanary: issue and verify with the log capture ofhttpservertest(M1-18); the token string, its signature segment and its payload segment appear in no captured log record and no returned error.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/runs/... ./internal/wire/... ./test/integration/invocationtoken/...make arch,make semgrep,make docsmake check
Acceptance criteria¶
- Schema test of the claims: the claim set of the code equals the tables of 05 section 5 and 09 4.5; every issued token has the header
alg = EdDSA,kid,typ = agent-invocation+jwtand exactly the claims of its trigger kind, withoutnull. - Tokens are signed with the
activekey of purposeinvocation; a newjtion every call; nothing is stored; the token appears in no log or error. - AG-22 (scheduled rotation without failed runs, at token level): through two complete rotations no token is rejected inside its validity by any of three adapters or by a second server process, and no adapter needs a forced JWKS reload.
- AG-22 emergency half at token level: the old key is rejected on the server at once in the same process and within
signing_key.cache_ttlin another; new tokens verify after at most one reload. - A token with another
algortypis rejected by the reference verifier (and by the server verifier), includingnone,HS256with the public key as secret,at+jwtandterminal-session+jwt. -
Verifyreturns403 invocation_token_invalidwithreasoninvalidfor every non-expiry failure andexpiredonly when nothing else fails; a key of another purpose never verifies. -
runstestis not part of the binary; the reference verifier uses only the standard library and builds no HTTP client. - README, ADR 0023 "Confirmation", threat model row T-TK-05,
docs/DECISIONS.mdand CHANGELOG updated. -
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.
internal/runs/README.mdupdated (token section of Scope, the reference verifier and who uses it later).- Rulings of note 11 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); ADR 0023 "Confirmation" names this ticket's tests andmake adr-indexis run. 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 "forging or reusing the invocation token": algorithm and type confusion,kidof another purpose, header keys, tokens in logs and errors; 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. - Review and red team verdicts and the findings fixed.
- What is not proven (for example AG-22 with real deliveries and run starts, which M2-31 proves).
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found; at least the cases of Design note 7 (renewal after a key went
retired, emergency rotation while an adapter still caches the old key), each with a concrete proposal for the owner.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.