Skip to content

M2-21 Switching and guided flow (I4)

No Date Ruling Reason
D-M2-21-0 2026-10-20 Base int/m1-wave50 at d200587, branch feat/M2-21-billing-switch-i4 in its own worktree with feat/M2-20-heartbeat-presence at cb2a50f merged first (stacked build); not merged locally and no pull request from this build, the full verification runs on the build branch. The base holds the contract the brief needs (05 rule 1.6, R-AZ-17, the German chapters of the specification), so no contract commit is ported; the spec paragraphs of this ticket are English additions to the German chapter files of the base, as M2-20 did. Place: this branch. The task of the steering names the base, the stacked dependency and the verification lane; the brief's own merge and pull request steps follow the integration.
D-M2-21-1 2026-10-20 The guided flow crosses module borders through two ports: authz.I1Resolver (validation, authorize of every listed agent, AddAffected, the call of the switch) that identity and chat get through their constructors, and the source port authz.AgentPolicySwitch that agents implements (authzsource.Switch). EmptyAgentPolicySwitch fails closed; internal/wire binds the real switch in the agents step and bindGuidedFlow fails the start when the empty one remains. Place: internal/authz/resolvei1.go, internal/authz/app/resolvei1.go, internal/wire/serve.go. authz owns the rights logic of every action, agents owns its table, event and audit entry; identity and chat may not import agents (02 4.4), and the agents module is built after authz.
D-M2-21-2 2026-10-20 Order for all five actions: the whole body is validated first where the merged use case validates its body (authz before authorize; identity and chat with their reason after authorize, as M1-31 and M1-54 do), then the outer authorize and the outer checks that write nothing, and the switch runs inside the unit of the outer action after those checks (for the second unit of identity and chat under its locks) and before its first write. Place: CreateGrant, addMember, addOwnerWrite, setSpaceOperatorWrite, setChannelOperatorWrite. A caller without read right on the outer target learns nothing about the listed agents, and the switch never commits without the change (BR-07).
D-M2-21-3 2026-10-20 An operator change without effect switches no agent; it answers as M1-54 D2 with the no-change entry of the operator action. Place: chat/app/operators.go. The guided flow exists to make a change admissible; a request without change cannot violate I1, and a switch alone goes through setAgentBillingMode.
D-M2-21-4 2026-10-20 A listed agent already on organization keeps its row and gets an allow entry agent.billing_mode.set with no_change: true; a disabled personal agent is switched like an active one; no If-Match is read for the listed agents. Place: agents/app/switch.go. Another owner may have switched the agent since the preview and the aim still holds; a disabled personal agent counts for I1 again at agent.enable; set_all reads no If-Match either (06 8).
D-M2-21-5 2026-10-20 One apply step applySwitchToOrganization writes the row and agent.updated of every switch to organization for SetBillingMode, SetBillingModeAll and SwitchToOrganization; each caller keeps its own audit entry (one per agent for the single switch and the guided flow, one with the list for set_all). Place: agents/app/switch.go, agents/app/policy.go. All three paths must write the same rows and events (TestSwitchPathsShareApplyStep).
D-M2-21-6 2026-10-20 A successful guided flow writes one allow entry agent.billing_mode.set per listed agent and then the entry of the outer action, all with the request's correlation_id; with one agent these are the two entries of BR-07. Place: agents/app/switch.go. 05 rule 1.6 asks for one audit entry per action.
D-M2-21-7 2026-10-20 The presence tracker learns a desired mode changed by the guided flow with the agent's next heartbeat; no after-commit step is added. presence.billing_mode_matches in getAgent, listAgents and agent.updated compares the reported mode with the desired mode of that read instead of the tracker memory. Place: agents/app/service.go agentView. kernel/txn has no after-commit phase and the HTTP chain owns the outermost unit under Idempotency-Key (02 8.1, 8.4); without the read-time comparison the card would show a match until the next heartbeat.
D-M2-21-8 2026-10-20 previewGrant with resolve_i1 authorizes access.preview with the target action agent.billing_mode.set for every listed agent (owner, no step-up, no audit), switches them in the dry run before the grant and starts text with one switch line per agent switched from personal and the not-ready line when organization is missing from its billing_modes_supported, by agent name. Place: authz/app/preview.go, authz/domain/previewtext. R-RE-10: the preview is a dry run of the whole request; the console shows the lines of 10 section 11 point 2.
D-M2-21-9 2026-10-20 No new key in features. Place: none. resolve_i1 belongs to the spec version that declares it (06 13 point 1).
D-M2-21-10 2026-10-20 The brief's test of the agent's own consumer reads the events through catch-up (syncapp.CatchUpService.PageEvents as the agent), the read the consumer engine of pollAgentEvents uses, because the integration world of M1-54 builds no gateway engine; a personal token of an owner gets 403 forbidden with owner_only on addOwner (the order of M1-31 and SR5) where the brief expected step_up_not_possible. Place: test/integration. The merged code of M2-07 and M1-31 decides these answers; the tests follow the code (D-W-100).
D-M2-21-11 2026-10-20 The entry lock of the presence tracker (e.mu) is never held across a database call. A heartbeat runs under a second per-entry lock (e.beat, taken only by Heartbeat, never inside a unit of work because the route is exempt from Idempotency-Key) and takes e.mu only for its memory steps; it keeps configured values that a Changed set during its database calls. Every other holder of e.mu (memory for views and agent.updated, Changed, Samples, Flush, expire, Stop) therefore never waits for a heartbeat that waits for a row; the fallback of the first fix (TryLock with the stored row) is removed. Place: agents/app/heartbeat.go Heartbeat, agents/app/presence.go entry. Class fix of the lock cycle of M2-21 (ruling of the steering under the owner rule "same error class a second time: fix the class at one place"): the cycle was any holder of an agents or channel row that takes e.mu while a heartbeat holds e.mu and waits for that row (switch and guided flow through memory, red team round 1; disableAgent and enableAgent under Idempotency-Key through Changed, delta red team). Fixing the heartbeat, the only holder of e.mu across the database, closes every entry at once; TestPresenceNoTrackerCallWaitsForAHeartbeatInTheDatabase parks a heartbeat in each of its database calls against every entry, the TestLockCycle_* integration tests keep the repros.
D-M2-21-12 2026-10-22 Port onto main from the ticket branch (66ef408..721ad0e, built on int/m1-wave50 with M2-20 stacked) after M2-20 landed: the ticket's own changes are applied on current main, the generated REST server, Go client, Dart, Python and TypeScript clients are regenerated from the merged contract, and the text the ticket added to the German spec chapters is carried into their English versions on main (02-architecture.md, 07-permissions.md, 10-backend-policy.md). The integration fix INT-W52/2 (module table of 02) is part of this port; INT-W52/4 (reason limit in the runs test world) is not, because that world is not on main; whoever lands it sets ReasonMaxChars there. Place: this branch. Each ticket lands on main on its own; only the ticket's own commits come along.