Skip to content

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, InvariantError with conflicts, 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 (PreviewGrant dry 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 rows owner.add and access.preview; 4.4 rows group.member.add and grant.create; 4.5 rows space.operator.set and channel.operator.set; 4.10 rows agent.read, agent.billing_mode.set, agent.billing_mode.set_all, agent.credential_holder.set, agent.heartbeat; 6 rows agent.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 with resolve_i1, which makes it permissible").
  • docs/spec/03-data-model.md (core contract, read only): table agents (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 of reported_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 receives agent.updated about 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" (rows authz, identity, chat, agents), 4.4 "Allowed dependencies" (identity and chat may use authz, nothing below agents may use agents; last paragraph on empty sources), 4.5 (kernel/reqmeta, kernel/textcheck, kernel/auditing, kernel/eventlog, kernel/txn), 8.1 "Flow" (RightsChange only 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, No null, 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.md 4.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, key audit.reason_max_chars. docs/spec/01-product.md: wireframe A5.
  • docs/api/openapi.yaml: operations addOwner, addGroupMember, createGrant, previewGrant, setSpaceOperator, setChannelOperator, setAgentBillingMode, setAllAgentsBillingMode, setCredentialHolder, getAgent, sendHeartbeat, pollAgentEvents, explainChannelAccess, revokeGrant, createGroup; schemas OwnerAdd, GroupMemberAdd, GrantCreate, OperatorSet, GrantPreview, PreviewHint, InvariantViolation, Problem (member conflicts, x-problem-codes), BillingModeSet, BillingModeSetAll, BillingModeSetAllResult, CredentialHolderSet, ReasonBody, GroupCreate, Agent, AgentRuntimeStatus, AgentPayload, AgentUpdatedEvent, GrantChangedPayload, EventEnvelope, AuditEvent, CorrelationId, Heartbeat, HeartbeatAck; header Idempotent-Replay. docs/api/asyncapi.yaml: messages event.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.add is rejected, the guided flow resolve_i1 switches 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, SetBillingModeAll lock 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 with git show main:<path>, no checkout): services/server/architecture/modules.yaml (module agents owns agent.billing_mode.set; module authz owns access.preview), services/server/internal/kernel/errs/codes.go, services/server/internal/agents/README.md, docs/spec/actions.yaml (rows access.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):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. 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).
  4. 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.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. 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.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/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:

  1. 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 organization always works and clears credential_holder_id (the old holder stays in the audit entry); switching to personal only if I1 holds afterwards, and set_all to personal only with an explicit list (10 3 I4, 10 9.1; 05 4.10 rows agent.billing_mode.set, agent.billing_mode.set_all; R-AZ-03, R-AZ-14).
  2. Guided flow: grant.create, group.member.add, owner.add, space.operator.set and channel.operator.set accept the optional member resolve_i1 = {switch_to_organization: [agent_ids], reason}; the server then checks agent.billing_mode.set for every listed agent with authorize, switches them to organization first in the same transaction, applies the change, checks I1 and writes one audit entry per action with the same correlation_id (05 rule 1.6, R-AZ-17; 10 11 point 4; 03 6.4).
  3. 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).
  4. set_all to personal with one agent that would violate I1 changes nothing at all (BR-08; 10 9.1).
  5. Effects of a switch: agent.updated with the new badge to every principal with agent.read, including the agent itself; the rights version rises; access.changed only for principals whose visible targets change (10 9.2 points 1 and 2; 05 6 row agent.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.
  6. 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).
  7. Preview: access.preview has the right and the owner rule of the target action, never step-up and never audit; with resolve_i1 the target actions are grant.create and agent.billing_mode.set (05 4.2 row access.preview; 07 9.3; R-RE-10).
  8. Unknown request members are rejected with 400 validation_failed: after this ticket resolve_i1 is a known member of exactly OwnerAdd, GroupMemberAdd, GrantCreate and OperatorSet and stays unknown in every other body (06 2 "Unknown values"; SEC-IN-1; 05 rule 1.6, paragraph "Milestone").
  9. The reason of resolve_i1 is display text and the only free text in the audit details: no control or bidi characters, at most audit.reason_max_chars characters (SEC-IN-4, SEC-AUD-5).
  10. One correlation_id per request: the response header Correlation-Id, every audit row and every event envelope of the request carry the same value (06 2 "Correlation"; 03 audit_events.correlation_id).
  11. No read right gives 404 not_found; 403 forbidden only 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 DTO ResolveI1 struct { AgentIDs []ids.ID; Reason string } (AgentIDs in request order, so error pointers can name the index); the interface I1Resolver { ResolveI1(ctx context.Context, by actor.Actor, req ResolveI1) error } that identity and chat call inside their unit of work; the source port AgentPolicySwitch { SwitchToOrganization(ctx context.Context, by actor.Actor, agentIDs []ids.ID, reason string) error } that agents implements; 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 of audit.reason_max_chars passed in by the caller), returning field errors with pointers (Design note 2). The domain imports only kernel-core (02 5), so internal/authz/app unpacks authz.ResolveI1 before the call.
  • internal/authz/app/resolvei1.go: the implementation of I1Resolver with the order of Design note 3 e, and its preview variant (Design note 9).
  • internal/agents/app/switch.go: the shared apply step applySwitchToOrganization (Design note 4) and the use case behind the source port.
  • test/integration/guided_flow_test.go and test/integration/billing_switch_test.go: the integration tests below, on the world builder of M1-54.

Change:

  • docs/api/openapi.yaml (spec first): new schema ResolveI1 (Design note 2); optional member resolve_i1 ($ref: ResolveI1) in OwnerAdd, GroupMemberAdd, GrantCreate and OperatorSet; one sentence on the guided flow in the descriptions of addOwner, addGroupMember, createGrant, setSpaceOperator, setChannelOperator, and in previewGrant the dry-run rule of Design note 9; a second request example guided on createGrant. Then make 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 that make codegen-check shows no diff. No operation is enabled or disabled in api/operations.yaml: the five operations and previewGrant are enabled by M1-31, M1-48, M1-49 and M1-54.
  • internal/authz/app: CreateGrant and AddGroupMember (M1-48) take an optional *authz.ResolveI1 in 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 of access.preview accepts a descriptor of kind agent whose target action is agent.billing_mode.set if 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: map GrantCreate.resolve_i1 and GroupMemberAdd.resolve_i1 into the commands.
  • internal/identity: the port gets its own DTO ResolveI1 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/app converts it to authz.ResolveI1 and calls authz.I1Resolver before inserting the owner row. internal/identity/adapters/httpapi maps OwnerAdd.resolve_i1.
  • internal/chat: the port gets its own DTO ResolveI1 struct { AgentIDs []ids.ID; Reason string }. SetSpaceOperatorCmd and SetChannelOperatorCmd (M1-54) get ResolveI1 *chat.ResolveI1. The use cases in internal/chat/app convert it to authz.ResolveI1 and call the resolver after the no-op check of Design note 3 d. internal/chat/adapters/httpapi maps OperatorSet.resolve_i1.
  • internal/agents/app: SetBillingMode and SetBillingModeAll (M1-51) call the shared apply step for every switch to organization (Design note 4); the reason rules of Design note 2 on BillingModeSet, BillingModeSetAll and CredentialHolderSet where M1-51 lacks them; the representation of billing_mode_matches only if Design note 8 requires it.
  • internal/agents/adapters/authzsource: implement authz.AgentPolicySwitch by calling the use case of internal/agents/app/switch.go.
  • internal/wire: pass the agents implementation of AgentPolicySwitch into authz, and authz as I1Resolver into identity and chat; a wiring test asserts that the production graph uses the real switch, not EmptyAgentPolicySwitch.
  • AZ-P7 (M1-29): the guided-flow cases need the real identity, chat and agents modules, so they live in test/integration/guided_flow_test.go as TestAZ_P7_ResolveI1MakesChangeAdmissible next to the existing AZ-P7 table of internal/authz, named with the test ID in the form M1-29 established.
  • Client SDKs: if a client package under sdk/python, sdk/typescript or packages/api_client already has a generate-check target, regenerate it with the generator its README names so that make api-clients stays green; otherwise nothing.
  • Spec and docs: docs/spec/07-permissions.md 9.3 (one paragraph: preview with resolve_i1, Design note 9); docs/spec/10-backend-policy.md 11 (point 1 and the sequence diagram name the preview result invariant_violations of GrantPreview instead of i1_conflicts, the agent's name comes from getAgent; point 4 the check order, the 404/403 answers and one agent.billing_mode.set entry per listed agent; a sentence on the preview); docs/spec/02-architecture.md 4.3 (row authz: port I1Resolver (guided flow), declares AgentPolicySwitch; row agents: implements authz.AgentPolicySwitch); docs/security/threat-model.md T-AI-08 (test column: BR-07, BR-08); READMEs of authz, identity, chat and agents (English, like the existing files); services/server/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-21-1 and 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.start and 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.add and the operator actions: docs/api/openapi.yaml has 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 personal as 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 no resolve_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 of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (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 through sendHeartbeat, events through pollAgentEvents).
  • Time-dependent tests set short values for billing.mismatch_alert_after, agent.presence_timeout and agent.heartbeat_persist_interval through the configuration loader or use the kernel/clock test 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

  1. Module boundaries (02 4.1 rule 4, 4.3, 4.4). authz, identity and chat may not import agents, so the switch crosses through a source port that authz declares and agents implements, as AgentPolicySource does. authz owns the orchestration (validation, authorize of every listed agent, AddAffected, the call of the switch) because the rights logic of every action lives only in authz (05 rule 1); agents owns the write of its table, the event and the audit entry of agent.billing_mode.set because it executes that action (02 4.1 rule 6). identity and chat get authz.I1Resolver through their constructors; make arch stays green without a new edge. Port packages import only kernel, and domain packages import only kernel-core (02 5 and rule 1, R-AR-03). In .go-arch-lint.yml, generated by tools/archgen, the root components are identity: [identity, kernel-ports] and chat: [chat, kernel-ports], and authz-domain may depend only on itself. So only app and adapters of identity and chat import authz, and they convert the module's own ResolveI1 DTO into authz.ResolveI1. Until internal/wire binds the agents implementation, EmptyAgentPolicySwitch fails closed (02 4.4, last paragraph). Ruling for docs/DECISIONS.md.

  2. Request member and validation.

    • Schema ResolveI1 in docs/api/openapi.yaml: type: object, required: [switch_to_organization, reason], switch_to_organization: array of Uuid with minItems: 1 and uniqueItems: true, reason: string with minLength: 1; description "Guided flow (05 rule 1.6, 10 section 11): switch these agents to organization first, 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 for additionalProperties: false, while docs/api/openapi.yaml today 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): reason is trimmed and not blank, passes kernel/textcheck (SEC-IN-4) and has at most audit.reason_max_chars characters (SEC-AUD-5); otherwise 400 validation_failed with pointer /resolve_i1/reason and errors[].code required, the code M1-26 uses for a text check, or too_long. An empty list or a repeated ID gives 400 validation_failed with 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_i1 come together in one answer with all errors[] (06 5 point 3).
    • Apply the same reason rules to reason of BillingModeSet, BillingModeSetAll and CredentialHolderSet where M1-51 does not already (10 9.1 "reason is required and appears in the audit").
  3. 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 outer authorize exactly as M1-31, M1-48 and M1-54 do: 404 not_found, 403 forbidden (owner_only, step_up_not_possible, delegation reasons) and 401 step_up_required come 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, reason claim_managed_group, archived target), If-Match of the operator actions (428 precondition_required, 412 version_conflict), operator validation (M1-54 D1). d. Operator no-op: when setSpaceOperator or setChannelOperator would change nothing, answer as M1-54 D2 (200, no event, no version, no rights version; audit with no_change: true for 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 through setAgentBillingMode. Ruling for docs/DECISIONS.md. e. Inside the unit of work of the outer action (its RightsChange locked permission_version in 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 as 404 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 as authorize returns it (403 forbidden with reason owner_only for a visible agent and a non-owner, 401 step_up_required, 403 forbidden with reason step_up_not_possible for a personal token). The first failing agent decides; authz audits the denial as for every action with Audit yes (R-AZ-04).
    • AddAffected(ctx, agentIDs...) (M1-29), then AgentPolicySwitch.SwitchToOrganization (note 4). f. The outer write, its events and its allow audit entry, unchanged. g. Phase hooks: I1, SR8 and access.changed (M1-29), then sync and audit. An error before step e switches nothing; an error in e, f or g rolls back everything (BR-07).
  4. The switch (internal/agents):

    • SwitchToOrganization runs in the ambient transaction and calls no authorize; its doc comment says that only authz calls it, after authorizing every agent. It locks the agents rows FOR UPDATE in ascending principal_id (lock order of M1-51 for set_all; 03 6.3 point 2).
    • Per agent on personal: billing_mode = organization, credential_holder_id NULL, billing_mode_changed_at now (kernel/clock), billing_mode_changed_by the actor, version + 1; append agent.updated (resource {kind: agent, id, version}, empty scope, payload AgentPayload with the agent after the change, receive action agent.read); record one allow entry agent.billing_mode.set with 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 with no_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_all does in M1-51 (a disabled personal agent becomes relevant for I1 again at agent.enable, 10 5.1).
    • No If-Match for the listed agents, as for set_all (06 8). Ruling for docs/DECISIONS.md.
    • Shared apply step: the row change and the event of a switch to organization move into one function in internal/agents/app that SetBillingMode, SetBillingModeAll and SwitchToOrganization call; each caller keeps its own audit entry: one per agent for the single switch and the guided flow, one with the list of agents for set_all (10 14). M1-51's tests stay green unchanged. Ruling for docs/DECISIONS.md.
  5. I1 after the switch: AgentPolicySource reads inside the ambient transaction (M1-29), so the hook sees the switched agents as organization and 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 with 409 i1_violation whose conflicts name only the remaining agents (10 5.4, InvariantViolation), and nothing of the switch remains.

  6. Audit and correlation:

    • Success: one allow entry agent.billing_mode.set per listed agent, then the allow entry of the outer action with its unchanged details, all with the request's correlation_id from kernel/reqmeta, equal to the response header Correlation-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_violation with details.violations, or last_owner), with the same correlation_id.
    • A denied switch: the deny entry of agent.billing_mode.set written by authz; the outer action has no allow entry.
  7. Events: in the one transaction first agent.updated per changed agent, then the outer action's events (grant.changed, group.changed, user.updated, space.updated or channel.updated with the inheriting channels of M1-54 D4), then access.changed from the hook. One request raises the rights version once (one RightsChange); every envelope carries the same correlation_id. The agent receives its own agent.updated through its consumer (04 15.1 point 4, M2-07), which tells its adapter that the desired mode changed (10 9.2 point 5).

  8. Presence (M2-20). The tracker learns a desired mode changed by resolve_i1 with 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/txn has none (its phases end with the commit, 02 8.1), and under Idempotency-Key the 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_matches in getAgent and in the payload of agent.updated compares the last reported mode with the desired billing_mode read 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.

  9. Preview (previewGrant, body GrantCreate):

    • With resolve_i1 the preview is a dry run of the whole request (R-RE-10). Authorization: access.preview for the grant target exactly as M1-49; then for every listed agent in ascending ID access.preview with target action agent.billing_mode.set (05 4.2: right and owner rule of the target action, without step-up; docs/spec/actions.yaml owner: as_target): an owner passes without fresh step-up, a visible non-owner gets 403 forbidden with reason owner_only, an unknown or invisible agent 404 not_found as in note 3 e. access.preview never audits, so no deny entry is written.
    • In the dry-run transaction of M1-49: AddAffected and 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_violations and hints as after both parts. text gets one line per agent that the request would switch from personal, 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, when organization is not in the agent's billing_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 for de and en. Ruling for docs/DECISIONS.md.
    • Without resolve_i1 the preview is unchanged; invariant_violations[].organization_ready comes from billing_modes_supported that M2-20 now fills (10 11 point 1).
  10. Idempotency and concurrency:

    • createGrant, addGroupMember and addOwner keep Idempotency-Key (M1-20; 02 8.4): resolve_i1 is part of the stored body, so the same key with another list gives 422 idempotency_key_reuse, and a replay returns the stored answer with Idempotent-Replay: true and switches nothing again. The operator actions keep If-Match.
    • Rights changes serialize on permission_version (03 6.3), so a guided flow and a parallel setAgentBillingMode back to personal for 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 its agents row (M2-20) and waits for the switch to commit.
  11. 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
  1. Discovery: no new key in features (06 13 point 1: features names capabilities beyond the base contract of the spec version; resolve_i1 belongs to the spec version that declares it). Ruling for docs/DECISIONS.md.

  2. 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.updated after a switch reaches every principal with agent.read and the agent itself; access.changed appears only for principals whose visible targets change (M1-29 ruling), the rights version rises in any case.

  3. The reverse way (10 11 last paragraph): no new operation. The 409 i1_violation of a switch back to personal names the extra principals in conflicts, and explainChannelAccess on a served channel names their sources (grant, group, owner); the test TestReverseSwitchNamesBlockingSources proves the console can show both.

  4. Rulings for docs/DECISIONS.md section 10, one row each with date, decision, reason and place: D-M2-21-1 resolver port in authz and source port AgentPolicySwitch implemented by agents (note 1); D-M2-21-2 check order and the place of the switch before the first write (note 3); D-M2-21-3 operator no-op switches nothing (note 3 d); D-M2-21-4 agents already on organization and disabled agents, no If-Match (note 4); D-M2-21-5 shared apply step with audit per caller (note 4); D-M2-21-6 one audit entry per action, N plus one entries (note 6); D-M2-21-7 presence follows at the next heartbeat, billing_mode_matches at read time (note 8); D-M2-21-8 preview with resolve_i1 as a dry run of the whole request with switch lines in the text (note 9); D-M2-21-9 no discovery feature key (note 12). The rest is in 05, 07 and 10 and needs no ruling, including the 404 not_found for 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 exactly audit.reason_max_chars characters (accepted) and one more (too_long); every rejection names its pointer.
  • internal/authz/app (fake authorizer, fake switch, fake transaction state): TestResolveI1Order: authorize is called with agent.billing_mode.set once per agent in ascending ID; the first denial is returned unchanged and nothing after it runs; not_found is returned as 404 not_found and nothing after it runs; AddAffected is called with all IDs before the switch; the switch is called once with the sorted IDs and the trimmed reason; the preview variant calls authorize with access.preview and never asks for step-up. TestEmptyAgentPolicySwitchFailsClosed.
  • internal/agents/domain or app (fake repository): TestSwitchToOrganization: personal to organization clears the holder and sets changed-at and changed-by and the version; an agent on organization gives no change and no_change: true; a disabled personal agent is switched. TestSwitchPathsShareApplyStep: the single switch, set_all to organization and SwitchToOrganization write equal rows and equal agent.updated payloads for the same agent; the audit entries differ only as note 4 says.
  • internal/authz/domain/previewtext: TestPreviewTextSwitchLines: golden files in de and en for a preview with one switched agent that supports organization and 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 without resolve_i1 then with resolve_i1 = {switch_to_organization: [Assistent], reason}:
    • grant.create Sam trigger on Research/Notizen (AZ-M21 shape): 409 i1_violation, then 201;
    • group.member.add Sam into a group with trigger on Research (AZ-M25 shape): 409 i1_violation, then 201;
    • owner.add Alex (AZ-M23 shape): 409 i1_violation, then 201 and Alex is an owner;
    • channel.operator.set Assistent on Support/Allgemein where Alex holds G-1 (AZ-M24 shape): 409 i1_violation, then 200 with effective_agent_id Assistent;
    • space.operator.set Assistent as default operator of Support: 409 i1_violation, then 200. After each successful case Assistent is organization without credential_holder_id, the change exists, agent.updated for Assistent precedes the action's own event in event_log with the same correlation_id and permission_version, the allow entries of agent.billing_mode.set and of the action share that correlation_id, and VerifyInvariants of authz passes.
  • TestBR07_GuidedFlowCommitsBoth (BR-07): createGrant Sam trigger on Research/Notizen with resolve_i1 [Assistent]: 201; exactly two allow audit entries in this order, agent.billing_mode.set for Assistent (old personal, new organization, old holder Owner, the reason of resolve_i1) and grant.create with its M1-48 details; both carry the correlation_id equal to the response header Correlation-Id; event_log holds agent.updated (payload billing_mode: organization, no credential_holder_id) before grant.changed and then access.changed for Sam, all with that correlation_id and one new permission_version; payloads validate against their schemas and contain no null.
  • TestBR07_AbortInChangePartUndoesSwitch (BR-07, abort of a part): world variant without G-2 in which Texter is personal with holder Owner; createGrant Sam trigger on the instance with resolve_i1 [Assistent]: 409 i1_violation whose conflicts name Texter only; Assistent is still personal with holder Owner and unchanged version; no grant, no agent.updated, no allow entry, permission_version unchanged; exactly one deny entry grant.create with deny_reason = i1_violation and details.violations naming Texter.
  • TestBR07_AbortInSwitchPartLeavesChangeUndone (BR-07, abort of the other part): Alex with trigger and manage on Support (world variant as AZ-M38) creates a grant read for Sam on Support/Allgemein, which alone would succeed: with resolve_i1 [Lotse] the answer is 403 forbidden with reason owner_only; with resolve_i1 [Assistent] (invisible for Alex) 404 not_found, byte for byte equal in status, body and headers (apart from Correlation-Id) to the answer for a random UUID; in both cases no grant, no switch, no allow entry, and the denial of agent.billing_mode.set stands in the audit log after the recorder's aggregation flush (M1-24, audit.deny_aggregate_window).
  • TestResolveI1_OuterChecksComeFirst: with a resolve_i1 that would be valid: addGroupMember on a group the caller cannot see gives 404 not_found; setChannelOperator without If-Match gives 428 precondition_required, with an old one 412 version_conflict; addGroupMember for a person who already is a member gives 409 conflict; addOwner without fresh step-up gives 401 step_up_required, with the owner's personal token 403 forbidden with reason step_up_not_possible; Assistent stays personal in every case.
  • TestResolveI1_OperatorNoOpSwitchesNothing: setChannelOperator with the channel's current assignment and resolve_i1 [Assistent]: 200, no version change, Assistent still personal, one audit entry channel.operator.set with no_change: true and none for agent.billing_mode.set.
  • TestResolveI1_AlreadyOrganizationIsNoChange: resolve_i1 [Assistent, Lotse] on a grant that needs it: Lotse keeps its version and gets no agent.updated; its audit entry carries no_change: true; Assistent is switched; three allow entries share the correlation_id.
  • TestResolveI1_ReasonRules (SEC-T07 for this field): blank, bidi and over audit.reason_max_chars give 400 validation_failed with pointer /resolve_i1/reason; the same table on BillingModeSet, BillingModeSetAll and CredentialHolderSet gives the matching pointer /reason.
  • TestResolveI1_KnownOnlyInFourSchemas (SEC-IN-1, SEC-T06 shape): resolve_i1 in the bodies of setAgentBillingMode, revokeGrant (ReasonBody) and createGroup gives 400 validation_failed as an unknown member; in OwnerAdd, GroupMemberAdd, GrantCreate and OperatorSet it is accepted; a test over docs/api/openapi.yaml asserts that exactly these four schemas reference ResolveI1.
  • TestResolveI1_IdempotencyReplay: createGrant with resolve_i1 and an Idempotency-Key; the replay answers the stored 201 with Idempotent-Replay: true and writes no second agent.updated or audit entry; the same key with another list gives 422 idempotency_key_reuse.
  • TestResolveI1_ConcurrentSwitchBackSerializes (BR-03 shape): world variant with Assistent on organization and nobody but the Owner with trigger on Research; setAgentBillingMode Assistent to personal (holder Owner) and a guided createGrant (Sam trigger on Research/Notizen, resolve_i1 [Assistent]), each valid alone, start behind a barrier, in both orders: either the switch to personal commits 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 to personal gets 409 i1_violation; never do both end with Assistent personal and Sam's grant; no client sees a deadlock; VerifyInvariants passes afterwards.
  • TestBR08_SetAllPersonalWithViolatorChangesNothing (BR-08, all real sources): world variant with Assistent on organization; setAllAgentsBillingMode to personal with agent_ids [Assistent, Lotse] and holder Owner: 409 i1_violation with conflicts naming Lotse; both agents unchanged (mode, holder, version), no agent.updated, permission_version unchanged, one deny entry agent.billing_mode.set_all with deny_reason = i1_violation.
  • TestI4_SwitchReachesClientsAndAdapter (10 9.2): Assistent's adapter sends a heartbeat with reported_billing_mode: personal and billing_modes_supported [personal, organization] (M2-20); the owner switches Assistent to organization with setAgentBillingMode and a reason: agent.updated in event_log with receive action agent.read; Assistent's consumer gets it through pollAgentEvents; getAgent shows billing_mode: organization and presence.billing_mode_matches: false; after billing.mismatch_alert_after with a further heartbeat reporting personal the warning billing_mode_mismatch appears; a heartbeat reporting organization ends it and billing_mode_matches is true. In a world variant without G-2 where Texter is personal with holder Owner and then disabled, setAllAgentsBillingMode to organization without a list switches every agent not on organization, the disabled Texter included, with one audit entry listing the agents and one agent.updated per changed agent.
  • TestGuidedFlowAdapterStateAfterSwitch (10 11 point 5 and the sequence diagram): Assistent's adapter reports personal with both modes supported; then the request of TestBR07_GuidedFlowCommitsBoth runs in this test's own world; Assistent's consumer receives the agent.updated; the next heartbeat with personal answers HeartbeatAck.presence.billing_mode_matches: false, and once billing.mismatch_alert_after has passed getAgent carries the warning billing_mode_mismatch; a heartbeat with organization ends the difference and the warning.
  • TestGuidedFlowPreviewOrganizationReady (10 11 point 1): previewGrant Sam trigger on Research/Notizen without resolve_i1: allowed: false, one violation for Assistent with organization_ready: false before any heartbeat and true after a heartbeat with billing_modes_supported containing organization, made visible to AgentPolicySource the way M2-20's tests do.
  • TestPreviewGrantWithResolveI1 (note 9): the owner without fresh step-up previews the same grant with resolve_i1 [Assistent]: 200, allowed: true, no invariant_violations, an effect for Sam, the switch line in text; afterwards no event_log row, no audit row, permission_version unchanged and Assistent still personal; Alex with manage on Support previewing a grant there with resolve_i1 [Lotse] gets 403 forbidden with reason owner_only, with an unknown agent 404 not_found.
  • TestReverseSwitchNamesBlockingSources (10 11 last paragraph): after the guided flow, setAgentBillingMode Assistent to personal gives 409 i1_violation with principal_ids [Sam]; explainChannelAccess on Research/Notizen lists Sam with the source of the grant; after revokeGrant the switch to personal succeeds.
  • 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-clients
  • make check

Acceptance criteria

  • resolve_i1 is a member of OwnerAdd, GroupMemberAdd, GrantCreate and OperatorSet (schema ResolveI1), the generated server carries it, and every other body still rejects it with 400 validation_failed.
  • resolve_i1 on each of grant.create, group.member.add, owner.add, space.operator.set and channel.operator.set switches the listed agents first and then applies the change in one transaction; each action has a case that is rejected without and admitted with resolve_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_id as the response header and the events.
  • BR-08 with all real sources: set_all to personal with 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 gets 403 forbidden with reason owner_only; an unknown and an invisible agent give the same 404 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 reports organization.
  • The preview with resolve_i1 is a dry run of the whole request without step-up and leaves nothing behind; without it organization_ready reflects 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 check is 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, chat and agents updated (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.md section 10 (one row each with date, decision, reason and place).
  • services/server/CHANGELOG.md entry 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 check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN), 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; on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. 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.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven.
  5. 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>.