Skip to content

M2-40: Terminal rights, SR10, I6, SR12 and fresh step-up

Status: ready | Depends on: M1-49, M2-39 | Milestone: M2

Goal

After this ticket authorize decides the five terminal actions of 05 "Hosts and terminal" (terminal.open, terminal.attach, terminal.stream, terminal.read_own, terminal.close) instead of treating them as deferred entries. Only a human in a session reaches them; effective terminal comes from the owner rule or a grant on the host (07 "Hosts and the terminal capability"); SR10 keeps people who are not owners on the host assigned to them and leaves a server host to owners alone; SR11 lets only the holder of a personal LLM login open or attach (I6, also against other owners); SR12 demands an own session user whose name the host reports, also from owners; and step-up fresh demands a passkey or step-up device key confirmation not older than terminal.step_up_max_age that no other opening has consumed. The module identity records the consumption in auth_sessions.step_up_consumed_at under a lock on the session row inside the transaction of the action, so one confirmation opens exactly one session. I6 holds transactionally: a grant on a personal host to anybody but the holder, any group grant there, and any rights change that would leave such a grant valid roll back with 409 i6_violation; switching a host to personal or changing the holder of its LLM login ends every other person's session there in the same transaction, and so does a change of the assignee for the old assignee (SR10). The owner declares the LLM login with the new operation setHostLlmCredentials, the session users a host reports become part of the rights state (a change raises the rights version), and the preview of a grant on a host says when it would not take effect (hint terminal_not_effective). The routes that open and attach sessions come with M2-44; this ticket proves AZ-M54 to AZ-M61 (AZ-M60 with terminal.open itself), TM-T08, TM-T09, TM-T28 (a) and ID-T28 without the console part as decisions of authorize and through the identity and terminal use cases, which is the server side of T-TM-03, T-TM-08 and T-TM-09 (threat model 6.7), R-TM-05 to R-TM-07, R-TM-21 and R-TM-23.

Read first

  • docs/PLAN.md: section 1 "Principles", 3 "Way of working per ticket", 4 "Closing a milestone", 5 "Test strategy" (row "Security"); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part: rights with SR10 and I6), "Out of scope", "Exit criteria" items 8 and 10, "Test strategy", "Red team" (the terminal paragraph: opening without fresh or with consumed step-up, over group grants on a host with personal, by a second owner under I6, on the server host by people who are not owners, on foreign hosts despite a grant, without an own session user, by agent, app and personal tokens, sessions after every kind of revocation), "Dependencies", "Risks"; block "terminal" (normative sources and the order "Noise core, records, permissions, ...") with the rows M2-39 (what exists), M2-40 (this ticket, the contract), M2-41 (depends on this ticket), M2-43, M2-44, M2-46 and M2-48 (who builds what next); row M6-18 (the console part of ID-T28); section 19: P-04, P-09, P-10, P-11, P-12, P-20, P-24.
  • Dependencies: the header names M1-49 and M2-39. D-W-102 struck M1-35 (step-up with the device key) from the plan column of this ticket, because M2-39 implies it through M1-48 and M1-44; it stays in the read list: docs/briefs/M1-35-identity-step-up.md. Through the two dependencies this ticket also builds on M1-48, M1-44, M1-34, M1-33, M1-32, M1-31, M1-29, M1-28, M1-27, M1-26, M1-25, M1-24, M1-20, M1-17, M1-16 and M1-13 (all implied, so not in the header). M2-39 had no committed brief when this brief was written; its plan row and its brief draft gave the names in Scope. Read docs/briefs/M2-39-*.md first if it exists when you start.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.2 "Host service bothy-host" (the list "One session user per person and host, never shared", points 1, 2 and 5); 2.3 "Server host"; 3.1 "States"; 6.1 "Opening" (steps 1 to 3); 6.2 "Detach and reattach" (the step-up bullet); 6.3 "Limits"; 7.1 "Why", 7.2 "Declaration", 7.3 "Invariant I6" (the three check points); 8 "Permissions" (8.1 to 8.4); 10.1 "Audit" (row host.manage, "Never in the audit"); 10.3 "Reasons for ending" (access_revoked, i6_violation); 13 "Errors"; 14 "Tests" (TM-T07 to TM-T09, TM-T12, TM-T17, TM-T28 and the sentence after the table); 15 "Milestones"; 16 (R-TM-01, R-TM-04 to R-TM-07, R-TM-21, R-TM-23); 17 (terminal.step_up_max_age, terminal.quick_reattach_window).
  • docs/spec/07-permissions.md: 2.4 "Capabilities" (row terminal), 2.5 "Validity of a grant", 2.12 "Hosts and the terminal capability", 3 "Principal context", 4.1 "Decisions and reasons" (rows subject_kind_not_allowed, step_up_not_possible, terminal_not_host_assignee, terminal_server_host_owner_only, i6_holder_only, i6_violation, terminal_no_session_user, terminal_profile_unknown, terminal_session_limit, terminal_step_up_not_fresh, host_not_active), 4.2 "Flow" (steps 1, 3, 5, 6, 8 and 9; the paragraph on deferred entries), 4.3 "Visibility" (host and terminal session), 4.4 "Invariants in the transaction", SR10, SR11, SR12, 7.2 "Permission state (snapshot)", 7.3 "Validity of the cache", 8.2 "Immediate effect on revocation" point 7, 9.3 "Plain-text preview", 11.1 "Test matrix" (AZ-M54 to AZ-M61), 11.2 (AZ-P1, AZ-P3, AZ-P8, AZ-P13), 12 (R-RE-11).
  • docs/spec/08-identity.md: 6.1 "Two keys per native device", 6.2 "Step-up flow", 6.3 "Fresh step-up for the terminal", 6.4 "Command-line tools", 15 "Tests" (ID-T18, ID-T28), 16 (R-ID-16), 18 "Configuration values of this chapter".
  • docs/spec/03-data-model.md (core contract, read only): auth_sessions (step_up_at, step_up_method, step_up_consumed_at, client_component and the four table checks), grants (target host, the checks of R-DM-35, the index on target_host_id, the paragraph on permission_version that names llm_credentials, credential_holder_id, assigned_user_id, reported_session_users and host_session_users), hosts, host_session_users, terminal_sessions; 6.3 "Lock order"; 6.8 "I6 check"; 8 (R-DM-35, R-DM-37, R-DM-39 to R-DM-41).
  • docs/spec/05-actions.md (core contract, read only): rules 1.1, 1.6, 1.7 and 1.11; 2.1 (+terminal, assignee, host-self); 2.2 (rows host, terminal_session); 2.4 (Step-up fresh); 2.5; table 4.2 (access.preview); 4.3 (user.enable); 4.4 (grant.create); 4.14 "Hosts and terminal" (every row and the paragraph after the table); 6 (host.updated, terminal_session.changed); 8 (R-AZ-23, R-AZ-24).
  • docs/spec/10-backend-policy.md: 3 "Invariants" (I6 and the paragraph on Ruling T12), 14 "Audit trail".
  • docs/spec/04-sync-protocol.md: 8.2 "What happens on a change" (entries with kind: "host"), 8.3.
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rows authz with HostSource, identity, terminal), 4.4 "Allowed dependencies" (rows authz, identity, terminal), 8.1 "Flow" (phase hooks), 9.2 and 9.3.
  • docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "No null"), 5 "Errors" (points 3 and 7), 7 "Idempotency", 16 "Endpoints" (group "Hosts").
  • docs/spec/19-security.md: SEC-TERM-3, SEC-TERM-5, SEC-TERM-10, SEC-TERM-12, SEC-AUD-5, SEC-LOG-2.
  • docs/api/openapi.yaml: operations setHostLlmCredentials, getHost, updateHost, createGrant, previewGrant, enableUser, createStepUpChallenge, completeStepUpWithDeviceKey, and the descriptions of openTerminalSession and attachTerminalSession (built by M2-44); schemas HostLlmCredentialsSet, LlmCredentials, Host, HostSessionUser, HostReportedSessionUser, HostUpdatedEvent, HostUpdatedPayload, GrantCreate, Grant, GrantPreview, PreviewHint, AccessEffect, AccessTarget, AccessTargetKind, TargetKind, Capability, InvariantViolation, FieldError, Problem (x-problem-codes terminal_denied, i6_violation, step_up_required, host_unavailable, conflict, version_conflict, precondition_required; member conflicts), parameter IfMatchRequired. docs/api/asyncapi.yaml: message event.host.updated (nothing on the terminal channels).
  • ADRs: docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md (decision points 4 to 7, both addenda of 2026-10-11, "Confirmation"), 0020 (step-up), 0024 (I1 without owner exception), 0017, 0018, 0053, 0070 (terminal in the web console: what is not built here).
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1, T12 as D-W-57 and T13 as D-W-62 in section 9), D-T25 to D-T29, D-T33, D-T36, D-T40, D-L22, D-W-63, D-W-66, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows. No other D-W ruling names M2-40.
  • docs/security/threat-model.md: 5.16 "Terminal" (rows "Permissions" and "Device"), 6.7 "Terminal" (T-TM-03, T-TM-08, T-TM-09, T-TM-11). docs/RISKS.md: RK-22. docs/OPEN-QUESTIONS.md: OQ-03 and OQ-04 (default answers; they concern M2-45 and M2-46, not this ticket), OQ-24 (terminal only over a private network; default no; it changes M2-43 and M2-44, not this ticket).
  • docs/operations/configuration.md: terminal.step_up_max_age, terminal.quick_reattach_window, auth.step_up_max_age, audit.reason_max_chars, audit.deny_aggregate_window, host.os_user_max_chars. docs/operations/tailscale.md section 3 (only for the network point).
  • Briefs: M1-25 (ActionSpec.StepUpFresh), M1-28 (requirement table and atoms, deferred entries, visibility table, resolution, decision mapping, Resource, the fake sources and the random-world generator of AZ-P8), M1-29 (RightsChange, AddAffected, VerifyInvariants, InvariantError, DiffVisible, the deny audit entry), M1-24 (auditing.Recorder, KnownReasons), M1-34 (actor fields StepUpAt, StepUpMethod, ClientComponent, the Authenticator lookup, the migration of auth_sessions), M1-35 (port StepUp), M1-31 (user.enable), M1-44 (personal tokens), M1-48 (CreateGrant), M1-49 (PreviewGrant, domain/preview.go, domain/previewtext with catalog_de.go, catalog_en.go and the golden files), M1-13 (unit of work).
  • Code on the base branch: internal/authz (snapshot, cache, requirements, gate, guards, sources, VerifyInvariants, preview), internal/kernel/authorize, internal/kernel/actor, internal/identity (step-up, Authenticator, sessions, Directory, identitytest), internal/terminal with terminaltest (M2-39), internal/kernel/auditing, internal/kernel/eventlog, internal/kernel/txn, internal/kernel/clock, internal/kernel/errs, internal/kernel/textcheck, internal/platform/pg/pgtest, internal/wire (configkeys_*.go, configchapters.go), api/operations.yaml, architecture/modules.yaml, migrations/.
  • docs/quality/gates.md, docs/quality/red-team-gate.md, docs/quality/push-discipline.md, CONTRIBUTING.md.

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. The five terminal actions of 05 4.14 become real catalogue requirements and leave the deferred list of M1-28 (07 4.2, AZ-P3): terminal.open (human and +terminal, resource host, Step-up fresh, Audit yes, rl.terminal), terminal.attach (self and +terminal, resource terminal_session, Step-up fresh, Audit yes, rl.terminal), terminal.stream (self, Audit denials, rl.terminal), terminal.read_own (self or owner, resources terminal_session and host, rl.read), terminal.close (self or owner, Audit yes, rl.write).
  2. Agents, apps and personal tokens reach none of these actions: 403 forbidden with reason subject_kind_not_allowed, audited, the same answer for every host or session id (05 4.14 paragraph after the table, R-AZ-23, AZ-M59, TM-T17, T-TM-09).
  3. Effective terminal(P, H) (07 2.12 point 3): P is an active owner in a session and not (H.llm_credentials = personal and P is not H.credential_holder_id); or a valid grant (07 2.5) with target H and terminal names P or one of P's valid groups. A host lies outside the tree; an instance grant gives no terminal, and a host that is not visible gives not_found (07 2.12 point 1, R-AZ-24, AZ-M60).
  4. SR10: a person who is not an active owner gets terminal.open and terminal.attach only on a host assigned to them (hosts.assigned_user_id), else terminal_not_host_assignee; on a server host (hosts.declared_server_host or hosts.reported_is_server_host) only owners, else terminal_server_host_owner_only; when the assignee changes, the sessions that SR10 no longer allows end in the same transaction with access_revoked (07 SR10, D-T1, TM-T08, AZ-M55, SEC-TERM-10).
  5. SR11 (I6): on a host with llm_credentials = personal only credential_holder_id opens or attaches, no other owner either (i6_holder_only) (07 SR11, D-T2, AZ-M56, TM-T09).
  6. SR12: terminal.open and terminal.attach only with a valid assignment of the person to a session user of the host (host_session_users, revoked_at IS NULL) whose os_user is in hosts.reported_session_users, owners included, else terminal_no_session_user. Assignments and reported names are part of the rights state, and a change of the report raises the rights version (07 SR12, 03 grants paragraph on permission_version, D-W-63, AZ-M61, TM-T28 (a)).
  7. Step-up fresh (05 2.4, 07 4.2 step 8, 08 6.3 points 1, 2, 4 and 5): step_up_at not older than terminal.step_up_max_age, step_up_method passkey or device_key, not consumed (step_up_consumed_at IS NULL OR step_up_consumed_at < step_up_at); the action consumes it in its own transaction under the lock of the session row, so one confirmation opens exactly one session; other step-up actions neither consume nor check the consumption; terminal.attach from the same device in the same login session within terminal.quick_reattach_window after the detachment needs no step-up, never in the console; otherwise 401 step_up_required with audit reason terminal_step_up_not_fresh (AZ-M58, ID-T28, R-ID-16, R-TM-07, SEC-TERM-3).
  8. I6 transactionally (03 6.8, 24 7.3 point 1, 10 "Invariants"): after grant.create with target host and after host.manage (declaration, holder, confirmation, state) no valid grant on a host with llm_credentials = personal names another subject than the user credential_holder_id; group grants there are always rejected; owners do not count. A violation rolls back with 409 i6_violation (decision invariant_violation, hint) and one deny audit entry in its own transaction (AZ-M57, TM-T09).
  9. Switching a host to personal or changing the holder of its LLM login ends every non-closed session of another person on the host in the same transaction with close_reason = 'i6_violation' (24 7.3 point 3, 03 6.8, 07 8.2 point 7).
  10. setHostLlmCredentials (PUT /api/v1/hosts/{host_id}/llm-credentials, action host.manage: owner, Step-up yes, Audit yes, Permissions yes, rl.admin, If-Match required): declaration none, personal or organization, credential_holder_id exactly with personal, a mandatory reason; audit details after 24 10.1 (operation llm_credentials, old and new values as codes, reason), event host.updated, rights version (24 7.2, 05 4.14).
  11. access.preview of a grant on a host (previewGrant): hint terminal_not_effective when SR10 or SR12 keep the grant from taking effect, an I6 violation as an entry of invariant_violations with allowed = false, effects with host entries, plain text from the template catalogue (07 2.12 point 4, 07 9.3, D-T1).
  12. Decision to problem (07 4.1, 24 13): terminal_not_host_assignee, terminal_server_host_owner_only, i6_holder_only, terminal_no_session_user, terminal_profile_unknown and terminal_session_limit give 403 terminal_denied with reason not_host_assignee, server_host_owner_only, i6_holder_only, no_session_user, profile_unknown and session_limit; terminal_step_up_not_fresh gives 401 step_up_required; host_not_active gives 409 host_unavailable; i6_violation gives 409 i6_violation with hint and conflicts. audit_events.deny_reason keeps the code of 07 4.1.
  13. No JSON null (ADR 0053); audit details, events and problem bodies of this ticket carry IDs and codes and the mandatory reason of host.manage only, never terminal content, host or session-user names, tokens, challenge values or signatures (24 10.1 "Never in the audit", SEC-LOG-2, SEC-AUD-5).

Terminal security requirements (D-W-101):

  1. Rights per machine: the additional right terminal on the target host, SR10, SR11 with I6 and SR12 (07 "Hosts and the terminal capability"). This ticket enforces all of them in authorize and the I6 check in the rights-change hook: the requirement +terminal behind the subject gate, SR10 with the server host and with the end of sessions when the assignee changes, SR11, SR12 with the reported names in the rights state, and the transactional I6 check with the end of foreign sessions. Proved by TestAZ_M54_* to TestAZ_M61_*, TestTMT08_*, TestTMT09_*, TestTMT28a_*, TestSR10_AssigneeChangeEndsSessions, TestSR12_ReportDropsName and TestI6_*. M2-39 owns the target host itself, host.read, host.manage, the subject gate, the assignments and the host.read part of AZ-M60; M2-46 ends sessions after every other kind of revocation (TM-T10).
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). This ticket enforces the rule in authorize and the consumption in identity (StepUp.ConsumeFresh); a console session fails closed until M6-18 adds the passkey rule. Proved by TestFreshStepUp, TestQuickReattach, TestAZ_M58_* and TestIDT28_* (ID-T28 without the console part). M1-35 built the step-up with the device key; M2-44 calls ConsumeFresh in the transactions of openTerminalSession and attachTerminalSession and proves TM-T07 and TM-T12 over HTTP; M6-18 owns the console part of ID-T28, TM-T07 and TM-T12 (reason: passkey_required); M4-36 and M5-12 use the step-up key in the apps.
  3. Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile tailscale reaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03, docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket adds no listener, no connection and no network code; its only new route setHostLlmCredentials sits behind the authenticated HTTP chain like every API route (TestAZP1_TerminalAuthzRoutes: the route is registered with its action and class, and this ticket enables no terminal stream or host connection route). M2-43 builds the outbound control connection and M2-44 the data connection and the relay; an answer "yes" to OQ-24 changes those two tickets, not this one.
  4. Audit without content: the entries this ticket writes or causes (denials of the terminal actions by authorize, host.manage for the declaration with no_change where it applies, the deny entry of i6_violation, terminal.terminate through EndSessions of M2-39) carry IDs, codes and the mandatory reason only (TestTerminalAuthzAudit_NoContent). M2-46 owns the audit and metrics of sessions without content (TM-T06), M2-44 the audit of terminal.open and terminal.attach with pin_changed and foreign_assignee.
  5. Idle timeout and maximum duration (24 "Limits"): not enforced by this ticket. M2-45 measures idle on the host, M2-46 adds terminal.idle_timeout plus terminal.idle_grace and terminal.max_session_duration in the server and the job terminal.sweep (TM-T11). The quick reattach of this ticket applies only to a detached session within terminal.quick_reattach_window and extends no limit (TestQuickReattach covers that only detached qualifies).
  6. Service user and session users without administrator rights (T12, HD rules): this ticket enforces the server side of T12: no terminal without an own session user that the host reports, also for owners (SR12), and the report is rights state (TestAZ_M61_*, TestTMT28a_*, TestSR12_ReportDropsName). M2-39 keeps a session user with one person forever (409 conflict with reason: session_user_taken), M2-42 builds install and doctor with HD1 to HD11 (TM-T13, TM-T22, TM-T24), M2-45 runs every PTY in the helper of the session user named by osu.
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-03 (opening through a stolen session, a foreign host or the server host) by step-up fresh per opening and SR10: TestAZ_M54_*, TestAZ_M55_*, TestAZ_M58_*, TestAZ_M60_*, TestTMT08_*, TestIDT28_ConcurrentOpeningsOneConfirmation (the host.read part of AZ-M60 in M2-39, TM-T07 in M2-44); T-TM-08 (a foreign LLM login through the terminal) by I6 and SR12: TestAZ_M56_*, TestAZ_M57_*, TestAZ_M61_*, TestTMT09_*, TestTMT28a_*, TestI6_UserEnableRevivesGrant (TM-T27 and TM-T29 in M2-42 and M2-45); T-TM-09 (prompt injection opens a terminal) by the subject gate: TestAZ_M59_*, TestSubjectGate_TerminalActions (TM-T17 over HTTP in M2-48); T-TM-11 (sessions after a revocation) for the three revocations built here: TestI6_HolderChangeEndsForeignSessions, TestSR10_AssigneeChangeEndsSessions and TestSR12_ReportDropsName (every other kind in M2-46). Not touched here: T-TM-01 and T-TM-02 (M2-38, M2-44, M2-45), T-TM-04, T-TM-05 and T-TM-12 to T-TM-14 (M2-42, M2-43, M2-45), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-10 (M4-35).

Scope

All paths under services/server/ unless noted. Words: the assignee is hosts.assigned_user_id (07 "host assignee"; M2-39 calls it the holder of the host); the holder is hosts.credential_holder_id, the holder of a personal LLM login. The specification is in place from planning (07 2.12 and 4.1, 08 6.3, 24 7 and 8, setHostLlmCredentials, PreviewHint.code terminal_not_effective, the problem codes); change it only where Design notes say so or where the implementation proves it wrong.

What this ticket expects from M2-39 (expected names; where the merged code names them differently, follow the code and list the difference, see Boundaries): the module internal/terminal with the port Hosts (List, Get, Create, Update, SetState, DTOs and HostView), domain (NewOSUser, CloseReason, the Go mirror of host_reported_session_users.v1 in reported.go), app (outgoing ports Store and People, view.go, hostsource.go, and EndSessions(ctx context.Context, sel SessionSelector, reason domain.CloseReason) (int, error) with SessionSelector{HostID, UserID ids.ID}, which runs only inside the caller's unit of work and writes terminal.terminate), adapters/postgres, adapters/httpapi and the test fixtures terminaltest; the tables hosts, host_session_users, terminal_sessions and the foreign key grants.target_host_id; in internal/authz the source HostSource (Host, HostIDs, AssignedTo) with HostFacts{ID, State, AssignedUserID, LLMCredentials, CredentialHolderID, DeclaredServerHost, ReportedIsServerHost}, the snapshot map Hosts, the effective function TerminalOn, the atoms HumanSession, Assignee and Terminal, the gate before resolution for requirements of the form All(HumanSession, ...), host entries in DiffVisible, the affected principals of a host target, createGrant with target host; the exact comparison of a name with hosts.reported_session_users behind session_user_not_on_host; the errors[].code unknown_user; the writer of host.updated. Add whatever of this is missing and is needed below, in the module that owns it, and list it in the report.

Create:

  • internal/authz/domain/terminal.go: pure functions FreshStepUp(f FreshFacts, now time.Time, maxAge time.Duration) bool, QuickReattach(f ReattachFacts, now time.Time, window time.Duration) bool, TerminalGuard(f TerminalGuardFacts) (Reason, bool) (Reason as in domain/reasons.go) and TerminalEffective(s Snapshot, hostID ids.ID) bool (Design notes 4 to 7); the fact types FreshFacts{StepUpAt, StepUpConsumedAt time.Time; StepUpMethod, ClientComponent string}, ReattachFacts{Action authorize.Action; PrincipalDeviceID, PrincipalSessionID ids.ID; ClientComponent string; Session authorize.TerminalFacts}, TerminalGuardFacts{PrincipalID ids.ID; Owner bool; Host HostFacts; SessionUser *SessionUserFact}. Use the fact types where M2-39 put them; domain never imports app or an adapter.
  • internal/authz/domain/i6.go: pure CheckI6(host HostFacts, grants []HostGrant, members map[ids.ID][]ids.ID) (I6Conflict, bool) with HostGrant{SubjectKind string; SubjectID ids.ID} for the grants already filtered to the valid ones (Design note 9).
  • internal/authz/domain/previewtext/testdata/example_host_de.golden and example_host_en.golden (Design note 13).
  • internal/terminal/domain/llm.go: Declaration{Credentials string; HolderID ids.ID}, pure ValidateDeclaration(d Declaration) []FieldError (the field error type that M2-39's domain uses for 400 validation_failed) and EndsForeignSessions(before, after Declaration) bool (Design note 10).
  • internal/terminal/domain/report_change.go: pure SameReport(a, b []ReportedSessionUser) bool and VanishedNames(before, after []ReportedSessionUser) []string over M2-39's mirror type (Design note 8).
  • internal/terminal/app/llm_credentials.go: use case SetLlmCredentials and the in-transaction function ApplyLlmDeclaration that M2-41 reuses for confirmHost (Design note 10).
  • internal/terminal/app/report.go: use case RecordSessionUserReport and the in-transaction function ApplySessionUserReport that M2-41 (pairHost) and M2-43 (report) call (Design note 8).
  • internal/terminal/adapters/httpapi/llm_credentials.go: strict-server method setHostLlmCredentials.
  • internal/terminal/adapters/postgres/queries/llm_credentials.sql and report.sql: update of the declaration with version check, update of reported_session_users, the valid assignments of one person with the reported names of their hosts, the hosts with llm_credentials = 'personal'; regenerate sqlc.
  • internal/identity/app/stepup_fresh.go and the queries LockSessionForStepUpConsumption and ConsumeStepUp in internal/identity/adapters/postgres/queries/stepup.sql (Design note 4).
  • Migration migrations/<next free number>_identity_step_up_consumed.sql only if the merged migration of M1-34 lacks auth_sessions.step_up_consumed_at or the check step_up_consumed_at IS NULL OR step_up_at IS NOT NULL; it adds exactly what is missing, as 03 has it.

Change:

  • internal/kernel/actor: field StepUpConsumedAt time.Time on the request principal (zero: never consumed), with a doc comment; the Authenticator of M1-34 reads auth_sessions.step_up_consumed_at in its one lookup query.
  • internal/kernel/authorize: Resource.HostID ids.ID (path hint of a terminal_session: its host), Resource.Terminal *TerminalFacts with TerminalFacts{SessionState string; SessionDeviceID, SessionAuthSessionID ids.ID; DetachedAt time.Time} (required for terminal.attach, Design note 4), and Decision.QuickReattach bool (true exactly when the step-up rule of a fresh action was waived by the quick reattach).
  • internal/authz: the requirement entries of the five actions instead of Deferred and the two rows of the "Permission" text mapping (Design note 1), visibility and resolution of terminal_session (note 3), the fresh branch of step 8 (note 4), the guard of terminal.open and terminal.attach (note 5), the snapshot fields SessionUsers and, for effective owners, PersonalHosts (notes 6 and 7), TerminalEffective in DiffVisible and in preview effects (note 7), I6 in VerifyInvariants and InvariantError (note 9), the decision mapping rows (note 12), the host part of PreviewGrant and the catalogue keys in domain/previewtext/catalog_de.go and catalog_en.go (note 13), the golden list of deferred actions without the five ids. internal/authz/sources.go: HostSource gains PersonalHosts(ctx context.Context) ([]HostFacts, error) (hosts with llm_credentials = personal, ascending id) and SessionUsersOf(ctx context.Context, userID ids.ID) ([]SessionUserFact, error) with SessionUserFact{HostID, HostSessionUserID ids.ID; OSUser string; Reported bool}; HostFacts gains Name string (for the preview text). The grant store gains ActiveGrantsOnHost(ctx context.Context, hostID ids.ID) ([]domain.Grant, error) (index target_host_id) if missing. Update the fake HostSource in internal/authz/adapters/fake and the shared contract test of the source port. The port (root package) gains the DTO FreshStepUpFacts{StepUpAt, StepUpConsumedAt time.Time; StepUpMethod, ClientComponent string} and the interface FreshStepUpCheck{ Fresh(f FreshStepUpFacts, now time.Time) bool }. authz/app implements it by mapping to domain.FreshFacts and calling domain.FreshStepUp with terminal.step_up_max_age. identity never imports internal/authz/domain (02 4.1 rule 2).
  • internal/identity: port StepUp gains ConsumeFresh(ctx context.Context, a actor.Principal) error (actor.Principal stands for the request principal type of kernel/actor); the StepUp use case receives an authz.FreshStepUpCheck from internal/wire.
  • internal/terminal: port Hosts gains SetLlmCredentials(ctx context.Context, p actor.Principal, cmd SetLlmCredentials) (HostView, error) with SetLlmCredentials{HostID ids.ID; IfMatch int64; LLMCredentials string; CredentialHolderID ids.ID; Reason string}; app.EndSessions gains SessionSelector.ExceptUserID ids.ID (zero: nobody excepted); the outgoing port People gains IsActiveOwner(ctx context.Context, userID ids.ID) (bool, error), bound in internal/wire to identity.Directory.User (true exactly when UserView.IsOwner and Status = active; it calls no authorize), like M2-39's binding of People.User; the use case Update of M2-39 ends sessions on a change of the assignee (Design note 11); hostsource.go implements the two new HostSource methods and Name.
  • internal/wire: configuration keys terminal.step_up_max_age and terminal.quick_reattach_window (durations, defaults from 24 17, chapter constant of 24 in configchapters.go if missing) passed to authz; internal/wire passes the authz FreshStepUpCheck to identity; the binding of People.IsActiveOwner to identity.Directory.
  • api/operations.yaml: move setHostLlmCredentials from later to the enabled list; rerun the route generator of M1-17.
  • docs/api/openapi.yaml (spec first, English): description of setHostLlmCredentials (holder rules, 409 conflict for a retired host, an unchanged declaration answers 200 without a new version, 409 i6_violation with conflicts); description of HostLlmCredentialsSet.credential_holder_id (errors[].code required when missing with personal, not_allowed when sent with none or organization, unknown_user when it names no human or a deleted one); Problem.conflicts "present with i1_violation (one entry per agent) and i6_violation (one entry per host)"; InvariantViolation.credential_holder_id and principal_ids for I6 (Design note 9); PreviewHint description for terminal_not_effective (principal_id for a user subject, group_id and count for a group subject). Then make api-lint, make api-checks, make api-tables and the server code generation.
  • docs/spec/07-permissions.md (English, like the chapter): 4.2 the order of SR10, SR11 and SR12 in step 9 and the owner rule of the requirement +terminal (Design notes 1 and 5); SR10 the end of sessions on a change of the assignee (note 11); 9.3 the preview of a grant on a host with the German example of Design note 13.
  • docs/spec/08-identity.md 6.3 point 4: the consumption checks the locked row again and is the first row lock of the action's transaction (Design note 4).
  • docs/spec/24-terminal.md 7.3 "Enforcement points" point 1: I6 is verified with every rights change, which covers user.enable of a person whose dormant grant would become valid again (Design note 9); 7.2 the holder rules and the retired rule (Design note 10); 13 row validation_failed: the errors[].code not_allowed (and unknown_user if M2-39 has not added it there).
  • docs/operations/configuration.md: regenerate with make config-reference if the schema text changes.
  • READMEs of internal/authz, internal/identity and internal/terminal; services/server/CHANGELOG.md under Unreleased; docs/DECISIONS.md section 10 rows D-M2-40-<n> for every Ruling below.

This ticket creates no new top-level make target and a migration only under the condition above.

Out of scope

  • The target host, the tables, host.read, host.manage, the subject gate, listHosts, createHost, getHost, updateHost (except Design note 11), setHostState, the assignments of session users with 409 conflict and session_user_not_on_host, EndSessions, the transitions of 24 3.1 and the host.read part of AZ-M60 (M2-39).
  • Pairing, issueHostPairingCode, pairHost, confirmHost and the entry host.pair (M2-41; confirmHost reuses ApplyLlmDeclaration and the I6 check of this ticket, pairHost calls ApplySessionUserReport).
  • The control connection, report, presence and connected, the entry host.connect (M2-43; its report calls RecordSessionUserReport).
  • openTerminalSession, attachTerminalSession, listTerminalSessions, getTerminalSession, closeTerminalSession, the session token, the call of ConsumeFresh, the audit of terminal.open and terminal.attach (pin_changed, foreign_assignee), the check that a stream comes from the device of the current token, the guards host_not_active (state active and connected) and terminal_profile_unknown (profile and working directory), the event terminal_session.changed, TM-T07 and TM-T12 over HTTP (M2-44).
  • terminal_session_limit; ending sessions after every other kind of revocation, among them a change of the owner status or of reported_is_server_host that SR10 no longer allows (subscribers of authorize.AccessChanged, identity.DeviceRevoked, identity.SessionEnded); metrics; TM-T06, TM-T10, TM-T11 (M2-46); TM-T17 in the suite (M2-48).
  • The console: passkey only with userVerification = required, reason: passkey_required, the console part of ID-T28, TM-T07 and TM-T12 (M6-18); console sessions (M6-01).
  • The branch of SR10 for an own ready dev environment (hosts.dev_environment_id, V2); until then it is false.
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-40-terminal-authz; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(authz): ..., feat(identity): ..., feat(terminal): ..., docs(spec): ...) 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 and generated references.
  • 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.
  • A new migration needs its line in migrations.sha256: run make migrations-lock after creating the file and before make check; never edit a merged migration (M1-01, ADR 0007).
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for M2-39: its merged names win over the expected names here; list each difference. If the base branch lacks the tables hosts, host_session_users or terminal_sessions, or authz.HostSource, M2-39 is not merged: stop with Verdict: BLOCKED <reason>.
  • No host service, no relay, no real device and no Noise handshake in the tests; sessions in the tests are rows written through terminaltest.
  • Time only through kernel/clock; tests move a fake clock and never sleep for a window.
  • Step-up challenge values, signatures, access tokens and API tokens never appear in logs, audit details, problem bodies, events or test failure messages.

Design notes

  1. Entries and atoms (05 4.14, 07 4.2, M1-28 requirement table, M2-39 atoms). Mapping rows for the two new texts of 05: human and +terminal is All(Human, Terminal), self and +terminal is All(Self, Terminal); the source test of M1-28 compares them with 05. Overrides by action id (they win over the text rule and put every action behind the gate of M2-39, which evaluates HumanSession before resolution and answers forbidden with subject_kind_not_allowed for agents, apps and personal tokens):

    • terminal.open: All(HumanSession, Terminal) on host.
    • terminal.attach: All(HumanSession, Self, Terminal) on terminal_session; Terminal is evaluated on the session's host.
    • terminal.stream: All(HumanSession, Self); terminal.close: All(HumanSession, Any(Self, Owner)).
    • terminal.read_own: All(HumanSession, ByKind{terminal_session: Any(Self, Owner), host: Auth}). Ruling for docs/DECISIONS.md: on a host, "self or owner" means "own sessions on a host you can see, owners all"; the list query of M2-44 restricts the rows to the caller's user_id for people who are not owners, so on the host only visibility counts.
    • Terminal stays M2-39's TerminalOn (an effective owner, or a grant terminal in effect); its owner part ignores I6, and SR11 (Design note 5) denies an owner who is not the holder with i6_holder_only (AZ-M56), as M2-39 ruled. If the base branch lacks the gate, add it as M2-39 describes (after 07 4.2 step 1, before snapshot, resolution and visibility).
    • Remove the five ids from the golden list of deferred actions; host.pair and host.connect stay deferred for M2-41 and M2-43. VerifyRegistrations then accepts routes with the five actions once M2-44 enables them (AZ-P1).
  2. Personal tokens. A personal token is a human without a session; the gate refuses it with subject_kind_not_allowed, so the step-up branch never answers step_up_not_possible for the five actions (05 4.14 paragraph after the table).

  3. Resolution and visibility (07 4.3, M1-28 resolution). A host resource resolves through HostSource.Host, fresh per call (reported_is_server_host changes without a new rights version, 03 grants paragraph). A terminal_session resource comes from terminal as Resource{Kind: terminal_session, ID, HostID: terminal_sessions.host_id, OwnerID: terminal_sessions.user_id, Terminal: &TerminalFacts{...}}; authz resolves the host through HostID (zero HostID is an internal error, an unknown host not_found with no_grant) and treats the session as visible when OwnerID is the principal or the principal is an effective owner, else not_found with not_self (the principal-owned kinds of M1-28). Replace the internal error of the visibility row terminal_session.

  4. Step-up fresh (05 2.4, 07 4.2 step 8, 08 6.3). When ActionSpec.StepUpFresh is set, step 8 evaluates only this branch, not the auth.step_up_max_age branch:

    • QuickReattach first: action terminal.attach, ClientComponent not console, Session.SessionState = detached, Session.SessionDeviceID equal to the principal's DeviceID, Session.SessionAuthSessionID equal to the principal's SessionID, and now - DetachedAt not greater than terminal.quick_reattach_window (equal is allowed, one nanosecond more is not). When it holds, step 8 passes without step-up and the decision carries QuickReattach = true; the use case then consumes nothing.
    • FreshStepUp otherwise: StepUpAt set and now - StepUpAt not greater than terminal.step_up_max_age (equal allowed), StepUpMethod passkey or device_key, StepUpConsumedAt zero or before StepUpAt, and ClientComponent not console. Failing any of them gives decision step_up_required with reason terminal_step_up_not_fresh. Ruling for docs/DECISIONS.md: a console session fails closed until M6-18 adds the passkey rule with userVerification = required (08 6.3 point 3); console sessions do not exist before M6-01.
    • identity.StepUp.ConsumeFresh(ctx, p) joins the ambient unit of work (none is an internal error), locks the session row with SELECT ... FOR UPDATE (id and user_id of the principal), answers 401 session_revoked for a revoked or expired session, re-evaluates freshness on the locked row's values through authz.FreshStepUpCheck (the same domain.FreshStepUp that step 8 uses) with the clock of the call, and on success sets step_up_consumed_at = now. On failure it returns the same error that Decision.Err gives for decision step_up_required with reason terminal_step_up_not_fresh (401 step_up_required without reason in the problem body), and the audit reason stays available to the caller. It writes no event and no audit entry; the use case of the action (M2-44) records the opening and the denial. Ruling for docs/DECISIONS.md: the check runs again on the locked row, not on the actor of the request, because two requests with one confirmation both pass authorize and only the lock orders them; ConsumeFresh is the first domain row lock of the action's transaction, so 03 6.3 holds and the second opening sees the consumption. Update 08 6.3 point 4.
    • Other step-up actions keep the auth.step_up_max_age branch and never read step_up_consumed_at (08 6.3 last paragraph).
  5. Guards SR10, SR11, SR12 (07 4.2 step 9; 24 6.1 step 2) for terminal.open and terminal.attach, in this order, first failure wins:

    • SR10 server host: the principal is not an effective owner and the host has DeclaredServerHost or ReportedIsServerHost: terminal_server_host_owner_only.
    • SR10 assignee: the principal is not an effective owner and AssignedUserID is not the principal (the branch of an own ready dev environment is false until V2): terminal_not_host_assignee.
    • SR11: LLMCredentials = personal and the principal is not CredentialHolderID, owners included: i6_holder_only.
    • SR12: no SessionUserFact of the principal for this host in the snapshot, or one with Reported = false, owners included: terminal_no_session_user.
    • Extension point after SR12, documented, no empty stubs: M2-44 adds host_not_active and terminal_profile_unknown, M2-46 terminal_session_limit (order of 24 6.1 step 2).

    Ruling for docs/DECISIONS.md: SR10 before SR11 before SR12, the order of 07 section 5 and 24 6.1 step 2, and within SR10 the server host first, because a server host is closed to every person who is not an owner, assigned or not, so its reason is the one that tells the person what would help. The guards run after step 8 as 07 4.2 orders them (TestTerminalDecisionOrder): a person without a fresh step-up learns step_up_required first.

  6. Rights state per person: session users (07 SR12, PLAN row). The snapshot of a human principal gains SessionUsers map[ids.ID]SessionUserFact (key: host id) from HostSource.SessionUsersOf, which returns the valid assignments (revoked_at IS NULL) with Reported computed by the exact comparison of M2-39 (os_user equal to a reported name, case included; 24 5.3 compares osu exactly). Every change of host_session_users (M2-39) and of reported_session_users (Design note 8) raises the rights version, so the cache of 07 7.3 stays correct without a new key.

  7. Effective terminal in the diff and in the preview (07 2.12 point 3, 04 8.2). TerminalEffective(s, h) is TerminalOn(s, h) without the owner part when s.PersonalHosts[h] names another holder than the principal; PersonalHosts map[ids.ID]ids.ID (host to holder) is filled for effective owners from HostSource.PersonalHosts. DiffVisible compares the host sets through TerminalEffective instead of TerminalOn, so a switch to personal gives removed: [{kind: host, id}] for every other owner and a switch back gives added with capabilities: [terminal]; preview effects use the same diff. Ruling for docs/DECISIONS.md: the I6 clause of 07 2.12 point 3 is part of the diff because it depends on the declaration, which raises the rights version (03); SR10 and SR12 are guards, not part of effective terminal, and never appear in the diff, because 04 8.2 reports the additional right itself and SR10 depends on reported_is_server_host, which raises no rights version. M2-39's ruling that a state or assignee change writes no access.changed stays true.

  8. The report as rights state (03 grants paragraph, 07 SR12, 24 2.2 point 5). RecordSessionUserReport(ctx, cause authorize.Action, hostID ids.ID, report []domain.ReportedSessionUser) (ReportOutcome, error) with ReportOutcome{Changed bool; EndedSessions int}:

    • Validate each entry with M2-39's decoder of host_reported_session_users.v1 and NewOSUser (pattern, at most host.os_user_max_chars; display names with textcheck); an invalid report writes nothing and returns a validation error to the caller.
    • Compare with the stored value through SameReport (equal when the set of os_user names and each name's ordered workdirs are equal; the order of the names does not matter). Equal: return Changed = false, no transaction, no rights version. Ruling for docs/DECISIONS.md: a host repeats its report after every reconnect, and a version bump per reconnect would empty every rights cache.
    • Different: one unit of work with RightsChange{Action: cause, Resource: host, Targets: [host], Principals: people with a valid assignment on the host}, then ApplySessionUserReport: lock the host row FOR UPDATE, compare again, write reported_session_users, call EndSessions(SessionSelector{HostID, UserID: person}, access_revoked) for every person whose assigned name is in VanishedNames (07 SR12: "and so does a report of the host that no longer lists that name"), and write host.updated. No host.manage audit entry here: host.pair (M2-41) and host.connect (M2-43) audit their own calls.
  9. I6 in VerifyInvariants (03 6.8, 24 7.3, 07 4.4). In phase 3.1 of every rights change, for every host from HostSource.PersonalHosts, read in the ambient transaction: load the grants on the host (ActiveGrantsOnHost), keep those valid at t (07 2.5: not revoked, expires_at after t, subject active, group not deleted), and call CheckI6. A valid grant with subject_kind = group is always a violation, members or not; a valid grant to a user other than CredentialHolderID is a violation; owners do not count. Ruling for docs/DECISIONS.md: the check runs on every rights change like I1 (ruling of M1-29), because user.enable (M1-31) of a person whose grant on a host became dormant before the switch to personal would make a grant to another person valid again, and 03 6.8 names only the actions where a violation starts from the request; update 24 7.3 point 1.

    • InvariantError gains code i6_violation with hint Revoke the other grants on this host or declare organization first. and conflict entries rendered as InvariantViolation{code: i6_violation, host_id, credential_holder_id, principal_ids, hint}; principal_ids are the user subjects other than the holder plus the members in effect of offending group grants, sorted and unique (empty for a group without members). IDs only, never names.
    • Several violations in one change: the problem code is the first of i1_violation, i6_violation, last_owner, and conflicts lists the entries of that code. Ruling for docs/DECISIONS.md.
    • The deny audit entry follows M1-29 with details.violations of the I6 entries; the preview of M1-49 calls the same evaluator, so I6 appears in invariant_violations with allowed = false.
  10. setHostLlmCredentials (24 7.2 and 7.3, 05 4.14 host.manage), use case SetLlmCredentials, answers in M2-39's order:

    • authorize(p, host.manage, host): gate (403 forbidden with reason subject_kind_not_allowed), unknown or invisible host (404 not_found, same body), not an owner (403 forbidden with reason owner_only), step-up yes (401 step_up_required); denials are audited by authz.
    • Body: reason as M2-39's updateHost checks it (textcheck, at most audit.reason_max_chars; 400 validation_failed with errors[].code required or too_long and pointer /reason). ValidateDeclaration: personal without credential_holder_id gives 400 validation_failed with errors[].code required and pointer /credential_holder_id; a holder with none or organization gives 400 validation_failed with errors[].code not_allowed; a holder that is not a human principal or is deleted (People.User) gives 400 validation_failed with errors[].code unknown_user. Ruling for docs/DECISIONS.md: the code not_allowed is new and documented in HostLlmCredentialsSet and 24 13.
    • If-Match as in M2-39: missing gives 428 precondition_required, different gives 412 version_conflict with current; then decide "No effect" in a read before any rights change: same llm_credentials and holder as stored gives 200 with the current host, no new version, no event, no rights version, and one audit entry with no_change: true (06 2).
    • A retired host gives 409 conflict, as M2-39 answers updateHost on it (24 3.1: retired is final). Ruling for docs/DECISIONS.md: every other state accepts a declaration; confirmHost (M2-41) sets it again at the confirmation.
    • Otherwise one unit of work with RightsChange{Action: host.manage, Resource: host, Targets: [host], Principals: old and new holder} and ApplyLlmDeclaration: lock the host row FOR UPDATE after permission_version (03 6.8), compare the version again (412 on a race), write llm_credentials, credential_holder_id (NULL unless personal), llm_credentials_declared_by, llm_credentials_declared_at, version + 1; when EndsForeignSessions(before, after) (the new value is personal and the old one was not, or the holder changed) call EndSessions(SessionSelector{HostID, ExceptUserID: new holder}, i6_violation); write host.updated; write the audit allow entry host.manage with the flat details operation = llm_credentials, from_llm_credentials, to_llm_credentials, from_credential_holder_id and to_credential_holder_id (absent when none) and reason. Phase 3.1 then runs I6; a violation rolls everything back, sessions included, and answers 409 i6_violation.
    • Answer 200 with the HostView and ETag.
  11. SR10 on a change of the assignee (07 SR10: "Checked ... on every change of assigned_user_id ...: if the condition no longer holds, the sessions end"; PLAN row "a change of the assignee ends other people's sessions in the same transaction"). In M2-39's Update, inside its unit of work, when assigned_user_id changes or is cleared and the old assignee is not an active owner (People.IsActiveOwner), call EndSessions(SessionSelector{HostID, UserID: old assignee}, access_revoked). Sessions of owners and of the new assignee stay. Ruling for docs/DECISIONS.md: only the old assignee can lose SR10 by this change (every other person who is not an owner was already refused by SR10); a change of the owner status or of reported_is_server_host is M2-46's.

  12. Decision mapping (Decision.Err, M1-28 table; 07 4.1, 24 13):

    Outcome Reason Problem Extension
    forbidden terminal_not_host_assignee 403 terminal_denied reason: not_host_assignee
    forbidden terminal_server_host_owner_only 403 terminal_denied reason: server_host_owner_only
    forbidden i6_holder_only 403 terminal_denied reason: i6_holder_only
    forbidden terminal_no_session_user 403 terminal_denied reason: no_session_user
    forbidden terminal_profile_unknown 403 terminal_denied reason: profile_unknown
    forbidden terminal_session_limit 403 terminal_denied reason: session_limit
    forbidden host_not_active 409 host_unavailable none
    step_up_required terminal_step_up_not_fresh 401 step_up_required none (passkey_required comes with M6-18)
    invariant_violation i6_violation 409 i6_violation hint, conflicts

    The rows for terminal_profile_unknown, terminal_session_limit and host_not_active are added now so that the one table stays complete; M2-44 and M2-46 add the guards that produce them. audit_events.deny_reason keeps the 07 code (M1-24 KnownReasons already lists them; add any that is missing).

  13. Preview of a grant on a host (07 2.12 point 4 and 9.3, M1-49). previewGrant with target_kind = host runs the same dry run:

    • effects: host entries from the diff of Design note 7, before_level and after_level none, capabilities_added: [terminal].
    • invariant_violations: the I6 entries of Design note 9; allowed = false with any of them.
    • hints: terminal_not_effective with principal_id for a user subject when, after the change, SR10 or SR12 of Design note 5 would deny that person on the host; for a group subject one hint with group_id and count = number of active members in effect whom SR10 or SR12 would deny, absent when count is 0. No terminal_not_effective hint when an I6 violation names the same host. group_reach and expiry_missing apply as in M1-49; read_horizon and trigger_blocked_tool_class never apply to a host.
    • Text lines for a host grant: summary, then denial, I6 violation, terminal_not_effective, group reach, missing expiry. No abilities line and no capabilities line: they describe levels in the tree. The cause of terminal_not_effective for a user subject is the first failing guard: server host, assignee, session user.
    • New catalogue keys (placeholders in braces; {host} is HostFacts.Name, {holder} the display name of the holder):
    Key de en
    summary.host {subject} erhält{until} das Zusatzrecht "Terminal" auf dem Rechner {host}. {subject} gets the additional right "Terminal" on host {host}{until}.
    hint.i6 Abgelehnt: Auf {host} gilt die persönliche LLM-Anmeldung von {holder}. Dort darf nur {holder} eine Freigabe haben. Rejected: {host} uses the personal LLM login of {holder}. Only {holder} may hold a grant there.
    hint.terminal_not_effective.server_host Hinweis: Die Freigabe wirkt für {subject} nicht. {host} betreibt den Server; dort öffnen nur Owner ein Terminal. Note: the grant does not take effect for {subject}. {host} runs the server; only owners open a terminal there.
    hint.terminal_not_effective.not_assignee Hinweis: Die Freigabe wirkt für {subject} nicht. Auf {host} öffnen nur Owner und die Person, der der Rechner zugewiesen ist, ein Terminal. Note: the grant does not take effect for {subject}. On {host} only owners and the person the host is assigned to open a terminal.
    hint.terminal_not_effective.no_session_user Hinweis: Die Freigabe wirkt für {subject} erst, wenn {subject} auf {host} einen eigenen Sitzungsbenutzer hat. Note: the grant takes effect for {subject} only once {subject} has an own session user on {host}.
    hint.terminal_not_effective.group (count > 1) Hinweis: Die Freigabe wirkt für {count} Mitglieder von {group} auf {host} nicht. Note: the grant does not take effect on {host} for {count} members of {group}.
    hint.terminal_not_effective.group_one Hinweis: Die Freigabe wirkt für 1 Mitglied von {group} auf {host} nicht. Note: the grant does not take effect on {host} for 1 member of {group}.

    Golden file internal/authz/domain/previewtext/testdata/example_host_de.golden (exactly 3 lines):

    Sam erhält das Zusatzrecht "Terminal" auf dem Rechner R1.
    Hinweis: Die Freigabe wirkt für Sam nicht. Auf R1 öffnen nur Owner und die Person, der der Rechner zugewiesen ist, ein Terminal.
    Hinweis: Die Freigabe für Sam hat kein Ablaufdatum.
    

    Golden file example_host_en.golden:

    Sam gets the additional right "Terminal" on host R1.
    Note: the grant does not take effect for Sam. On R1 only owners and the person the host is assigned to open a terminal.
    Note: the grant for Sam has no expiry date.
    

    World of both golden tests: owner (locale de or en respectively, time zone UTC); host R1 assigned to Alex, organization, not a server host; Sam without grants; grant: Sam, host R1, terminal, no expiry, reason Cover. Ruling for docs/DECISIONS.md: the host templates, the cause order and the omitted lines; add the German example to 07 9.3.

  14. Rulings for docs/DECISIONS.md, one row each with reason: notes 1 (terminal.read_own on a host), 4 (console fails closed; consumption on the locked row), 5 (order), 7, 8 (unchanged report), 9 (every rights change; code priority), 10 (not_allowed; retired), 11, 13. Facts already in the specification (the rules of SR10 to SR12, I6, the columns) and the rulings of M2-39 (the gate, the owner part of TerminalOn, the exact comparison) are not repeated there.

Tests (write them first)

Use the fake sources and the controllable clock of M1-28 for domain and app tests, and PostgreSQL 18 through pgtest with real authz, identity, terminal, audit recorder and event appender for integration tests. Test names carry the IDs; if M1-28 or M1-29 established another form, follow it. World (07 11.1 plus hosts through terminaltest): the Owner (owner, locale de, time zone UTC); Alex, Sam and Robin as people; group Leser with member Sam; host R1 (active, assigned to Alex, organization, reported session users bothy-s-alex, bothy-s-sam), host R2 (active, personal, holder Robin, reported bothy-s-robin, bothy-s-owner), host R3 (active, assigned to Sam, declared_server_host = true, reported bothy-s-sam, bothy-s-owner); assignments Alex bothy-s-alex and Sam bothy-s-sam on R1, Robin bothy-s-robin and the Owner bothy-s-owner on R2, Sam bothy-s-sam and the Owner bothy-s-owner on R3; grants terminal to Alex and to Sam on R1 and to Sam on R3; the agent Lotse (an agent actor in the app tests). Every person has a session with a fresh device_key step-up unless a test says otherwise. A person's status is changed through the identity test helper where a test needs it (user.disable is M1-45, outside the dependency closure).

Unit (internal/authz/domain, internal/terminal/domain):

  • TestFreshStepUp: table with device_key and passkey fresh (allowed); password_totp and oidc_reauth fresh (denied); age exactly terminal.step_up_max_age (allowed) and one nanosecond more (denied); no step-up; StepUpConsumedAt equal to StepUpAt (denied); consumed before a newer step-up (allowed); ClientComponent = console with a fresh passkey (denied).
  • TestQuickReattach: same device and login session, detached, DetachedAt exactly terminal.quick_reattach_window ago (true) and one nanosecond more (false); another device; another login session; state active; action terminal.open; console (false in every case except the first).
  • TestTerminalGuard: each rule alone and the order of Design note 5: a person who is not an owner on a server host assigned to them gives terminal_server_host_owner_only; not assigned gives terminal_not_host_assignee; owner on R3 passes SR10; owner who is not the holder on R2 gives i6_holder_only; holder Robin passes SR11; no assignment, an assignment with Reported = false: terminal_no_session_user for an owner and for an assignee; dev environment branch false.
  • TestTerminalEffective: owner on organization (true), owner on personal as holder (true) and not holder (false), person with a grant (true), without (false).
  • TestCheckI6: organization host (none); personal without grants; with a grant to the holder (none); to another user, to an owner who is not the holder, to a group with and without members (violation with the principal_ids of Design note 9).
  • TestDecisionErr_TerminalReasons: every row of the table in Design note 12 (problem code, status, reason, hint and conflicts where named); the audit reason stays the 07 code.
  • TestRequirementSources_Terminal (the source test of M1-28 for human and +terminal, self and +terminal and the overrides) and TestDeferredActionsGolden (golden without the five ids, with host.pair and host.connect).
  • TestPreviewTextExampleHostGerman and TestPreviewTextExampleHostEnglish: render the golden world and compare byte for byte; TestPreviewTextHostVariants: each catalogue key of Design note 13 in both languages, the cause order, no abilities line, hint.i6 with the holder's display name.
  • TestValidateDeclaration (each rule of Design note 10), TestEndsForeignSessions (to personal, holder change, back to organization, unchanged), TestSameReport and TestVanishedNames (reordered names equal, reordered workdirs different, removed and renamed names).

Decisions of authorize (internal/authz/app, fakes, world above):

  • TestAZ_M54_AlexOpensOwnHost: Alex, R1, terminal.open, fresh step-up: allow.
  • TestAZ_M55_SamOnForeignHost: Sam with grant and assignment on R1: forbidden terminal_not_host_assignee.
  • TestAZ_M56_SecondOwnerOnPersonalHost: Robin is made a second owner; the Owner, R2, with assignment and fresh step-up: forbidden i6_holder_only; Robin: allow.
  • TestAZ_M57_GroupGrantOnPersonalHost: VerifyInvariants after a grant terminal to group Leser on R2: violation i6_violation with host_id R2 and principal_ids [Sam]; grants that are revoked, expired exactly at t, of a disabled person or of a deleted group are ignored (the HTTP part is TestTMT09_I6).
  • TestAZ_M58_ConsumedStepUp: Alex's step-up consumed for another opening: step_up_required terminal_step_up_not_fresh.
  • TestAZ_M59_AgentToken: Lotse (agent) on R1 and on a random host id: forbidden subject_kind_not_allowed both, one deny entry each.
  • TestAZ_M60_InstanceGrantGivesNoTerminal: a host R5 (active, no assignee, organization, row AZ-M60 of 07 11.1 as M2-39 corrects it), Alex with a grant on the instance with trigger and all capabilities and no grant on R5: terminal.open gives not_found, the same answer as for a random host id.
  • TestAZ_M61_OwnerWithoutSessionUser: the Owner, R1, fresh step-up, no assignment on R1: forbidden terminal_no_session_user.
  • TestTMT08_SR10: Sam on R1 (terminal_not_host_assignee), Alex on R1 (allow), Sam on R3 (terminal_server_host_owner_only); the same with reported_is_server_host instead of the declaration; the Owner on R3 (allow).
  • TestTMT28a_NoSessionUser: the Owner and Alex (assignment ended) on R1: both forbidden terminal_no_session_user, each with an aggregated deny audit entry (AZ-P13); Decision.Err gives 403 terminal_denied with reason: no_session_user.
  • TestTerminalDecisionOrder: Sam on R1 without step-up gets step_up_required before terminal_not_host_assignee; Sam with a session but without any grant on R2 gets not_found (R2 not visible); the Owner without step-up on R2 gets step_up_required before i6_holder_only.
  • TestTerminalAttachStreamReadClose: on a session of Alex on R1: Alex terminal.attach with fresh step-up (allow), with quick reattach (allow, QuickReattach = true), from another device without step-up (step_up_required); the Owner terminal.attach (forbidden not_self), terminal.close and terminal.read_own (allow); Sam on Alex's session for all four actions (not_found); Alex terminal.stream (allow); terminal.read_own on host R1 for Sam (allow); missing Resource.Terminal on terminal.attach and missing HostID are internal errors.
  • TestSubjectGate_TerminalActions: agent, app and a personal token of Alex on the five terminal actions: forbidden subject_kind_not_allowed, also for unknown ids.
  • TestTerminalSnapshotVersion: the snapshot caches SessionUsers and PersonalHosts; a new rights version rebuilds them, the same version reuses them (count source calls).
  • TestAZP8_TerminalMatchesReference: extend the random-world generator of AZ-P8 with declarations, server host flags, assignments and reports, and compare terminal.open and terminal.attach decisions with cache against the reference without cache.

Integration (PostgreSQL 18 via pgtest, HTTP through the real chain, people and sessions from the identity test helpers, steerable clock; terminal sessions are rows written through terminaltest):

  • TestIDT28_PasswordTotpAndOidcStepUp: rows with step_up_method password_totp and oidc_reauth (no operation sets them before M6-01; write them with the identity test helper), read through the real Authenticator: authorize(terminal.open) gives step_up_required terminal_step_up_not_fresh.
  • TestIDT28_StepUpTooOld: step-up through completeStepUpWithDeviceKey (M1-35), clock moved past terminal.step_up_max_age: step_up_required; one nanosecond before: allow.
  • TestIDT28_TwoOpeningsNeedTwoConfirmations: open harness (authorize then a unit of work with ConsumeFresh) succeeds once; the second attempt with the next request's actor fails at authorize (AZ-M58); a new step-up makes the third succeed; grant.create by the Owner still passes step-up within auth.step_up_max_age after a consumption.
  • TestIDT28_ConcurrentOpeningsOneConfirmation: eight goroutines with the same actor after one confirmation run the open harness in parallel: all pass authorize, exactly one ConsumeFresh succeeds, the others return step_up_required, and step_up_consumed_at was written once.
  • TestConsumeFresh_Rules: outside a unit of work (internal error), revoked session (401 session_revoked), console session (step_up_required), a step-up that aged out between authorize and the lock (step_up_required).
  • TestTMT09_I6: over HTTP as the Owner with fresh step-up: createGrant terminal on R2 to Sam and to group Leser give 409 i6_violation with hint and conflicts (AZ-M57); to Robin gives 201; setHostLlmCredentials on R1 to personal with holder Alex while Sam has a grant gives 409 i6_violation; in each refusal grants, hosts, terminal_sessions, permission_version, event_log and allow audit rows are unchanged, and exactly one deny entry with deny_reason = i6_violation exists.
  • TestI6_UserEnableRevivesGrant: Sam's status is set to disabled through the identity test helper, the grant to Sam on R1 is dormant, R1 switches to personal with holder Alex (allowed: no valid grant to another subject); user.enable of Sam then gives 409 i6_violation and Sam stays disabled.
  • TestI6_SwitchEndsForeignSessions: the Owner gets the assignment bothy-s-owner on R1 and R1 reports it; R1 with open sessions of Alex and the Owner; Sam's grant revoked; switch to personal with holder Alex: 200, the Owner's session closed with i6_violation in the same commit (same correlation_id on host.updated, the host.manage entry and the terminal.terminate entry), Alex's session open; access.changed for the Owner with removed host R1.
  • TestI6_HolderChangeEndsForeignSessions: host R4 (active, no assignee, personal with holder Robin, Robin a second owner, no grants, assignments of Robin and the Owner reported), Robin's session open; the holder changes to the Owner: Robin's session closed with i6_violation; a variant that fails in phase 3.1 (a test subscriber vetoes) leaves Robin's session open and the declaration unchanged.
  • TestSR10_AssigneeChangeEndsSessions: the Owner gets the assignment bothy-s-owner on R1 and R1 reports it; sessions of Alex and the Owner on R1; updateHost sets the assignee to Sam: Alex's session closed with access_revoked in the same commit, the Owner's open; clearing the assignee ends Sam's session; with Alex as a second owner, the change leaves Alex's session open.
  • TestSetHostLlmCredentials_HTTP: happy path (200 Host, version raised, ETag, host.updated valid against HostUpdatedEvent, audit details of Design note 10, permission_version raised); unchanged declaration (200, no new version, no event, audit no_change: true); 400 validation_failed with errors[].code required, not_allowed, unknown_user and for the reason; missing If-Match (428 precondition_required), different (412 version_conflict); unknown host (404 not_found, same body as an invisible host); Alex as assignee (403 forbidden with reason owner_only); Robin without relation to R1 (404 not_found); the Owner without fresh step-up (401 step_up_required); a personal token of Alex (403 forbidden with reason subject_kind_not_allowed); a retired host (409 conflict).
  • TestSR12_ReportDropsName: RecordSessionUserReport with cause host.connect for R1 without bothy-s-alex: permission_version raised, authorize(terminal.open) for Alex gives terminal_no_session_user, Alex's open session on R1 closed with access_revoked in the same transaction, host.updated written; the same report again: no transaction, no version; the name reported again: Alex is allowed again; an invalid entry (pattern, length): nothing written.
  • TestPreviewHostGrant: over HTTP as the Owner: after the Owner revokes Sam's grant on R1, a preview of a grant to Sam on R1 gives allowed = true, hint terminal_not_effective with principal_id Sam, one effect for Sam with target_kind = host, target_id R1, before_level and after_level none and capabilities_added: [terminal], and the German text of the golden file; with Sam's grant still in place the same preview has the hint and the text but no effect for Sam; grant Alex on R1 has no such hint; after Alex's assignment on R1 ended, the same preview has the hint with the cause no_session_user; grant to group Leser on R1 has group_id and count 1; grant Sam on R2 gives allowed = false with the I6 entry and no terminal_not_effective hint; after each preview the counts of grants, event_log, audit_events and permission_version are unchanged.
  • TestTerminalAuthzAudit_NoContent: every audit row these tests wrote holds no host name, no os_user, no step-up challenge, signature or token text, and no JSON null.
  • TestAZP1_TerminalAuthzRoutes: setHostLlmCredentials is registered with host.manage and rl.admin, the server starts, and no route with terminal.open, terminal.attach, terminal.stream, terminal.read_own, terminal.close, host.pair or host.connect is enabled by this ticket.

Contract: responses of setHostLlmCredentials, updateHost, createGrant and previewGrant validate against Host, Grant, GrantPreview and Problem in the integration tests; problems carry code and, where named above, reason, hint and conflicts.

Red team focus of the milestone (PLAN 8 "Red team", terminal paragraph: opening without fresh or with consumed step-up, over group grants on a host with personal, by a second owner under I6, on the server host by people who are not owners, on foreign hosts despite a grant, without an own session user, by agent, app and personal tokens, sessions after a revocation) is covered by TestIDT28_*, TestAZ_M56_*, TestAZ_M57_*, TestTMT08_SR10, TestTMT09_I6, TestTMT28a_NoSessionUser, TestSubjectGate_TerminalActions, TestI6_UserEnableRevivesGrant, TestI6_HolderChangeEndsForeignSessions, TestSR10_AssigneeChangeEndsSessions and TestSR12_ReportDropsName; name them in the red team request.

Commands that must pass, from the repository root:

  • cd services/server && go test -race -count=1 ./internal/authz/... ./internal/identity/... ./internal/terminal/... ./internal/kernel/... ./internal/wire/... ./api/...
  • make codegen-check, make arch, make api-lint, make api-checks, make api-tables, make register-check, make catalog-check
  • make migrations-lint (only with the conditional migration, after make migrations-lock)
  • make config-reference then make config-doc-check
  • make docs
  • make check

Acceptance criteria

  • terminal.open, terminal.attach, terminal.stream, terminal.read_own and terminal.close are real requirements and no longer in the deferred golden list; setHostLlmCredentials is enabled and registered (AZ-P1, AZ-P3).
  • AZ-M54 to AZ-M61 give the outcomes and reasons of 07 11.1 (AZ-M60 with terminal.open).
  • TM-T08, TM-T09 and TM-T28 (a) hold as decisions of authorize and, for the I6 refusals, over HTTP with 409 i6_violation; the denials map to 403 terminal_denied with the reason of 24 13.
  • An assignment whose name the host no longer reports gives terminal_no_session_user, and a change of the report raises the rights version; an unchanged report raises nothing.
  • ID-T28 without the console part: password_totp, a step-up older than terminal.step_up_max_age and a second opening with one confirmation give step_up_required; concurrent openings with one confirmation give exactly one consumption.
  • Switching to personal with grants to others gives 409 i6_violation and changes nothing; switching to personal or changing the holder ends the sessions of other people in the same transaction with i6_violation; changing the assignee ends the old assignee's sessions in the same transaction with access_revoked.
  • The preview of a grant on a host to a person who is not the assignee says "does not take effect" (terminal_not_effective, golden texts in German and English); an I6 violation gives allowed = false.
  • Agents, apps and personal tokens get 403 forbidden with reason subject_kind_not_allowed on every terminal action.
  • Audit, events and problems of this ticket hold no content, names, tokens or null.
  • 07 4.2, SR10 and 9.3, 08 6.3, 24 7.2, 7.3 and 13 and openapi.yaml match the implementation; rulings recorded.
  • 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 and terminal updated (terminal entries, step-up fresh and ConsumeFresh, the guards, the session users in the rights state, I6 in VerifyInvariants, setHostLlmCredentials, RecordSessionUserReport and ApplySessionUserReport for M2-41 and M2-43, the extension point of the guard for M2-44 and M2-46).
  • Rulings of note 14 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 07, 08, 24 and openapi.yaml of the Scope made in the same change.
  • 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); 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>.