M2-21: Switching and guided flow (I4)¶
Status: ready | Depends on: M1-54, M2-20 | Milestone: M2
Goal¶
After this ticket an owner turns a change that invariant I1 would reject into an admissible one in a single request, without any moment in which I1 is broken (R-AZ-17). The five actions grant.create, group.member.add, owner.add, space.operator.set and channel.operator.set accept the optional request member resolve_i1 = {switch_to_organization: [agent_ids], reason}: the server authorizes agent.billing_mode.set for every listed agent, switches those agents to organization first and then applies the change, all in one transaction, checks I1 before the commit and writes one audit entry per action with the same correlation_id. Either every part commits or none does (BR-07). The grant preview (previewGrant, whose body is GrantCreate) runs the same request as a dry run. Switching per agent and for all agents with a reason (setAgentBillingMode, setAllAgentsBillingMode, built in M1-51) and the guided flow share one apply step, so all three paths write the same rows and events, and the whole I4 path is proven against all real sources and the heartbeat of M2-20: set_all to personal with one violating agent changes nothing (BR-08), a switch reaches every client and the agent's own consumer as agent.updated, and the adapter's actual mode shows as a difference until its heartbeat reports organization. This is the server side of 10 "Switching (I4)" and "Guided flow 'Grant a person access in a personal space'", the mitigation of RK-11 and the way out that OQ-01 relies on: I1 keeps no owner exception (L22), and a second owner or a further person with trigger becomes possible by switching first.
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 8 "M2 Agent platform, extensions and terminal" (Goal; Scope "10 with I2 to I4 and I6, configured and reported mode"; Out of scope "user interface (M4)"; Exit criteria items 2 and 10; Test strategy; Red team "faking the reported mode", "another person triggering a personal subscription through every path", "confused deputy through the server API"; Risks), block "agents and extensions" rows M2-07, M2-08, M2-09, M2-20, M2-21, M2-22; section 7 rows M1-29, M1-31, M1-48, M1-49, M1-51, M1-54; section 10 row M4-28.- Dependencies. Header: M1-54 (operators with I1, the world builder in
test/integration/) and M2-20 (heartbeat, presence,billing_modes_supported, warnings). Implied through them and therefore not in the header: M1-13 (unit of work,RightsChange, phase hooks), M1-17 (generated server,api/operations.yaml), M1-18 (HTTP chain, problem writer, closed request decoding,httpservertest), M1-20 (idempotency keys), M1-24 (auditing.Recorder), M1-26 (kernel/textcheck), M1-28 (authorize, resource descriptors,AgentPolicySource), M1-29 (rights-change hook,AddAffected,VerifyInvariants,InvariantErrorwithconflicts, the AZ-P7 case table), M1-31 (Owners.AddOwner), M1-33 (identitytest), M1-35 (step-up), M1-44 (agent API tokens), M1-48 (CreateGrant,AddGroupMember), M1-49 (PreviewGrantdry run and the text catalogue), M1-51 (agent records, switch use cases,internal/agents/adapters/authzsource), M1-53 (channels), M2-07 (agent consumers,pollAgentEvents). docs/spec/10-backend-policy.md: 3 "Invariants" (I1, I4 and the consequence for owners), 5.1 "Which changes", 5.3 "Query", 5.4 "Error response", 8 "Expected and reported", 9.1 "Actions", 9.2 "What happens when switching", 11 "Guided flow 'Grant a person access in a personal space'" (all, including the sequence diagram and the reverse way), 13 "Display in the app" (rows Admin switch, I1 preview), 14 "Audit trail", 15 "Tests" (BR-01 to BR-08), 17 "Configuration values of this chapter".docs/spec/05-actions.md(core contract, read only): 1 "Binding rules" rules 1, 2, 6 (the I1 list and the bullet "Guided flow") and 7; 2.4 (columns Owner, Step-up, Audit, Permissions); 4.2 rowsowner.addandaccess.preview; 4.4 rowsgroup.member.addandgrant.create; 4.5 rowsspace.operator.setandchannel.operator.set; 4.10 rowsagent.read,agent.billing_mode.set,agent.billing_mode.set_all,agent.credential_holder.set,agent.heartbeat; 6 rowsagent.updated,grant.changed,group.changed,user.updated,access.changed; 8 R-AZ-01, R-AZ-03, R-AZ-04, R-AZ-14, R-AZ-17; 9 test 5 ("I1 test ... and withresolve_i1, which makes it permissible").docs/spec/03-data-model.md(core contract, read only): tableagents(billing_mode,credential_holder_id,billing_mode_changed_at,billing_mode_changed_by,version, the check(billing_mode = 'personal') = (credential_holder_id IS NOT NULL), the paragraph on immediate storage of a change ofreported_billing_mode),audit_events(correlation_id,details),owners,grants,group_memberships; 6.3 "Lock order"; 6.4 "I1 check" (last paragraph: the guided flow).docs/spec/07-permissions.md: 2.7 "Owner", 4.1 "Decisions and reasons", 4.2 "Flow", 4.4 "Invariants in the transaction", SR1, SR5, SR8, 9.3 "Plain-text preview", 10 "Examples" (the world of the tests), 11.1 "Test matrix" (AZ-M21 to AZ-M25), 11.2 "Required tests" (AZ-P7), 12 R-RE-10.docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope", 8.2 "What happens on a change", 15.1 "Connection and cursor" point 4 (an agent receivesagent.updatedabout itself).docs/spec/02-architecture.md: 4.1 "Rules" (rule 4: source port or in-transaction event), 4.3 "Ports, events and sources per module" (rowsauthz,identity,chat,agents), 4.4 "Allowed dependencies" (identityandchatmay useauthz, nothing belowagentsmay useagents; last paragraph on empty sources), 4.5 (kernel/reqmeta,kernel/textcheck,kernel/auditing,kernel/eventlog,kernel/txn), 8.1 "Flow" (RightsChangeonly at the outermost unit of work), 8.2 "Lock order", 8.4 "Idempotency in the Unit of Work".docs/spec/06-api.md: 2 "Basic rules" (rows No effect, Nonull, Unknown values, Correlation); 5 "Errors" (points 3, 4 and 7); 7 "Idempotency"; 8 "Concurrency"; 13 "Versioning, deprecation, compatibility" point 1 (features); 16 "Endpoints" groups Users, Groups, Access, Spaces, Channels, Agents.docs/spec/09-agents.md4.8 "Heartbeat and reported state" (comparison of actual and desired mode).docs/spec/19-security.md: SEC-IN-1, SEC-IN-4, SEC-AUD-5, tests SEC-T06 and SEC-T07, keyaudit.reason_max_chars.docs/spec/01-product.md: wireframe A5.docs/api/openapi.yaml: operationsaddOwner,addGroupMember,createGrant,previewGrant,setSpaceOperator,setChannelOperator,setAgentBillingMode,setAllAgentsBillingMode,setCredentialHolder,getAgent,sendHeartbeat,pollAgentEvents,explainChannelAccess,revokeGrant,createGroup; schemasOwnerAdd,GroupMemberAdd,GrantCreate,OperatorSet,GrantPreview,PreviewHint,InvariantViolation,Problem(memberconflicts,x-problem-codes),BillingModeSet,BillingModeSetAll,BillingModeSetAllResult,CredentialHolderSet,ReasonBody,GroupCreate,Agent,AgentRuntimeStatus,AgentPayload,AgentUpdatedEvent,GrantChangedPayload,EventEnvelope,AuditEvent,CorrelationId,Heartbeat,HeartbeatAck; headerIdempotent-Replay.docs/api/asyncapi.yaml: messagesevent.agent.updated,event.grant.changed,event.group.changed,event.user.updated,event.space.updated,event.channel.updated,event.access.changed.- ADRs 0008, 0013, 0017, 0021, 0024, 0053.
docs/DECISIONS.md: D-I1, D-L22, D-LS-08, D-K-D23, D-G-39, D-W-100; section 10 for the format of new rows.docs/OPEN-QUESTIONS.md: OQ-01 (default "No": I1 stays strict,owner.addis rejected, the guided flowresolve_i1switches first).docs/RISKS.md: RK-11.docs/security/threat-model.md: T-AI-02, T-AI-08.docs/operations/configuration.md:audit.reason_max_chars,auth.step_up_max_age,database.tx_retry_max,billing.mismatch_alert_after,agent.presence_timeout,agent.heartbeat_persist_interval.- M1 and M2 briefs whose interfaces this ticket uses:
M1-13-unit-of-work.md,M1-18-http-chain.md,M1-20-idempotency-keys.md,M1-24-audit-chain.md,M1-28-authz-core.md,M1-29-authz-invariants.md(scope 1, 2, 5 and 6; the AZ-P7 acceptance),M1-31-identity-people.md(AddOwner),M1-48-authz-api.md(createGrant,addGroupMember),M1-49-authz-explain.md(access.preview, dry run, text catalogue),M1-51-agents-records.md(backend policy,SetBillingModeAlllock order),M1-54-chat-operators.md(D1 to D7, the world builder),M2-07-agent-consumers.md(pollAgentEvents),M2-20-heartbeat-presence.md(Design notes 2, 8, 12 and 13: the tracker re-reads desired values on every heartbeat). - Merged code on branch
main(read withgit show main:<path>, no checkout):services/server/architecture/modules.yaml(moduleagentsownsagent.billing_mode.set; moduleauthzownsaccess.preview),services/server/internal/kernel/errs/codes.go,services/server/internal/agents/README.md,docs/spec/actions.yaml(rowsaccess.preview:owner: as_target,step_up: false,audit: never;agent.billing_mode.set). - Code:
internal/authz(port,app,domain/previewtext,adapters/httpapi),internal/identity(owners),internal/chat(operators),internal/agents(policy, presence,adapters/authzsource),internal/kernel/authorize,internal/kernel/auditing,internal/kernel/eventlog,internal/kernel/reqmeta,internal/kernel/textcheck,internal/kernel/txn,internal/platform/pg/pgtest,internal/platform/httpserver/httpservertest,internal/identity/adapters/identitytest,internal/wire,test/integration/,services/server/api/operations.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:
- I4: switching the backend policy is an owner action with step-up, per agent or for several agents in one transaction, with a mandatory reason, audited and a rights change; switching to
organizationalways works and clearscredential_holder_id(the old holder stays in the audit entry); switching topersonalonly if I1 holds afterwards, andset_alltopersonalonly with an explicit list (10 3 I4, 10 9.1; 05 4.10 rowsagent.billing_mode.set,agent.billing_mode.set_all; R-AZ-03, R-AZ-14). - Guided flow:
grant.create,group.member.add,owner.add,space.operator.setandchannel.operator.setaccept the optional memberresolve_i1 = {switch_to_organization: [agent_ids], reason}; the server then checksagent.billing_mode.setfor every listed agent withauthorize, switches them toorganizationfirst in the same transaction, applies the change, checks I1 and writes one audit entry per action with the samecorrelation_id(05 rule 1.6, R-AZ-17; 10 11 point 4; 03 6.4). - All or nothing: when any part fails (authorization of a switch, a check of the change, I1 or SR8) nothing of either part remains; only a deny audit entry written in its own transaction stays (BR-07; 07 4.4; 02 8.1).
set_alltopersonalwith one agent that would violate I1 changes nothing at all (BR-08; 10 9.1).- Effects of a switch:
agent.updatedwith the new badge to every principal withagent.read, including the agent itself; the rights version rises;access.changedonly for principals whose visible targets change (10 9.2 points 1 and 2; 05 6 rowagent.updated; 04 15.1 point 4). Running runs finish in the old mode and the next delivery carries the new desired mode (10 9.2 points 3 and 4); that side is M2-09 and M2-08, and nothing in this ticket may contradict it. - Desired and actual mode stay apart: the actual mode from the heartbeat is evidence for display, warning and audit, never a permission; the agent card shows the difference until the adapter reports the new mode, and the warning ends with that heartbeat (10 8, 9.2 point 5, 11 point 5; 09 4.8).
- Preview:
access.previewhas the right and the owner rule of the target action, never step-up and never audit; withresolve_i1the target actions aregrant.createandagent.billing_mode.set(05 4.2 rowaccess.preview; 07 9.3; R-RE-10). - Unknown request members are rejected with
400 validation_failed: after this ticketresolve_i1is a known member of exactlyOwnerAdd,GroupMemberAdd,GrantCreateandOperatorSetand stays unknown in every other body (06 2 "Unknown values"; SEC-IN-1; 05 rule 1.6, paragraph "Milestone"). - The reason of
resolve_i1is display text and the only free text in the audit details: no control or bidi characters, at mostaudit.reason_max_charscharacters (SEC-IN-4, SEC-AUD-5). - One
correlation_idper request: the response headerCorrelation-Id, every audit row and every event envelope of the request carry the same value (06 2 "Correlation"; 03audit_events.correlation_id). - No read right gives
404 not_found;403 forbiddenonly with read right (05 rule 2, R-AZ-01). I1 has no owner exception (L22, OQ-01).
Scope¶
All paths under services/server/ unless noted. No migration. No new top-level Makefile target.
Create:
internal/authz/resolvei1.go(port, root package): the DTOResolveI1 struct { AgentIDs []ids.ID; Reason string }(AgentIDsin request order, so error pointers can name the index); the interfaceI1Resolver { ResolveI1(ctx context.Context, by actor.Actor, req ResolveI1) error }thatidentityandchatcall inside their unit of work; the source portAgentPolicySwitch { SwitchToOrganization(ctx context.Context, by actor.Actor, agentIDs []ids.ID, reason string) error }thatagentsimplements;EmptyAgentPolicySwitch(fails closed with an internal error, doc comment names this ticket as the one that wires the real implementation). Names are proposals (02 4.3); follow the conventions of the merged port.internal/authz/domain/resolvei1.go: pure validation of plain values (agentIDs []ids.ID,reason string, the limit ofaudit.reason_max_charspassed in by the caller), returning field errors with pointers (Design note 2). The domain imports onlykernel-core(02 5), sointernal/authz/appunpacksauthz.ResolveI1before the call.internal/authz/app/resolvei1.go: the implementation ofI1Resolverwith the order of Design note 3 e, and its preview variant (Design note 9).internal/agents/app/switch.go: the shared apply stepapplySwitchToOrganization(Design note 4) and the use case behind the source port.test/integration/guided_flow_test.goandtest/integration/billing_switch_test.go: the integration tests below, on the world builder of M1-54.
Change:
docs/api/openapi.yaml(spec first): new schemaResolveI1(Design note 2); optional memberresolve_i1($ref: ResolveI1) inOwnerAdd,GroupMemberAdd,GrantCreateandOperatorSet; one sentence on the guided flow in the descriptions ofaddOwner,addGroupMember,createGrant,setSpaceOperator,setChannelOperator, and inpreviewGrantthe dry-run rule of Design note 9; a second request exampleguidedoncreateGrant. Thenmake api-lint,make api-tables,make api-checks; regenerate the server code with the generator of M1-17 (cd services/server && go generate ./api/...) so thatmake codegen-checkshows no diff. No operation is enabled or disabled inapi/operations.yaml: the five operations andpreviewGrantare enabled by M1-31, M1-48, M1-49 and M1-54.internal/authz/app:CreateGrantandAddGroupMember(M1-48) take an optional*authz.ResolveI1in their commands and call the resolver at the place of Design note 3;PreviewGrant(M1-49) passes it into the dry run (Design note 9); the requirement ofaccess.previewaccepts a descriptor of kindagentwhose target action isagent.billing_mode.setif the merged descriptor cannot name it yet.internal/authz/domain/previewtext(M1-49): two catalogue entries per language for the switch lines of Design note 9, with golden files.internal/authz/adapters/httpapi: mapGrantCreate.resolve_i1andGroupMemberAdd.resolve_i1into the commands.internal/identity: the port gets its own DTOResolveI1 struct { AgentIDs []ids.ID; Reason string }(02 5 rule 1, R-AR-03).AddOwner(M1-31) takes it as an optional*identity.ResolveI1(extend the method or its command in the style of the merged port).internal/identity/appconverts it toauthz.ResolveI1and callsauthz.I1Resolverbefore inserting the owner row.internal/identity/adapters/httpapimapsOwnerAdd.resolve_i1.internal/chat: the port gets its own DTOResolveI1 struct { AgentIDs []ids.ID; Reason string }.SetSpaceOperatorCmdandSetChannelOperatorCmd(M1-54) getResolveI1 *chat.ResolveI1. The use cases ininternal/chat/appconvert it toauthz.ResolveI1and call the resolver after the no-op check of Design note 3 d.internal/chat/adapters/httpapimapsOperatorSet.resolve_i1.internal/agents/app:SetBillingModeandSetBillingModeAll(M1-51) call the shared apply step for every switch toorganization(Design note 4); the reason rules of Design note 2 onBillingModeSet,BillingModeSetAllandCredentialHolderSetwhere M1-51 lacks them; the representation ofbilling_mode_matchesonly if Design note 8 requires it.internal/agents/adapters/authzsource: implementauthz.AgentPolicySwitchby calling the use case ofinternal/agents/app/switch.go.internal/wire: pass the agents implementation ofAgentPolicySwitchintoauthz, andauthzasI1Resolverintoidentityandchat; a wiring test asserts that the production graph uses the real switch, notEmptyAgentPolicySwitch.- AZ-P7 (M1-29): the guided-flow cases need the real
identity,chatandagentsmodules, so they live intest/integration/guided_flow_test.goasTestAZ_P7_ResolveI1MakesChangeAdmissiblenext to the existing AZ-P7 table ofinternal/authz, named with the test ID in the form M1-29 established. - Client SDKs: if a client package under
sdk/python,sdk/typescriptorpackages/api_clientalready has agenerate-checktarget, regenerate it with the generator its README names so thatmake api-clientsstays green; otherwise nothing. - Spec and docs:
docs/spec/07-permissions.md9.3 (one paragraph: preview withresolve_i1, Design note 9);docs/spec/10-backend-policy.md11 (point 1 and the sequence diagram name the preview resultinvariant_violationsofGrantPreviewinstead ofi1_conflicts, the agent's name comes fromgetAgent; point 4 the check order, the404/403answers and oneagent.billing_mode.setentry per listed agent; a sentence on the preview);docs/spec/02-architecture.md4.3 (rowauthz: portI1Resolver(guided flow), declaresAgentPolicySwitch; rowagents: implementsauthz.AgentPolicySwitch);docs/security/threat-model.mdT-AI-08 (test column: BR-07, BR-08); READMEs ofauthz,identity,chatandagents(English, like the existing files);services/server/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-M2-21-1and following (Design note 15).
Out of scope¶
- The console dialog with the plain texts of 10 11 point 2, the agent card, the global switch screen and the suggestion of "the agents that the last global switch switched over (from the audit log)" (M4-28; console views M6).
- Heartbeat, presence, the warnings of 10 8, the gauge and the alarm
BothyBillingModeMismatch(M2-20, merged); this ticket reads them only. - The invocation token with the current desired mode at the next delivery and BR-04 (M2-08); runs that end in the old mode, I3 at
run.startand BR-06 (M2-09); the adapters' own switch of their runtime (10 10; M2-33, M2-34, M2-35). - The API of the switch operations and
setCredentialHolder, BR-01 to BR-03 (M1-51 and M1-54, merged; they stay green, only the shared apply step changes inside). - Previews of
owner.add,group.member.addand the operator actions:docs/api/openapi.yamlhas no route for them (07 9.3 "previews of further actions come with their routes"); no ticket of the plan builds them. - The reverse way to
personalas a guided flow: the specification rules it out (10 11 last paragraph); this ticket only proves that the console can name the blocking sources with existing operations. group.member.sync(M1-42): the claim sync has no request body and noresolve_i1.- I6 and grants on hosts (M2-40); costs and reconciliation (M2-22, M2-28).
getAgentTriggerers(agent.triggerers.read): no ticket of the plan names it (plan gap).- SDK hand layers and conformance scenarios (M2-29, M2-30, M2-31).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-21-billing-switch-i4; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(authz): ...,feat(agents): ...,feat(identity): ...,feat(chat): ...,docs(api): ...) 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>. - No agent runtime, adapter, SDK, coding agent or model runs in any test and no third-party service is called: agents are harness principals that call the REST API with their own API tokens and DPoP proofs from
identitytest(heartbeat throughsendHeartbeat, events throughpollAgentEvents). - Time-dependent tests set short values for
billing.mismatch_alert_after,agent.presence_timeoutandagent.heartbeat_persist_intervalthrough the configuration loader or use thekernel/clocktest clock; no test depends on a default value. - Changes to code of merged tickets (M1-29, M1-31, M1-48, M1-49, M1-51, M1-54, M2-20) stay within the Design notes, each with a test; no other behaviour of those tickets changes, and I1 gets no exception of any kind (L22).
Design notes¶
-
Module boundaries (02 4.1 rule 4, 4.3, 4.4).
authz,identityandchatmay not importagents, so the switch crosses through a source port thatauthzdeclares andagentsimplements, asAgentPolicySourcedoes.authzowns the orchestration (validation,authorizeof every listed agent,AddAffected, the call of the switch) because the rights logic of every action lives only inauthz(05 rule 1);agentsowns the write of its table, the event and the audit entry ofagent.billing_mode.setbecause it executes that action (02 4.1 rule 6).identityandchatgetauthz.I1Resolverthrough their constructors;make archstays green without a new edge. Port packages import onlykernel, and domain packages import onlykernel-core(02 5 and rule 1, R-AR-03). In.go-arch-lint.yml, generated bytools/archgen, the root components areidentity: [identity, kernel-ports]andchat: [chat, kernel-ports], andauthz-domainmay depend only on itself. So onlyappandadaptersofidentityandchatimportauthz, and they convert the module's ownResolveI1DTO intoauthz.ResolveI1. Untilinternal/wirebinds the agents implementation,EmptyAgentPolicySwitchfails closed (02 4.4, last paragraph). Ruling for docs/DECISIONS.md. -
Request member and validation.
- Schema
ResolveI1indocs/api/openapi.yaml:type: object,required: [switch_to_organization, reason],switch_to_organization: array ofUuidwithminItems: 1anduniqueItems: true,reason: string withminLength: 1; description "Guided flow (05 rule 1.6, 10 section 11): switch these agents toorganizationfirst, in the same transaction as the change." Close the schema the way the merged write schemas are closed: 06 2 "Unknown values" and SEC-IN-1 ask foradditionalProperties: false, whiledocs/api/openapi.yamltoday carries it on no schema and the request decoder of M1-18 rejects unknown members; follow the merged convention and name it in the report. - Rules beyond the schema (
internal/authz/domain, used by all five paths and by the preview):reasonis trimmed and not blank, passeskernel/textcheck(SEC-IN-4) and has at mostaudit.reason_max_charscharacters (SEC-AUD-5); otherwise400 validation_failedwith pointer/resolve_i1/reasonanderrors[].coderequired, the code M1-26 uses for a text check, ortoo_long. An empty list or a repeated ID gives400 validation_failedwith pointer/resolve_i1/switch_to_organization(or/resolve_i1/switch_to_organization/<index>of the repeated entry) and the code the merged request validator uses for that keyword. - Field errors of the outer body and of
resolve_i1come together in one answer with allerrors[](06 5 point 3). - Apply the same reason rules to
reasonofBillingModeSet,BillingModeSetAllandCredentialHolderSetwhere M1-51 does not already (10 9.1 "reasonis required and appears in the audit").
- Schema
-
Order of checks, the same skeleton for all five actions (05 rule 1.6; 10 11 point 4: "checks both actions individually with
authorize... first the switch, then the grant"): a. Decode and validate the whole body (note 2). b. Load the outer target and run the outerauthorizeexactly as M1-31, M1-48 and M1-54 do:404 not_found,403 forbidden(owner_only,step_up_not_possible, delegation reasons) and401 step_up_requiredcome first, so a caller without read right on the outer target learns nothing about the listed agents. c. The outer checks that write nothing, unchanged: subject or person exists,409 conflict(already an owner, person not active, already a member,reasonclaim_managed_group, archived target),If-Matchof the operator actions (428 precondition_required,412 version_conflict), operator validation (M1-54 D1). d. Operator no-op: whensetSpaceOperatororsetChannelOperatorwould change nothing, answer as M1-54 D2 (200, no event, no version, no rights version; audit withno_change: truefor the operator action) and switch no agent. Reason: the guided flow exists to make a change admissible (05 rule 1.6), a request without change cannot violate I1, and a switch alone goes throughsetAgentBillingMode. Ruling for docs/DECISIONS.md. e. Inside the unit of work of the outer action (itsRightsChangelockedpermission_versionin phase 1, 02 8.1) and before its first write,I1Resolver.ResolveI1:- for each listed agent in ascending ID:
authorize(by, agent.billing_mode.set, {kind: agent, id}).not_found(unknown ID, a principal that is not an agent, an agent the caller may not read) is answered as404 not_found, with exactly the status, body and headers of an unknown agent ID, identical in all three cases (05 rule 1.2 and 2.5, R-AZ-01, ADR 0017, 07 4.2 "second target"); every other denial is returned asauthorizereturns it (403 forbiddenwithreasonowner_onlyfor a visible agent and a non-owner,401 step_up_required,403 forbiddenwithreasonstep_up_not_possiblefor a personal token). The first failing agent decides;authzaudits the denial as for every action with Audityes(R-AZ-04). AddAffected(ctx, agentIDs...)(M1-29), thenAgentPolicySwitch.SwitchToOrganization(note 4). f. The outer write, its events and its allow audit entry, unchanged. g. Phase hooks: I1, SR8 andaccess.changed(M1-29), thensyncandaudit. An error before step e switches nothing; an error in e, f or g rolls back everything (BR-07).
- for each listed agent in ascending ID:
-
The switch (
internal/agents):SwitchToOrganizationruns in the ambient transaction and calls noauthorize; its doc comment says that onlyauthzcalls it, after authorizing every agent. It locks theagentsrowsFOR UPDATEin ascendingprincipal_id(lock order of M1-51 forset_all; 03 6.3 point 2).- Per agent on
personal:billing_mode = organization,credential_holder_idNULL,billing_mode_changed_atnow (kernel/clock),billing_mode_changed_bythe actor,version + 1; appendagent.updated(resource{kind: agent, id, version}, empty scope, payloadAgentPayloadwith the agent after the change, receive actionagent.read); record one allow entryagent.billing_mode.setwith resource the agent and the detail keys M1-51 writes for this action (old and new mode, old holder,reason; 10 14), never names (SEC-AUD-5). - Per agent already on
organization: no row change, no event, an allow entry withno_change: true(06 2 "No effect"). Reason: another owner may have switched it since the console's preview, and the aim of the request still holds. Ruling for docs/DECISIONS.md. - A disabled agent is switched like an active one, as
set_alldoes in M1-51 (a disabled personal agent becomes relevant for I1 again atagent.enable, 10 5.1). - No
If-Matchfor the listed agents, as forset_all(06 8). Ruling for docs/DECISIONS.md. - Shared apply step: the row change and the event of a switch to
organizationmove into one function ininternal/agents/appthatSetBillingMode,SetBillingModeAllandSwitchToOrganizationcall; each caller keeps its own audit entry: one per agent for the single switch and the guided flow, one with the list of agents forset_all(10 14). M1-51's tests stay green unchanged. Ruling for docs/DECISIONS.md.
-
I1 after the switch:
AgentPolicySourcereads inside the ambient transaction (M1-29), so the hook sees the switched agents asorganizationand does not check them any more; every other active personal agent is checked as always (M1-29 ruling: all personal agents on every rights change). A list that misses a violating agent fails with409 i1_violationwhoseconflictsname only the remaining agents (10 5.4,InvariantViolation), and nothing of the switch remains. -
Audit and correlation:
- Success: one allow entry
agent.billing_mode.setper listed agent, then the allow entry of the outer action with its unchanged details, all with the request'scorrelation_idfromkernel/reqmeta, equal to the response headerCorrelation-Id. With one listed agent these are the two entries of BR-07 (10 11 point 4); with more agents there is one entry per action (05 rule 1.6 "one audit entry per action"). Ruling for docs/DECISIONS.md. - I1 or SR8 violation: only the deny entry of the outer action, written by the path of M1-29 in its own transaction (
deny_reason = i1_violationwithdetails.violations, orlast_owner), with the samecorrelation_id. - A denied switch: the deny entry of
agent.billing_mode.setwritten byauthz; the outer action has no allow entry.
- Success: one allow entry
-
Events: in the one transaction first
agent.updatedper changed agent, then the outer action's events (grant.changed,group.changed,user.updated,space.updatedorchannel.updatedwith the inheriting channels of M1-54 D4), thenaccess.changedfrom the hook. One request raises the rights version once (oneRightsChange); every envelope carries the samecorrelation_id. The agent receives its ownagent.updatedthrough its consumer (04 15.1 point 4, M2-07), which tells its adapter that the desired mode changed (10 9.2 point 5). -
Presence (M2-20). The tracker learns a desired mode changed by
resolve_i1with the agent's next heartbeat, which re-reads the desired values under the row lock (M2-20 notes 2 c and 13), the same rule as for a change made in another process. This matches 10 11, where the warning follows the next heartbeat, and needs no after-commit mechanism:kernel/txnhas none (its phases end with the commit, 02 8.1), and underIdempotency-Keythe HTTP chain owns the outermost unit of work (02 8.4), so a use case cannot act after the commit. Do not add one.Agent.presence.billing_mode_matchesingetAgentand in the payload ofagent.updatedcompares the last reported mode with the desiredbilling_moderead in that request (the rule of M1-51 that M2-20 note 8 keeps); if the merged code takes it from tracker memory, change only that comparison in the representation, with a test. Warnings and the mismatch age follow the tracker. Ruling for docs/DECISIONS.md. -
Preview (
previewGrant, bodyGrantCreate):- With
resolve_i1the preview is a dry run of the whole request (R-RE-10). Authorization:access.previewfor the grant target exactly as M1-49; then for every listed agent in ascending IDaccess.previewwith target actionagent.billing_mode.set(05 4.2: right and owner rule of the target action, without step-up;docs/spec/actions.yamlowner: as_target): an owner passes without fresh step-up, a visible non-owner gets403 forbiddenwithreasonowner_only, an unknown or invisible agent404 not_foundas in note 3 e.access.previewnever audits, so no deny entry is written. - In the dry-run transaction of M1-49:
AddAffectedand the switch, then the grant apply step, then the evaluator; the sentinel rollback of M1-49 leaves no row, event, audit entry or counter change. - Result:
allowed,effects,invariant_violationsandhintsas after both parts.textgets one line per agent that the request would switch frompersonal, from two new catalogue entries per language in the style of the existing keys, worded after 10 11 point 2: the switch line ("First ... is switched to Organization; the organization key then bears the costs.") and, whenorganizationis not in the agent'sbilling_modes_supported, the not-ready line ("The adapter of ... does not report an organization key yet. Invocations fail until it is switched."), with a German counterpart each; golden files fordeanden. Ruling for docs/DECISIONS.md. - Without
resolve_i1the preview is unchanged;invariant_violations[].organization_readycomes frombilling_modes_supportedthat M2-20 now fills (10 11 point 1).
- With
-
Idempotency and concurrency:
createGrant,addGroupMemberandaddOwnerkeepIdempotency-Key(M1-20; 02 8.4):resolve_i1is part of the stored body, so the same key with another list gives422 idempotency_key_reuse, and a replay returns the stored answer withIdempotent-Replay: trueand switches nothing again. The operator actions keepIf-Match.- Rights changes serialize on
permission_version(03 6.3), so a guided flow and a parallelsetAgentBillingModeback topersonalfor the same agent end in an order that keeps I1 (BR-03 shape); deadlocks and lock timeouts are retried by the unit of work (database.tx_retry_max). A heartbeat locks only itsagentsrow (M2-20) and waits for the switch to commit.
-
Answers per step (codes only from
x-problem-codes, 06 5 point 7):
| Step | Condition | Answer |
|---|---|---|
| a | schema or rule of note 2 | 400 validation_failed with all errors[] |
| b | outer target invisible or unknown | 404 not_found |
| b | outer action not allowed, step-up missing or old, personal token | as M1-31, M1-48, M1-54 (403 forbidden, 401 step_up_required) |
| c | outer state | as M1-31, M1-48, M1-54 (409 conflict, 428 precondition_required, 412 version_conflict, 400 validation_failed) |
| d | operator no-op | 200, nothing switched |
| e | listed agent unknown or invisible | 404 not_found, identical to an unknown agent ID |
| e | listed agent visible, caller no owner | 403 forbidden with reason owner_only |
| g | a personal agent still violates I1 | 409 i1_violation with hint and conflicts |
| g | no active owner left | 409 last_owner |
| success | 201 Grant, 201 GroupMember, 201 Owner, 200 Space, 200 Channel as without resolve_i1 |
-
Discovery: no new key in
features(06 13 point 1:featuresnames capabilities beyond the base contract of the spec version;resolve_i1belongs to the spec version that declares it). Ruling for docs/DECISIONS.md. -
Switching per agent and for all agents (M1-51 operations): the API stays; the shared apply step of note 4 and the reason rules of note 2; BR-08 and the end-to-end tests of I4 run against all real sources (world builder of M1-54) and the heartbeat of M2-20.
agent.updatedafter a switch reaches every principal withagent.readand the agent itself;access.changedappears only for principals whose visible targets change (M1-29 ruling), the rights version rises in any case. -
The reverse way (10 11 last paragraph): no new operation. The
409 i1_violationof a switch back topersonalnames the extra principals inconflicts, andexplainChannelAccesson a served channel names their sources (grant, group, owner); the testTestReverseSwitchNamesBlockingSourcesproves the console can show both. -
Rulings for
docs/DECISIONS.mdsection 10, one row each with date, decision, reason and place:D-M2-21-1resolver port inauthzand source portAgentPolicySwitchimplemented byagents(note 1);D-M2-21-2check order and the place of the switch before the first write (note 3);D-M2-21-3operator no-op switches nothing (note 3 d);D-M2-21-4agents already onorganizationand disabled agents, noIf-Match(note 4);D-M2-21-5shared apply step with audit per caller (note 4);D-M2-21-6one audit entry per action, N plus one entries (note 6);D-M2-21-7presence follows at the next heartbeat,billing_mode_matchesat read time (note 8);D-M2-21-8preview withresolve_i1as a dry run of the whole request with switch lines in the text (note 9);D-M2-21-9no discovery feature key (note 12). The rest is in 05, 07 and 10 and needs no ruling, including the404 not_foundfor unknown and invisible agents (note 3 e, 05 rule 1.2).
Tests (write them first)¶
Unit:
internal/authz/domain:TestResolveI1Validate: table with a valid request, an empty list, a repeated ID (pointer of the second occurrence), a blank reason, a reason with a control and with a bidi character, a reason of exactlyaudit.reason_max_charscharacters (accepted) and one more (too_long); every rejection names its pointer.internal/authz/app(fake authorizer, fake switch, fake transaction state):TestResolveI1Order:authorizeis called withagent.billing_mode.setonce per agent in ascending ID; the first denial is returned unchanged and nothing after it runs;not_foundis returned as404 not_foundand nothing after it runs;AddAffectedis called with all IDs before the switch; the switch is called once with the sorted IDs and the trimmed reason; the preview variant callsauthorizewithaccess.previewand never asks for step-up.TestEmptyAgentPolicySwitchFailsClosed.internal/agents/domainorapp(fake repository):TestSwitchToOrganization:personaltoorganizationclears the holder and sets changed-at and changed-by and the version; an agent onorganizationgives no change andno_change: true; a disabled personal agent is switched.TestSwitchPathsShareApplyStep: the single switch,set_alltoorganizationandSwitchToOrganizationwrite equal rows and equalagent.updatedpayloads for the same agent; the audit entries differ only as note 4 says.internal/authz/domain/previewtext:TestPreviewTextSwitchLines: golden files indeandenfor a preview with one switched agent that supportsorganizationand one that does not.
Integration (PostgreSQL 18 via pgtest, all real modules through internal/wire, HTTP through the real chain, the world builder of M1-54 with the world of 07 10 and 11.1: Owner, Alex, Sam, Robin; Lotse (organization, operator of Support, G-5), Texter (organization, operator of Marketing), Runner, Assistent (personal, credential holder Owner, operator of Research); grants G-1 to G-6 created through the authz admin port; agent tokens and DPoP proofs from identitytest):
TestAZ_P7_ResolveI1MakesChangeAdmissible(05 9 test 5; AZ-P7): reads the bullet "Guided flow" of 05 rule 1.6 and fails if an action named there has no case. Per action, each in a fresh world, first withoutresolve_i1then withresolve_i1 = {switch_to_organization: [Assistent], reason}:grant.createSamtriggeron Research/Notizen (AZ-M21 shape):409 i1_violation, then201;group.member.addSam into a group withtriggeron Research (AZ-M25 shape):409 i1_violation, then201;owner.addAlex (AZ-M23 shape):409 i1_violation, then201and Alex is an owner;channel.operator.setAssistent on Support/Allgemein where Alex holds G-1 (AZ-M24 shape):409 i1_violation, then200witheffective_agent_idAssistent;space.operator.setAssistent as default operator of Support:409 i1_violation, then200. After each successful case Assistent isorganizationwithoutcredential_holder_id, the change exists,agent.updatedfor Assistent precedes the action's own event inevent_logwith the samecorrelation_idandpermission_version, the allow entries ofagent.billing_mode.setand of the action share thatcorrelation_id, andVerifyInvariantsofauthzpasses.
TestBR07_GuidedFlowCommitsBoth(BR-07):createGrantSamtriggeron Research/Notizen withresolve_i1 [Assistent]:201; exactly two allow audit entries in this order,agent.billing_mode.setfor Assistent (oldpersonal, neworganization, old holder Owner, the reason ofresolve_i1) andgrant.createwith its M1-48 details; both carry thecorrelation_idequal to the response headerCorrelation-Id;event_logholdsagent.updated(payloadbilling_mode: organization, nocredential_holder_id) beforegrant.changedand thenaccess.changedfor Sam, all with thatcorrelation_idand one newpermission_version; payloads validate against their schemas and contain nonull.TestBR07_AbortInChangePartUndoesSwitch(BR-07, abort of a part): world variant without G-2 in which Texter ispersonalwith holder Owner;createGrantSamtriggeron the instance withresolve_i1 [Assistent]:409 i1_violationwhoseconflictsname Texter only; Assistent is stillpersonalwith holder Owner and unchanged version; no grant, noagent.updated, no allow entry,permission_versionunchanged; exactly one deny entrygrant.createwithdeny_reason = i1_violationanddetails.violationsnaming Texter.TestBR07_AbortInSwitchPartLeavesChangeUndone(BR-07, abort of the other part): Alex withtriggerandmanageon Support (world variant as AZ-M38) creates a grantreadfor Sam on Support/Allgemein, which alone would succeed: withresolve_i1 [Lotse]the answer is403 forbiddenwithreasonowner_only; withresolve_i1 [Assistent](invisible for Alex)404 not_found, byte for byte equal in status, body and headers (apart fromCorrelation-Id) to the answer for a random UUID; in both cases no grant, no switch, no allow entry, and the denial ofagent.billing_mode.setstands in the audit log after the recorder's aggregation flush (M1-24,audit.deny_aggregate_window).TestResolveI1_OuterChecksComeFirst: with aresolve_i1that would be valid:addGroupMemberon a group the caller cannot see gives404 not_found;setChannelOperatorwithoutIf-Matchgives428 precondition_required, with an old one412 version_conflict;addGroupMemberfor a person who already is a member gives409 conflict;addOwnerwithout fresh step-up gives401 step_up_required, with the owner's personal token403 forbiddenwithreasonstep_up_not_possible; Assistent stayspersonalin every case.TestResolveI1_OperatorNoOpSwitchesNothing:setChannelOperatorwith the channel's current assignment andresolve_i1 [Assistent]:200, no version change, Assistent stillpersonal, one audit entrychannel.operator.setwithno_change: trueand none foragent.billing_mode.set.TestResolveI1_AlreadyOrganizationIsNoChange:resolve_i1 [Assistent, Lotse]on a grant that needs it: Lotse keeps its version and gets noagent.updated; its audit entry carriesno_change: true; Assistent is switched; three allow entries share thecorrelation_id.TestResolveI1_ReasonRules(SEC-T07 for this field): blank, bidi and overaudit.reason_max_charsgive400 validation_failedwith pointer/resolve_i1/reason; the same table onBillingModeSet,BillingModeSetAllandCredentialHolderSetgives the matching pointer/reason.TestResolveI1_KnownOnlyInFourSchemas(SEC-IN-1, SEC-T06 shape):resolve_i1in the bodies ofsetAgentBillingMode,revokeGrant(ReasonBody) andcreateGroupgives400 validation_failedas an unknown member; inOwnerAdd,GroupMemberAdd,GrantCreateandOperatorSetit is accepted; a test overdocs/api/openapi.yamlasserts that exactly these four schemas referenceResolveI1.TestResolveI1_IdempotencyReplay:createGrantwithresolve_i1and anIdempotency-Key; the replay answers the stored201withIdempotent-Replay: trueand writes no secondagent.updatedor audit entry; the same key with another list gives422 idempotency_key_reuse.TestResolveI1_ConcurrentSwitchBackSerializes(BR-03 shape): world variant with Assistent onorganizationand nobody but the Owner withtriggeron Research;setAgentBillingModeAssistent topersonal(holder Owner) and a guidedcreateGrant(Samtriggeron Research/Notizen,resolve_i1 [Assistent]), each valid alone, start behind a barrier, in both orders: either the switch topersonalcommits first and the guided flow then switches Assistent back and commits, or the guided flow commits first (Assistent unchanged,no_change: true) and the switch topersonalgets409 i1_violation; never do both end with Assistentpersonaland Sam's grant; no client sees a deadlock;VerifyInvariantspasses afterwards.TestBR08_SetAllPersonalWithViolatorChangesNothing(BR-08, all real sources): world variant with Assistent onorganization;setAllAgentsBillingModetopersonalwithagent_ids [Assistent, Lotse]and holder Owner:409 i1_violationwithconflictsnaming Lotse; both agents unchanged (mode, holder, version), noagent.updated,permission_versionunchanged, one deny entryagent.billing_mode.set_allwithdeny_reason = i1_violation.TestI4_SwitchReachesClientsAndAdapter(10 9.2): Assistent's adapter sends a heartbeat withreported_billing_mode: personalandbilling_modes_supported [personal, organization](M2-20); the owner switches Assistent toorganizationwithsetAgentBillingModeand a reason:agent.updatedinevent_logwith receive actionagent.read; Assistent's consumer gets it throughpollAgentEvents;getAgentshowsbilling_mode: organizationandpresence.billing_mode_matches: false; afterbilling.mismatch_alert_afterwith a further heartbeat reportingpersonalthe warningbilling_mode_mismatchappears; a heartbeat reportingorganizationends it andbilling_mode_matchesistrue. In a world variant without G-2 where Texter ispersonalwith holder Owner and then disabled,setAllAgentsBillingModetoorganizationwithout a list switches every agent not onorganization, the disabled Texter included, with one audit entry listing the agents and oneagent.updatedper changed agent.TestGuidedFlowAdapterStateAfterSwitch(10 11 point 5 and the sequence diagram): Assistent's adapter reportspersonalwith both modes supported; then the request ofTestBR07_GuidedFlowCommitsBothruns in this test's own world; Assistent's consumer receives theagent.updated; the next heartbeat withpersonalanswersHeartbeatAck.presence.billing_mode_matches: false, and oncebilling.mismatch_alert_afterhas passedgetAgentcarries the warningbilling_mode_mismatch; a heartbeat withorganizationends the difference and the warning.TestGuidedFlowPreviewOrganizationReady(10 11 point 1):previewGrantSamtriggeron Research/Notizen withoutresolve_i1:allowed: false, one violation for Assistent withorganization_ready: falsebefore any heartbeat andtrueafter a heartbeat withbilling_modes_supportedcontainingorganization, made visible toAgentPolicySourcethe way M2-20's tests do.TestPreviewGrantWithResolveI1(note 9): the owner without fresh step-up previews the same grant withresolve_i1 [Assistent]:200,allowed: true, noinvariant_violations, an effect for Sam, the switch line intext; afterwards noevent_logrow, no audit row,permission_versionunchanged and Assistent stillpersonal; Alex withmanageon Support previewing a grant there withresolve_i1 [Lotse]gets403 forbiddenwithreasonowner_only, with an unknown agent404 not_found.TestReverseSwitchNamesBlockingSources(10 11 last paragraph): after the guided flow,setAgentBillingModeAssistent topersonalgives409 i1_violationwithprincipal_ids [Sam];explainChannelAccesson Research/Notizen lists Sam with the source of the grant; afterrevokeGrantthe switch topersonalsucceeds.TestGuidedFlowAuditDetails(SEC-AUD-5): the details of every entry written in these tests contain only the reserved fields, IDs, codes and the reason; a search for the display names of Owner, Alex, Sam and the agents finds nothing.TestWiringUsesRealAgentPolicySwitch(internal/wire): the production graph binds the agents implementation.
Every response of these tests validates against docs/api/openapi.yaml in the test build and contains no null.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/authz/... ./internal/agents/... ./internal/identity/... ./internal/chat/... ./internal/kernel/... ./internal/wire/... ./test/integration/...make codegen-check,make arch,make api-lint,make api-tables,make api-checks,make register-check,make docs,make api-clientsmake check
Acceptance criteria¶
-
resolve_i1is a member ofOwnerAdd,GroupMemberAdd,GrantCreateandOperatorSet(schemaResolveI1), the generated server carries it, and every other body still rejects it with400 validation_failed. -
resolve_i1on each ofgrant.create,group.member.add,owner.add,space.operator.setandchannel.operator.setswitches the listed agents first and then applies the change in one transaction; each action has a case that is rejected without and admitted withresolve_i1(05 9 test 5, AZ-P7). - BR-07: both parts or none commit, for an abort in the change part and in the switch part; a success writes two audit entries (one per action) with the same
correlation_idas the response header and the events. - BR-08 with all real sources:
set_alltopersonalwith one violating agent changes nothing; BR-01 to BR-03 stay green. - Every listed agent is authorized with
agent.billing_mode.set; a non-owner gets403 forbiddenwithreasonowner_only; an unknown and an invisible agent give the same404 not_found. - Switching per agent and for all agents shares one apply step with the guided flow; the switch reaches clients and the agent's own consumer as
agent.updated, and the actual mode shows as a difference until a heartbeat reportsorganization. - The preview with
resolve_i1is a dry run of the whole request without step-up and leaves nothing behind; without itorganization_readyreflects the heartbeat. - Replays and concurrent switch-backs keep I1; no deadlock reaches a client.
- Spec and docs updated (openapi, 07 9.3, 10 11, 02 4.3, threat model, READMEs, CHANGELOG, rulings).
-
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,chatandagentsupdated (the resolver port and the source port, the guided flow in each use case, the shared apply step, the presence rule of note 8). - Rulings of note 15 recorded in
docs/DECISIONS.mdsection 10 (one row each with date, decision, reason and place). 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 T-AI-08 and T-AI-02: can a principal without owner rights switch an agent through any of the five bodies or the preview, can any sequence of guided flows, switches, grants, memberships, owners and operators commit a state that breaks I1, also in parallel, and does the preview persist anything; 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.
- Follow-ups that could not be done in this scope, each with its reason, and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.