Skip to content

M2-39: Host records and the target host

Status: ready | Depends on: M1-48 | Milestone: M2

Goal

After this ticket the server module terminal exists with its three tables hosts, host_session_users and terminal_sessions and with the first five operations of the API group Hosts. An owner with step-up creates a host in state pairing and receives its pairing code bothy_hpc_... exactly once (createHost), renames it, sets or clears its holder, assigns people their own session user and ends assignments (updateHost, ruling T12), and disables, enables and retires it (setHostState). Everyone who may read a host (every owner, its holder, every person with a grant terminal on it in effect, also through a group) lists and reads it (listHosts, getHost); everyone else gets 404, identical to an unknown id. Every change is a rights change with step-up, a mandatory reason, an audit entry and the event host.updated, which never carries a key. Every state change follows the life cycle of 24 "States"; a transition out of active ends all sessions of the host, and the end of an assignment ends the sessions of that person on the host, in the same transaction that raises permission_version. In authz the host becomes a real target outside the tree instance -> space -> channel: the source authz.HostSource, the rights snapshot per host, the atoms for the holder and for +terminal, visibility of host, real requirement entries for host.read and host.manage instead of deferred ones, host entries in access.changed, and grants on hosts that reference a real row (the foreign key grants.target_host_id deferred by M1-27). The signing-key purpose terminal gets its active key at start (EnsureActive of M1-32). On these records M2-40 builds the guards SR10, SR11 with I6 and SR12 and the actions terminal.*, M2-41 pairing, M2-43 the control connection, M2-44 sessions and the relay, M2-46 revocation and limits.

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy"; section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part), "Out of scope", "Exit criteria" item 8, "Test strategy" (terminal part), "Red team" (terminal paragraph), "Risks"; block "terminal" (normative sources and the order "Noise core, records, permissions, ...") with the rows M2-39 (this ticket, the contract), M2-40 (the only ticket that depends on it directly: rights, SR10, I6, SR12, fresh step-up), M2-41 (pairing), M2-43 (control connection, reported_* columns), M2-44 (sessions, relay, terminal_session.changed), M2-46 (revocation, limits, metrics) and M2-48 (conformance); section 7 rows M1-27, M1-28, M1-29, M1-32, M1-44, M1-48 and M1-69; section 10 row M4-37 (the console screen A8 on top of these operations); section 18.3 row "Pair and manage hosts, grant terminal"; section 19: P-04, P-09, P-10, P-11, P-12, P-20, P-24.
  • Dependencies: the header names M1-48. D-W-102 struck M1-29 and M1-32 from this ticket's plan column because M1-48 implies them; both stay in this read list: M1-29 (the rights-change path, access.changed, DiffVisible, VerifyInvariants, the Admin port with CreateGrant) and M1-32 (server signing keys, EnsureActive, which this ticket extends with the purpose terminal). Through M1-48 the closure holds M1-01 to M1-03, M1-05 to M1-18, M1-20, M1-22 to M1-29, M1-31 to M1-35 and M1-44 (all implied, so not in the header). Not implied and therefore possibly not merged when you start: M1-19 (rate limits), M1-30 (expiry jobs), M1-49 (preview), M1-51 (agent records), M1-52 and M1-53 (spaces and channels), M1-63 and M1-65 (catch-up and gateway delivery), M1-69 (canary scenario), M1-70 and M1-71 (leak and contract tests); Design notes 13 and 14 say what to do in each case.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits"; 2.1 "Overview"; 2.2 "Host service bothy-host" points 1, 2 and 5 of "One session user per person and host, never shared" (assignments, session_user_taken, reported names, session_user_not_on_host) and the "Limit, honestly"; 2.3 "Server host"; 3.1 "States" (diagram and the paragraph under it); 3.2 "Pairing" point 1 and the paragraph on terminal.fingerprint_max_attempts; 3.3 "Re-pairing" and 3.4 "Second control connection" (transitions other tickets drive through this ticket's state machine); 4.1 "Two key pairs per host"; 5.3 "Terminal session token" (purpose terminal); 6.3 "Limits"; 6.6 "State of a session"; 8 "Permissions" (8.1 to 8.4); 10.1 "Audit"; 10.2 "Events"; 10.3 "Reasons for ending"; 10.4 "Privacy"; 13 "Errors"; 14 "Tests" (TM-T28 (b) and (e)); 16 (R-TM-04, R-TM-05, R-TM-21, R-TM-23); 17 "Configuration values of this chapter" (terminal.pairing_code_ttl, terminal.session_token_ttl).
  • docs/spec/03-data-model.md (core contract, read only): 1 principles 8, 9, 10, 12 and 13; 4.1 server_signing_keys (purpose terminal) and sequence_counters; 4.3 grants (target_host_id, level NULL on hosts, the checks of R-DM-35, the index on target_host_id) and the paragraph after it (which host columns raise permission_version); 4.14 "Terminal (terminal)" with hosts, host_session_users and terminal_sessions (every column, check, unique, index, foreign key and retention); 6.3 "Lock order"; 6.8 "I6 check" (owned by M2-40, read it to keep the lock order); 8 R-DM-35, R-DM-36, R-DM-39, R-DM-40, R-DM-41; 9 host.name_max_chars, host.os_user_max_chars.
  • docs/spec/05-actions.md (core contract, read only): rules 1.1, 1.2, 1.5, 1.7, 1.10, 1.11; 2.1 'Column "Permission"' (+terminal, assignee, host-self); 2.2 "Target of a resource" (row host, terminal_session); 2.4, 2.5, 2.7; 4.14 "Hosts and terminal" (all rows and the paragraph under the table: agents, apps and personal tokens reach none of these actions except host.pair and host.connect, subject_kind_not_allowed); 6 rows host.updated, terminal_session.changed, access.changed, grant.changed; 8 R-AZ-23, R-AZ-24.
  • docs/spec/04-sync-protocol.md (core contract, read only): 2.2 "Envelope" (payload rules 1 to 3); 8.2 "What happens on a change" (entries with kind: "host", affected principals of a target change).
  • docs/spec/07-permissions.md: 2.2 "Targets and inheritance", 2.4 "Capabilities" (row terminal), 2.5 "Validity of a grant", 2.12 "Hosts and the terminal capability" (points 1 to 5), 3 "Principal context" (kind host), 4.1 (no_grant, owner_only, subject_kind_not_allowed, capability_missing, step_up_required, and the terminal reasons owned by M2-40), 4.2 "Flow", 4.3 "Visibility" (last bullet), 7.2 "Permission state (snapshot)", 7.4 "Query pattern" rules 1 and 3, SR5, SR10, SR11, SR12, 11.1 rows AZ-M54 to AZ-M61, 11.2 AZ-P1, AZ-P3, AZ-P4, AZ-P8, AZ-P13, 12 R-RE-11.
  • docs/spec/06-api.md: 2 "Basic rules" (rows "No effect", "No null", "Unknown values", "Caching"), 4 "Authorization in the API", 5 "Errors" (points 3 and 7), 6 "Pagination", 7 "Idempotency" (rows "other POST and PATCH" and "POST with a one-time secret", point 3 auth_flow), 8 "Concurrency" (If-Match, 412, 428), 16 "Endpoints".
  • docs/spec/08-identity.md: 6.3 "Fresh step-up for the terminal" and ID-T28 (owned by M2-40, for the terminal security group), 12 "API tokens for agents, apps and tools" (form and kinds).
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rows authz: declares HostSource; terminal: ports Hosts, Sessions, Relay, implements authz.HostSource; instance: Keys), 4.4 "Allowed dependencies" (row terminal: instance, identity, authz, agents), 4.6 "Actions per module" and 4.7 "Event types and WebSocket ops per module" (rows terminal), 8.1 "Flow" (phases of a rights change), 8.2 "Lock order", 8.3 "In-Tx events", 9.2 "404 instead of 403", 12.3 "Job catalogue" (row terminal.sweep, not built here).
  • docs/spec/19-security.md: SEC-IN-4, 10 "Secrets" (first row: signing keys), SEC-SEC-3, SEC-SEC-4, SEC-LOG-1, SEC-AUD-5 (schema audit_details.v1), SEC-TERM-3, SEC-TERM-5, SEC-T10, SEC-T18.
  • docs/api/openapi.yaml: operations listHosts, createHost, getHost, updateHost, setHostState (with x-authz-action, x-rate-limit-class, x-idempotency-exempt, parameters HostIdPath, IdempotencyKey, IfMatchRequired, Cursor, Limit); schemas Host, HostPage, HostCreate, HostPairingCode, HostUpdate, HostStateSet, HostState, HostPlatform, HostProfile, HostDoctorCheck, HostSessionUser, HostSessionUserAssign, HostReportedSessionUser, LlmCredentials, HostUpdatedPayload, HostUpdatedEvent, TerminalSession, TerminalSessionState, TerminalCloseReason, TerminalCloseDetail, AccessChangedPayload, AccessTarget, AccessTargetKind, Capability, SigningKeyPurpose, Problem (x-problem-codes), FieldError; responses BadRequest, Forbidden, NotFound, Conflict, PreconditionFailed, PreconditionRequired.
  • docs/api/asyncapi.yaml: message event.host.updated (x-receive-action: host.read). docs/api/webhooks.md: the table "Not subscribable" (row terminal: host.updated never goes to webhooks, D-T41).
  • docs/spec/actions.yaml (M1-02): entries host.read (right owner or assignee or +terminal), host.manage (right owner), terminal.terminate (system action).
  • ADRs: docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md, 0017-one-authorization-function-404-instead-of-403-mandatory-registration.md, 0018-grant-model-levels-capabilities-inheritance-no-deny.md, 0021-hash-chained-audit-log-separate-from-telemetry.md, 0049-machine-readable-action-catalogue-as-the-single-source.md, 0052-one-event-envelope-for-gateway-rest-and-webhooks.md, 0053-no-json-null-in-api-and-events.md, 0061-token-format-with-a-fixed-prefix-and-checksum.md (kind hpc), 0066-signing-keys-in-the-writable-key-store.md, 0067-jose-profile-with-the-standard-library.md.
  • 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-T33, D-T37, D-T40, D-T41, D-W-63, D-W-66, D-OP-03, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows (D-M2-39-<n>, English like the file).
  • docs/security/threat-model.md 5.16 "Terminal" and 6.7 "Terminal" (T-TM-01 to T-TM-14). docs/RISKS.md: RK-22, RK-23. docs/OPEN-QUESTIONS.md: OQ-03 and OQ-04 (default answers; they concern M2-45 and M2-46, not this ticket) and OQ-24 (terminal only over a private network; default no).
  • docs/operations/configuration.md: host.name_max_chars, host.os_user_max_chars, terminal.pairing_code_ttl, terminal.session_token_ttl, auth.clock_leeway, auth.step_up_max_age, audit.reason_max_chars, api.page_size_default, api.page_size_max, auth.token_pepper_ref. docs/operations/tailscale.md section 3 (for the network point only). docs/observability/signals.md 4.3a "Terminal" (not built here, M2-46).
  • Briefs: docs/briefs/M1-27-authz-grant-store.md (deferred foreign keys, Target.HostID, CheckCreate rule 3), M1-28-authz-core.md (sources, atoms, requirement table, deferred entries, visibility table row host, resolution of host targets, snapshot, Scope, filter mode, AZ-P8), M1-29-authz-invariants.md (RightsChange, AddAffected, DiffVisible, Admin.CreateGrant), M1-32-signing-keys.md (Purpose, EnsureActive, window per purpose), M1-44-identity-api-tokens.md (domain/apitoken, Tokens, the authenticator, checksum_vectors.json), M1-48-authz-api.md (the pattern for HTTP use cases with authorization, visibility, events and audit), M1-26-identity-principals.md (identity.Directory, kernel/textcheck), M1-24-audit-chain.md (auditing.Recorder, Entry, Attrs, ReasonText), M1-13-unit-of-work.md, M1-16-event-log.md (eventlog.Event, Appender), M1-20-idempotency-keys.md, M1-12-postgres-migrations.md (migration convention, pgtest), M1-31-identity-people.md (addOwner, cursor helper), M2-38-noise-core.md (the format of the terminal security group).
  • Code on the base branch: internal/terminal (only doc.go and README.md, P-24), internal/authz (sources.go, domain with requirements, visibility, snapshot, effective rights and DiffVisible, app with cache, scope and the rights-change hook, adapters), internal/identity (domain/apitoken, port Tokens, Directory, the authenticator and its test helpers for access tokens with DPoP and step-up), internal/instance (keys.go, EnsureActive), internal/audit, internal/kernel/{actor,authorize,auditing,eventlog,txn,clock,errs,ids,textcheck,idempotency}, internal/platform/pg/pgtest, internal/platform/httpserver and httpservertest, internal/wire (configkeys_*.go, configchecks.go, configchapters.go, testdata/txn_registrations.golden), architecture/modules.yaml, api/operations.yaml, migrations/ with migrations.sha256, .gitleaks.toml, tools/ci/tests/test_gitleaks_rules.py.

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. Life cycle (24 "States", 3.2): the five states pairing, confirming, active, disabled, retired; a transition exists only as the diagram of 24 3.1 draws it, plus the fall back from confirming to pairing after terminal.fingerprint_max_attempts failures (24 3.2); every other request gives 409 invalid_state_transition; a host disabled with disabled_reason = duplicate_connection cannot be enabled, only paired again; only active allows sessions. Every state change raises permission_version (03 4.3, paragraph after grants), and every transition out of active ends all sessions of the host (host_disabled, host_retired), in the same transaction.
  2. host.read (05 4.14, 07 2.12 point 5): owners read every host, the holder (hosts.assigned_user_id) reads the own host, a person with a grant terminal in effect on the host (directly or through a group in effect, also when SR10 blocks it) reads that host; everyone else gets 404 with the same status, body and headers as for an unknown id (05 rule 1.2, AZ-P4). The host is a target outside the tree; a grant on the instance gives no host (07 2.12 point 1, R-AZ-24, AZ-M60).
  3. host.manage (05 4.14): owner only, step-up yes, a mandatory reason, Audit yes, Permissions yes. Agents, apps and personal tokens reach neither host.read nor host.manage: 403 forbidden with reason subject_kind_not_allowed (05 4.14, SR5, R-AZ-23).
  4. Grants on hosts (07 2.12 points 1 to 3, 24 8.1, 03 grants, R-DM-35): only owners create them, only for people and groups, without level and with exactly the capability terminal; grants.target_host_id references hosts(id) with ON DELETE RESTRICT; a change of the effective terminal right appears in access.changed as an entry {kind: host, id, capabilities: [terminal]} without level (04 8.2).
  5. Session users (ruling T12, 24 2.2 points 1, 2 and 5, 03 host_session_users, R-DM-39, R-DM-41): an owner assigns a person a session user only with a name the host reported in hosts.reported_session_users, otherwise 400 validation_failed with errors[].code session_user_not_on_host and nothing stored (TM-T28 (e)); a name is unique per host over all rows, including ended ones, without regard to case, and a person has at most one valid assignment per host, otherwise 409 conflict with reason: session_user_taken and nothing changed (TM-T28 (b)); an ended row stays and keeps its name taken; the end of an assignment ends the sessions of that person on the host in the same transaction (access_revoked, SR12, 24 8.3).
  6. terminal_sessions holds metadata only, never content or commands (03, R-DM-36); its session user belongs to the same host and person through the composite foreign key (host_session_user_id, host_id, user_id) -> host_session_users(id, host_id, user_id) (R-DM-40, D-W-66).
  7. host.updated (05 6, 24 10.2): durable, receive action host.read, written on every change including the creation, with the host's state, holder, LLM declaration, profiles, check results and session user assignments; never a key, never the pairing code or its hash.
  8. Audit (24 10.1, 19 SEC-AUD-5): every host.manage write and every refusal is audited; details are codes, IDs, the mandatory reason and the session user name of an assignment, never the host name, a pairing code, a key, a path or terminal content. Every session the server ends is audited with the system action terminal.terminate.
  9. Pairing code (24 3.2 point 1, ADR 0061, 03 principle 9, D-T32): createHost returns bothy_hpc_<random>_<checksum> exactly once with Cache-Control: no-store; only its HMAC-SHA-256 with the server pepper is stored (hosts.pairing_code_hash), valid until pairing_expires_at = creation plus terminal.pairing_code_ttl; the code never appears in a log, an event, an audit entry, the idempotency store or a test failure message.
  10. Signing-key purpose terminal (03 server_signing_keys, 24 5.3, D-T33): EnsureActive creates an active key for it at every start; it rotates only on owner request, its retiring window is terminal.session_token_ttl plus auth.clock_leeway, and getJwks does not publish it (the host service gets the keys over its control connection, M2-43).
  11. Display texts without control or bidi characters (SEC-IN-4): host names at most host.name_max_chars characters, session user names at most host.os_user_max_chars characters and never a path, reasons at most audit.reason_max_chars characters; no JSON null in any response or event (ADR 0053).

Terminal security requirements (D-W-101):

  1. Rights per machine: the additional right terminal, SR10, SR11 with I6, SR12 (07 "Hosts and the terminal capability"). This ticket builds the base all four stand on and proves it: the target host outside the tree (a grant on the instance gives no host, AZ-M60: TestAZM60_InstanceGrantGivesNoHost), the +terminal atom (an owner, or a grant terminal in effect) and the holder atom (TestHostRequirement), visibility of host after 07 2.12 point 5 (TestHostRead_Visibility), grants on hosts only by owners and only with terminal to people and groups with a real foreign key (TestCreateGrant_HostTarget, TestMigration_GrantsTargetHostFK), and the session-user records of SR12 with their T12 rules (TestMigration_HostSessionUsers, TestUpdateHost_SessionUserTaken_TMT28b, TestUpdateHost_NameNotReported_TMT28e). The guards SR10, SR11 with I6 and SR12 for terminal.open and terminal.attach, the I6 check at grant.create on a host and at host.manage (03 6.8), and the end of sessions when the holder changes belong to M2-40 (AZ-M54 to AZ-M59, AZ-M61, TM-T08, TM-T09, TM-T28 (a)).
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not built here: step-up fresh with its consumption in the opening transaction is M2-40 (ID-T28 without the console part), the console with passkey only is M6-18, the step-up key in the apps M4-36 and M5-12. What this ticket enforces: every host.manage operation needs step-up yes (younger than auth.step_up_max_age) of a human acting in a session; a stale step-up gives 401 step_up_required and changes nothing, and agents, apps and personal tokens get 403 forbidden with reason subject_kind_not_allowed before any lookup of the host (TestHostManage_StepUpReasonAudit, TestHostRequirement).
  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 port and no outbound connection: its five operations are REST routes under /api/v1 on the existing API listener, and the server never contacts a host. Proven by TestAZP1_HostRoutes (the five routes on the API router, no other route added) and by the unchanged listener test of M1-11. M2-43 builds the outbound control connection of the host service 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: this ticket writes host.manage entries with codes, IDs, the mandatory reason and, per assignment or end, the session user name under the reserved member os_user, and one terminal.terminate entry per session it ends with session_id, close_reason, bytes_in, bytes_out and duration_ms; never the host name, a pairing code or its hash, a key, terminal content, commands, environment or paths (24 10.1 "Never in the audit", 19 SEC-AUD-5). Proven by TestHostManage_StepUpReasonAudit, TestSetHostState_LeavingActiveEndsSessions and the canary scenario with a host name (SEC-T10, Design note 13). The audit of terminal.open, terminal.attach, terminal.stream and terminal.close belongs to M2-44, the content-free telemetry and TM-T06 to M2-46.
  5. Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host, M2-46 adds the server checks (terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration, terminal.detach_grace), the job terminal.sweep and TM-T11. This ticket creates the columns they rely on (terminal_sessions.opened_at with default now(), last_input_at, detached_at, closed_at, the closed list of close_reason with idle_timeout, max_duration and detach_expired), proven by TestMigration_TerminalSessions_RDM40; it starts no timer and no job.
  6. Service user and session users without administrator rights (T12, HD rules): M2-42 builds install, session-user add and remove and doctor with HD1 to HD3 (TM-T13), M2-45 runs every PTY in the helper of the session user. This ticket holds the server side of T12: one session user per person and host, never shared and never handed on, also after the end of an assignment (a name unique per host over all rows without regard to case, at most one valid assignment per person, rows never deleted while the host is not retired), and only names the host reported. Proven by TestMigration_HostSessionUsers, TestUpdateHost_SessionUserTaken_TMT28b and TestUpdateHost_NameNotReported_TMT28e.
  7. Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-03 (unauthorized opening) through the target outside the tree and grants on hosts only by owners (AZ-M60 TestAZM60_InstanceGrantGivesNoHost, TestCreateGrant_HostTarget; the opening itself by M2-40 and M2-44 with TM-T07, TM-T08, AZ-M54, AZ-M55, AZ-M58); T-TM-06 (pairing of a foreign host) through a code in token format with checksum and scanner rule, issued once, stored only as HMAC and valid terminal.pairing_code_ttl (TestCreateHost_PairingCode, TestHostPairingCode_Mint; redemption and fingerprint by M2-41 with TM-T19 and TM-T20); T-TM-08 and T-TM-11 (a foreign LLM login, sessions or processes after revocation) through session users that are never shared and the end of sessions in the same transaction when an assignment ends or the host leaves active (TM-T28 (b) and (e) server part, TestUpdateHost_RevokeEndsSessions, TestSetHostState_LeavingActiveEndsSessions); T-TM-09 (prompt injection opens a terminal) through the refusal of agents and apps at host.read and host.manage (TestHostRequirement; TM-T17 and AZ-M59 by M2-40); T-TM-01 (content in the server) through host.updated without keys and no host name or code in logs or audit (TestHostUpdated_Event, Design note 13; TM-T06 by M2-46). Not touched here: T-TM-02 (M2-44, M2-45), T-TM-04 and T-TM-14 (M2-42, M2-43), T-TM-05 (M2-41, M2-43), T-TM-07 (M6-18), T-TM-10 (M4-35), T-TM-12 and T-TM-13 (M2-42, M2-45).

Scope

All paths under services/server/ unless they start with docs/, tools/ or a dot. Names of M1 code below are the expected ones; where the merged code names them differently, follow the code (Boundaries).

Create:

  • migrations/<next free number>_terminal_hosts.sql (goose, M1-12 convention with its Down section): the tables hosts, host_session_users and terminal_sessions exactly as 03 4.14, and the foreign key grants.target_host_id -> hosts(id) ON DELETE RESTRICT that M1-27 deferred (Design note 11). Then make migrations-lock.
  • internal/terminal/hosts.go (root package, port): interface Hosts with List, Get, Create, Update, SetState and their DTOs (Design note 1); doc.go gets the package documentation of the built module.
  • internal/terminal/domain/: state.go (State, Cause, Transition, Design note 2), names.go (NewHostName, NewOSUser), sessionusers.go (PlanAssignments, Design note 6), close.go (CloseReason and CloseDetail with the closed lists of 24 10.3), reported.go (Go mirrors and decoders of host_profiles.v1, host_reported_session_users.v1, host_doctor.v1), with table tests.
  • internal/terminal/app/: ports.go (outgoing ports Store, PairingCodes, People, Presence), hosts.go (the five use cases), sessions_end.go (EndSessions, Design note 7), hostsource.go (the implementation of authz.HostSource), view.go (row to HostView, Design note 8), with tests.
  • internal/terminal/adapters/postgres/: queries/hosts.sql, queries/host_session_users.sql, queries/terminal_sessions.sql, the repository implementing app.Store, the sqlc.yaml entry for terminal; regenerated sqlc code.
  • internal/terminal/adapters/httpapi/: the strict-server methods of listHosts, createHost, getHost, updateHost, setHostState.
  • internal/terminal/terminaltest/: test fixtures that insert a host in any state with reported profiles, reported session users, check results and keys, a session-user row and a terminal_sessions row directly in SQL (production writes of these columns come from M2-41, M2-43 and M2-44; fixtures are test code only).

Change:

  • internal/authz: sources.go declares HostSource and HostFacts; domain gets the snapshot map Hosts, the effective function TerminalOn, the atoms HumanSession, Assignee and Terminal, the gate of Design note 3, the requirement entries and the mapping row for host.read and host.manage, the visibility and resolution of host, host entries in DiffVisible; app gets the host part of Scope, the affected principals of a host target in the rights-change hook, the host target in Admin.CreateGrant; the golden list of deferred actions loses host.read and host.manage; the AZ-P8 reference and its world generator learn hosts (Design note 3).
  • internal/kernel/authorize: Scope gets HostIDs []ids.ID (ascending) and AllHosts bool.
  • internal/kernel/auditing and internal/audit/domain: the value constructor OSUserName(s string) Value, accepted only under the reserved member os_user (Design note 9).
  • internal/identity: kind hpc in domain/apitoken; port Tokens gets NewHostPairingCode(ctx context.Context) (PairingCode, error); the authenticator refuses kind hpc without a database call; testdata/checksum_vectors.json gets hpc vectors whose random part starts with EXAMPLE (Design note 10).
  • internal/instance: purpose terminal in Purpose, EnsureActive and the window table (Design note 12).
  • internal/wire: binding of terminal.Hosts, of the terminal implementation as authz.HostSource, of app.PairingCodes to identity.Tokens, of app.People to identity.Directory, of app.Presence to NoConnections (Design note 8); the event type host.updated; the configuration keys this ticket is the first consumer of among host.name_max_chars, host.os_user_max_chars, terminal.pairing_code_ttl, terminal.session_token_ttl (defaults from their chapter tables, chapter constants in configchapters.go); testdata/txn_registrations.golden if registrations change.
  • api/operations.yaml: move listHosts, createHost, getHost, updateHost, setHostState from later to enabled; rerun the route generator (M1-17).
  • architecture/modules.yaml: only if the entry terminal lacks the dependencies of 02 4.4 (instance, identity, authz) or the tables; then regenerate as M1-05 does.
  • Tests of M1-27 and M1-28 that insert a grant row with target host and a random target_host_id: insert a hosts row first through terminaltest (the foreign key now exists); the M1-28 snapshot test that expects host grants to be skipped now expects the Hosts map. M1-32's TestEnsureActive_* expect five purposes. Adjust the fixtures and expectations, never drop a case.
  • docs/spec/07-permissions.md (English): 4.2 a paragraph for the gate of Design note 3; 11.1 row AZ-M60 with a host without holder (Design note 3, ruling 16); 2.12 point 3 one sentence that the exception of I6 is decided by SR11 (M2-40).
  • docs/spec/24-terminal.md (English): 2.2 point 5 the exact match of a reported name; 10.1 rows host.manage and terminal.terminate with the detail members of Design note 9; 13 row validation_failed with the errors[].code values unknown_user and mutually_exclusive (Design note 5).
  • docs/spec/08-identity.md 12 "Form": kind hpc (pairing code of a host; only host.manage issues it, only host.pair redeems it, the authenticator never accepts it).
  • docs/spec/19-security.md: 10 first row names the session token (terminal) among the signing keys, on request only; SEC-AUD-5 the reserved member os_user.
  • docs/api/openapi.yaml: description of HostUpdatedPayload.host: "Never carries noise_public_key; clients read keys with getHost." Then make api-lint, make api-checks, make api-tables and the server code generation.
  • docs/operations/configuration.md: regenerate with make config-reference if the schema text changes.
  • READMEs of terminal (English like the file: status, port Hosts, tables, actions, events, HostSource, the extension points for M2-40 to M2-46), authz, identity, instance, audit; services/server/CHANGELOG.md under Unreleased; docs/DECISIONS.md section 10 rows D-M2-39-<n> for every ruling of Design note 15.

This ticket creates one migration and no new top-level make target.

Out of scope

  • Pairing: issueHostPairingCode, pairHost, confirmHost, the fingerprint check and its failure counter, re-pairing with host_rekeyed and key_changed, writing keys, reported_profiles, reported_session_users and platform at host.pair, the redemption lookup HashHostPairingCode, the test that make gitleaks finds a generated code, TM-T19 and TM-T20, TM-T28 (b) and (e) through confirmHost (M2-41). M2-41 drives its transitions through domain.Transition of this ticket and reuses PairingCodes.
  • Rights of the terminal: the entries host.pair, host.connect, terminal.open, terminal.attach, terminal.stream, terminal.read_own, terminal.close stay deferred; SR10, SR11 with I6, SR12 as guards, the I6 check at grant.create on a host and at host.manage (03 6.8, 409 i6_violation), setHostLlmCredentials, step-up fresh and auth_sessions.step_up_consumed_at, session users and reported names in the rights snapshot, the end of sessions when the holder changes (SR10), the hint terminal_not_effective in access.preview, AZ-M54 to AZ-M59 and AZ-M61, TM-T08, TM-T09 (M2-40).
  • The control connection /hosts/connect, report, the columns reported_* and last_seen_at as written data, host.connect, host.presence_changed, the presence registry, duplicate_connection, the close codes 4110 to 4114, closing the host's connections at disabled and retired (M2-43).
  • Sessions: openTerminalSession, attachTerminalSession, listTerminalSessions, getTerminalSession, closeTerminalSession, the session token, the relay, the event terminal_session.changed, the close code 4100 and session.close to the host service (M2-44; they hook into EndSessions, Design note 7); several nodes (M2-47).
  • Revocation subscribers (authorize.AccessChanged, identity.DeviceRevoked, identity.SessionEnded), limits per person and host, idle and maximum duration, the job terminal.sweep, the metrics bothy.terminal.*, rl.terminal and rl.host (M2-46); retention jobs of the class security (M7).
  • The host service (M2-38, M2-42, M2-45) and the conformance suite (M2-48).
  • Screens: the console screen A8 (M4-37), terminal views (M4-35, M4-36, M5-12, M6-18).
  • hosts.dev_environment_id and SR10 for an own dev environment (V2).
  • A transition pairing or confirming -> retired: the diagram of 24 3.1 has none, so it is 409 invalid_state_transition here (spec observation in the report).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-39-terminal-hosts; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(terminal): host records, feat(authz): target host, feat(identity): pairing code kind) 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).
  • 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).
  • 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. No test contacts a host or runs a host service; hosts exist only as rows.
  • Never lower a threshold, disable a gate or delete a test.
  • Time only through kernel/clock; tests move a fake clock, they never sleep for a window.
  • Pairing codes, the token pepper, private keys and access tokens never appear in logs, audit details, problem bodies, event payloads or test failure messages (print a hash prefix if needed).
  • 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>.
  • If the base branch's docs/spec/actions.yaml lacks host.read, host.manage or terminal.terminate, or its grants migration lacks the column target_host_id, the M1 state is missing on the base branch: stop with Verdict: BLOCKED <reason>.

Design notes

1. Module layout and port. internal/terminal follows the module layout of 02 (port in the root package, domain without dependencies outside kernel, app with outgoing ports, adapters/postgres, adapters/httpapi). The port Hosts (02 4.3 names it) is:

type Hosts interface {
    List(ctx context.Context, p actor.Principal, page Page) (HostPage, error)
    Get(ctx context.Context, p actor.Principal, hostID ids.ID) (HostView, error)
    Create(ctx context.Context, p actor.Principal, cmd CreateHost) (PairingCodeIssued, error)
    Update(ctx context.Context, p actor.Principal, cmd UpdateHost) (HostView, error)
    SetState(ctx context.Context, p actor.Principal, cmd SetHostState) (HostView, error)
}

actor.Principal stands for the request principal type of kernel/actor. DTOs: CreateHost{Name, Reason string; AssignedUserID ids.ID}; UpdateHost{HostID ids.ID; IfMatch int64; Name string; NameSet bool; AssignedUserID ids.ID; ClearAssignedUser bool; Add []SessionUserAssign; Revoke []ids.ID; Reason string}; SessionUserAssign{UserID ids.ID; OSUser string}; SetHostState{HostID ids.ID; IfMatch int64; State, Reason string}; PairingCodeIssued{HostID ids.ID; Code <the redacting secret type of M1-44>; ExpiresAt time.Time}; HostView mirrors the schema Host field by field (zero values mean absent); HostPage{Items []HostView; NextCursor string}. No Sessions or Relay port yet; M2-44 adds them. Outgoing ports of app: Store (the repository), PairingCodes { Issue(ctx context.Context) (PairingCode, error) }, People { User(ctx context.Context, id ids.ID) (UserFacts, error) } with kind and status, Presence { Connected(ctx context.Context, hostID ids.ID) bool }. internal/wire binds them to identity.Tokens, identity.Directory and NoConnections (Design note 8).

2. Life cycle as a pure function. domain.Transition(from State, disabledReason string, c Cause) (Result, error) with Result{To State; DisabledReason string; EndSessions bool; CloseReason CloseReason} and ErrInvalidTransition (mapped to 409 invalid_state_transition). It is the only place that knows the transitions; M2-41 and M2-43 call it for theirs. Every transition raises permission_version (03 4.3), so callers run it in a unit of work with a rights change. The table, first column the state before (none for a new host):

From Cause To Ends sessions with Driven by
none create pairing createHost (this ticket)
pairing pair confirming pairHost (M2-41)
pairing new_pairing_code pairing issueHostPairingCode (M2-41)
confirming confirm active confirmHost (M2-41)
confirming new_pairing_code pairing M2-41
confirming fingerprint_attempts_exhausted pairing M2-41 (24 3.2)
active new_pairing_code pairing host_rekeyed M2-41 (24 3.3)
active disable disabled with reason owner host_disabled setHostState (this ticket)
active duplicate_connection disabled with reason duplicate_connection host_disabled M2-43 (24 3.4)
active retire retired host_retired setHostState
disabled enable active (only with reason owner) setHostState
disabled new_pairing_code pairing host_rekeyed M2-41
disabled retire retired host_retired setHostState

Every other pair is ErrInvalidTransition, among them every cause from retired, enable with duplicate_connection (24 3.1), disable and retire from pairing and confirming, and every cause that would keep the state except new_pairing_code on pairing. A request for the state a host already has is not a transition; the use case answers it as "No effect" (Design note 4). Ruling for docs/DECISIONS.md: every transition into a state other than active sets EndSessions (when leaving disabled there are normally no open sessions; ending them anyway costs one indexed query and closes any row a crash left behind).

3. The target host in authz. Everything below lives in internal/authz (rights logic only there, 05 rule 1.1).

  • Source (sources.go), read fresh per call inside the ambient transaction, like StructureSource (host facts are few and change rarely; a stale cache would decide on an old holder):

    type HostFacts struct {
        ID                   ids.ID
        State                string // pairing, confirming, active, disabled, retired
        AssignedUserID       ids.ID // zero: owners only (SR10)
        LLMCredentials       string // none, personal, organization (consumed by M2-40)
        CredentialHolderID   ids.ID // set exactly with personal (consumed by M2-40)
        DeclaredServerHost   bool   // consumed by M2-40
        ReportedIsServerHost bool   // false while not reported; consumed by M2-40
    }
    type HostSource interface {
        Host(ctx context.Context, id ids.ID) (HostFacts, bool, error)
        HostIDs(ctx context.Context) ([]ids.ID, error)                    // every host, ascending
        AssignedTo(ctx context.Context, userID ids.ID) ([]ids.ID, error) // ascending
    }
    

    The fields follow 02 4.3 ("the host's state, assignee, LLM declaration, server host"). The implementation is internal/terminal/app/hostsource.go over the terminal store; internal/wire binds it.

  • Snapshot (domain/snapshot.go): replace "host grants are skipped" by a map Hosts map[ids.ID]Caps built from the grants in effect with target host (the principal's own and, for people, those of their groups in effect; Grant.InEffect and Membership.InEffect of M1-27); their expires_at enter ValidUntil. Host grants never enter Instance, Spaces or Channels.

  • Effective right: TerminalOn(s Snapshot, hostID ids.ID) bool is true for an effective owner, or when s.Hosts[hostID] contains terminal (07 2.12 point 3). Ruling for docs/DECISIONS.md: the exception of I6 for owners on a personal host (07 2.12 point 3, first bullet) is decided by the SR11 guard of M2-40 with reason i6_holder_only (AZ-M56), which comes after the requirement (07 4.2 step 9); before M2-40 no host can be personal (only confirmHost and setHostLlmCredentials set llm_credentials), so the atom does not repeat it.
  • Atoms (requirement table of M1-28): HumanSession (kind user, acting in a session, not with an API token; reason subject_kind_not_allowed); Assignee (the resource is a host whose AssignedUserID is the principal; reason not_self); Terminal (TerminalOn for the resolved host; reason capability_missing).
  • Mapping row for the 05 text owner or assignee or +terminal: Any(Owner, Assignee, Terminal). Overrides by action id (they win over the text rule): host.read: All(HumanSession, ByKind{instance: Auth, host: Any(Owner, Assignee, Terminal)}) (instance is the resource of listHosts, as listGroups does in M1-48); host.manage: All(HumanSession, Owner). Remove both from the deferred entries and from the golden list of deferred actions in the same change; host.pair, host.connect and terminal.* except the system action stay deferred.
  • Gate before resolution: when the requirement of an action is All(HumanSession, ...), authorize evaluates HumanSession right after step 1 of 07 4.2 (after check_active and the pending checks, before snapshot, resolution and visibility) and answers forbidden with subject_kind_not_allowed. The answer depends only on the principal, never on the resource, so it reveals nothing (an agent gets the same 403 for an existing and for a random host id, AZ-P4). Ruling for docs/DECISIONS.md: 05 4.14 refuses agents, apps and personal tokens with subject_kind_not_allowed (TM-T17, AZ-M59), which visibility first would turn into 404; update 07 4.2 with one paragraph. M2-40 uses the same gate for terminal.*.
  • Resolution: a resource of kind host resolves through HostSource.Host; missing gives not_found with no_grant. A grant resource with target host keeps M1-28's rule (no path in the tree, only Owner passes) and resolves the host for existence.
  • Visibility (domain/visibility.go, row host): effective owner, or Assignee, or Terminal; with ViewAs both snapshots must see it; agents and apps never pass the gate. The row terminal_session stays an internal error until M2-40.
  • Scope for host.read: AllHosts for an effective owner; otherwise HostIDs = keys of Hosts with terminal united with HostSource.AssignedTo(principal), ascending; with ViewAs intersected like the channel lists. Memoised per request (WithRequestScope), never stored in the cache entry.
  • DiffVisible: host entries compare the set {H : TerminalOn(snapshot, H)}, over HostSource.HostIDs for an effective owner and over the keys of Hosts otherwise; a host in the set after but not before is added with capabilities: ["terminal"], the reverse is removed with kind and id only; hosts have no level and no changed entry (one capability). Ruling for docs/DECISIONS.md: owners' implicit terminal is listed per host, so owner.add, owner.remove and createHost report hosts like every other target, and M2-46 can end sessions from removed entries alone; the set does not depend on state or holder (07 2.12 point 3), so setHostState and a holder change write no access.changed.
  • Affected principals of a host target in txn.RightsChange.Targets (the rights-change hook of M1-29 derives them, 04 8.2): every active owner, the current holder, and the subjects of grants in effect on the host with group members expanded; the use case adds others with AddAffected before it writes (a new holder, the people of add_session_users and revoke_session_users). The difference step drops everyone without a change.
  • Admin.CreateGrant with target host: the host must exist (404 otherwise, like an unknown space); a host in state retired gives 409 conflict like an archived target. Ruling for docs/DECISIONS.md: a retired host is final, a grant on it could never work. Subject and capability rules stay with CheckCreate of M1-27 and the checks of 03 (owner only, people and groups, exactly terminal). grant.changed for a host grant has an empty scope.
  • AZ-P8: extend the world generator of the differential test with hosts, holders and host grants (direct and through groups, with expiry) and the reference with 07 2.12, so the test covers host.read decisions and Scope host lists.

4. The five operations. Every operation calls authorize before it reads anything else, then validates, then writes in one unit of work. Answers in this order: 403 forbidden with reason subject_kind_not_allowed (gate), 404 (unknown or invisible host), 403 forbidden with reason owner_only (a visible host, not an owner), 401 step_up_required, 400 validation_failed for the checks of the body alone, 428 precondition_required and 412 version_conflict with current, then the checks against stored state (400 validation_failed for a name the host does not report before any 409), success. Schema validation of the generated server and a presence check of If-Match in the merged chain may answer earlier; keep the chain's order where it does and name it in the report.

  • listHosts (host.read, rl.read): authorize(host.read, Resource{Kind: instance}), then Scope; query WHERE ($all OR id = ANY($ids)) ordered by lower(name) then id, keyset cursor over both (cursor helper of M1-31; a foreign or broken cursor 400 cursor_invalid), limit default api.page_size_default, capped at api.page_size_max, no totals; then authorize(host.read, host) per element in filter mode, dropping a mismatch as 07 7.4 rule 3 and the list use cases of M1-48 do. Hosts in every state are listed, retired included. Ruling for docs/DECISIONS.md: retired hosts stay readable records (retention security after retired_at), and the contract has no filter parameter.
  • getHost (host.read, rl.read): authorize(host.read, host); 200 with HostView and ETag: "<version>".
  • createHost (host.manage on instance, rl.admin, x-idempotency-exempt: auth_flow, so no Idempotency-Key handling and no stored answer):
    1. authorize; then body: name trimmed, not empty (required), textcheck.DisplayText (its rule codes), at most host.name_max_chars characters (too_long); assigned_user_id absent or a principal of kind user whose status is not deleted (People.User), else 400 validation_failed with errors[].code unknown_user and pointer /assigned_user_id; reason trimmed, not empty, textcheck.DisplayText, at most audit.reason_max_chars characters.
    2. Name taken by a host that is not retired (case-insensitive, also when the unique index fires in a race): 409 conflict without a reason. Ruling for docs/DECISIONS.md, like group names in M1-29.
    3. PairingCodes.Issue before the unit of work (no slow work inside a transaction).
    4. Unit of work with a rights change (Action: host.manage, Resource: host with the new id, Targets: [host], Principals: [holder] when set): insert the row (UUIDv7, state = pairing through Transition(none, create), pairing_code_hash, pairing_expires_at = t + terminal.pairing_code_ttl, created_by, defaults of 03 for the rest), host.updated, audit allow. The hook writes access.changed for every active owner (added host entry).
    5. 201 with HostPairingCode{host_id, pairing_code, expires_at} and Cache-Control: no-store; the handler calls idempotency.MarkOneTimeSecret(ctx) as a second guard if the M1-20 middleware runs for exempt operations.
  • updateHost (host.manage on host, rl.admin, Idempotency-Key, If-Match required):
    1. authorize; body: at least one of name, assigned_user_id, clear_assigned_user = true, a non-empty add_session_users or revoke_session_users, else 400 validation_failed with an empty pointer and errors[].code required; name and reason as in createHost; assigned_user_id with clear_assigned_user = true gives 400 validation_failed with errors[].code mutually_exclusive and pointer /clear_assigned_user; each add_session_users entry: user_id as assigned_user_id (pointer /add_session_users/<i>/user_id, unknown_user), os_user through domain.NewOSUser (pattern of 03, at most host.os_user_max_chars, too_long).
    2. Compare If-Match with the current version (428 precondition_required when absent, 412 version_conflict with current when different), then decide "No effect" in a read before any rights change (phase 1 raises permission_version unconditionally): same name, same holder, nothing to add and only people without a valid assignment to revoke gives 200 with the current host, no new version, no event, no rights version, and one audit entry with no_change: true.
    3. Unit of work with a rights change (Targets: [host], Principals: old and new holder, every person in both lists): lock the host row FOR UPDATE, compare version again (412 on a race); a retired host gives 409 conflict (ruling: retired is final, 24 3.1); revocations first, then additions (Design note 6); the name and holder change; version + 1; one host.updated; the audit entries of Design note 9. Any 400 or 409 inside rolls everything back.
    4. 200 with the HostView and ETag.
  • setHostState (host.manage on host, rl.admin, Idempotency-Key, If-Match required, body HostStateSet):
    1. authorize; reason as above; If-Match as above.
    2. Requested state equal to the current one (also disabled on a host disabled with duplicate_connection, and retired on a retired host): 200 without change, audit no_change: true.
    3. Otherwise the cause (active from disabled is enable, disabled is disable, retired is retire) through domain.Transition; ErrInvalidTransition gives 409 invalid_state_transition before any write.
    4. Unit of work with a rights change (Targets: [host]): lock the row, compare version, run Transition again, write state, disabled_at and disabled_reason (set with disable, cleared with enable and retire), retired_at with retire, version + 1; when EndSessions is set, EndSessions(host, all people, CloseReason) (Design note 7); host.updated; audit. Ruling for docs/DECISIONS.md: disabled_at describes the current disabled state and is cleared on enable and retire; the audit keeps the history.
    5. 200 with the HostView and ETag.

Ruling for docs/DECISIONS.md: every host.manage write with an effect runs as a rights change (05 rule 1.7, Permissions yes), also a pure rename; a write without effect raises nothing (06 2 "No effect").

5. Errors this ticket introduces. Two errors[].code values of 400 validation_failed, added to 24 13 row validation_failed in the same change: unknown_user (a person id that is unknown, not a human or deleted) and mutually_exclusive (assigned_user_id together with clear_assigned_user = true). Everything else uses registered codes: required, too_long, the rule codes of textcheck, session_user_not_on_host, 409 conflict (name taken, retired host, reason: session_user_taken), 409 invalid_state_transition, 412 version_conflict, 428 precondition_required, 422 idempotency_key_reuse, 400 cursor_invalid, 401 step_up_required, 403 forbidden, 404. Problem bodies never echo a host name, a session user name or a reason.

6. Session users (T12). domain.PlanAssignments(reported []ReportedSessionUser, rows []SessionUserRow, revoke []ids.ID, add []SessionUserAssign) (Plan, error) is pure; the use case feeds it the locked rows of the host (FOR UPDATE, ascending id).

  • Revocations first: each listed person with a valid assignment gets revoked_at = t, revoked_by = the owner; a listed person without one has no effect for that entry. Then EndSessions(host, that person, access_revoked).
  • Additions after the revocations, so one request can move a person to a new session user: the name must equal a reported os_user exactly, including case (else 400 validation_failed with errors[].code session_user_not_on_host and pointer /add_session_users/<i>/os_user); then a row of this host with the same lower(os_user), valid or ended and of any person, or a remaining valid assignment of the same person, gives 409 conflict with reason: session_user_taken. All 400 checks of all entries come before any 409 check, and both before any write. Duplicates inside one request fall under the same two rules.
  • Ruling for docs/DECISIONS.md: a reported name matches only exactly, because the host service compares osu exactly with host_service.session_users (24 5.3) and a case variant would fail there with 4104; uniqueness stays case-insensitive (03). Update 24 2.2 point 5 with one sentence.
  • Rows are never deleted or updated except revoked_at and revoked_by; the database unique indexes are the last line (map 23505 on them to the same 409).
  • HostView.session_users lists the valid assignments ordered by created_at then id, absent when there is none.

7. Ending sessions in the same transaction. app.EndSessions(ctx context.Context, sel SessionSelector, reason domain.CloseReason) (int, error) with SessionSelector{HostID, UserID ids.ID} (zero UserID means every person) runs only inside the caller's unit of work (outside it returns an internal error). It locks the rows with state <> 'closed' of the selector FOR UPDATE in ascending id, sets state = closed, closed_at = t, close_reason, detached_at = NULL (03 check (state = 'detached') = (detached_at IS NOT NULL)), leaves close_detail and closed_by NULL (a server end, 03), raises version, and writes one audit entry per session: action terminal.terminate, decision allow, actor kind system (05 rule 1.10), resource terminal_session, details session_id, close_reason, bytes_in, bytes_out, duration_ms (closed_at - opened_at). It is the one function every later end goes through: M2-44 adds the event terminal_session.changed, the close 4100 of the device stream and session.close to the host service (after commit), M2-46 the metric bothy.terminal.sessions.closed and its subscribers; leave a documented extension point, no empty stubs. Ruling for docs/DECISIONS.md: terminal_session.changed comes with M2-44 (PLAN row), because no session can be opened before it, so no client can miss the event.

8. The host representation. view.go maps a row to HostView: disabled_reason only with disabled; platform, reported_is_server_host, require_local_confirm, host_version, os_version, last_seen_at, key_fingerprint, noise_public_key (base64url without padding) only when set; credential_holder_id only with personal; profiles and doctor from the jsonb columns (empty lists when nothing is reported, never absent); reported_session_users only once the host has been paired (paired_at set; ruling: "Absent before pairing" of the schema); session_users as Design note 6; connected from Presence. Until M2-43 builds control connections, internal/wire binds NoConnections, whose false is the truth on a server without the route /hosts/connect; M2-43 replaces the binding with its registry (ruling for docs/DECISIONS.md).

9. Events and audit.

  • host.updated through eventlog.Appender in the same unit of work, one per transaction with an effect: Type host.updated, SchemaVersion 1, ResourceKind host, ResourceID, ResourceVersion = the new version, empty scope, no audience, ReceiveAction host.read, actor from the request, payload HostUpdatedPayload{host} where host is the HostView without noise_public_key; key_changed absent (only M2-41 sets it to true). Ruling for docs/DECISIONS.md: "never keys" (05 6) means no key member in the event; the fingerprint is a digest, not a key, and stays so clients can warn; update the description of HostUpdatedPayload.host in openapi.yaml.
  • Audit of host.manage, written in the unit of work through auditing.Recorder.Record with the action, resource host and id. Details (flat, 19 SEC-AUD-5): operation (code create, update, state or session_user), reason (auditing.ReasonText), and per operation: create: to_assigned_user_id when set; update: name_changed (bool), from_assigned_user_id and to_assigned_user_id when the holder changes; state: from_state, to_state, disabled_reason when set; session_user: change (assigned or revoked), user_id, os_user (auditing.OSUserName). An updateHost writes one update entry when name or holder change and one session_user entry per assignment and per end, all with the request's correlation_id; a request without effect writes one entry of its operation with no_change: true. Never the host name (24 10.4: no host names outside the API and host.updated). Ruling for docs/DECISIONS.md: one entry per assignment matches 24 10.1 "per assignment or end" and keeps the details flat.
  • auditing.OSUserName(s string) Value: accepted only under the reserved member os_user, validated by the audit domain against ^[A-Za-z0-9_][A-Za-z0-9_.-]*$ (the pattern of 03 host_session_users.os_user; the length is the caller's, checked with host.os_user_max_chars). Update 19 SEC-AUD-5: "If an action needs further free text, its ticket extends the schema here and in the code." M2-43 reuses it for host.connect.
  • Denials are recorded by the authz layer as for every action (aggregated per audit.deny_aggregate_window, R-AZ-04); host.read allows are not audited (Audit no).

10. The pairing code. identity owns the token format and the pepper, so the code comes from there (02 4.4: terminal may use the identity port):

  • domain/apitoken: kind hpc with the format of ADR 0061 (43 Base62 characters of 256 random bits, CRC-32 checksum in 6 Base62 characters, total length as the other kinds).
  • identity.Tokens.NewHostPairingCode(ctx context.Context) (PairingCode, error) with PairingCode{Secret <the redacting secret type of M1-44>; Hash []byte}, Hash = HMAC-SHA-256 with the pepper of auth.token_pepper_ref, like api_tokens.token_hash. No database access in this method.
  • The authenticator refuses a bearer value of kind hpc with 401 unauthenticated without a database call (like whi, M1-44).
  • testdata/checksum_vectors.json: hpc vectors with EXAMPLE in the random part; tools/ci/tests/test_gitleaks_rules.py then covers hpc through its cross-check, and .gitleaks.toml already lists hpc.
  • Ruling for docs/DECISIONS.md: createHost must return a code (openapi HostPairingCode and the check (state = 'pairing') = (pairing_code_hash IS NOT NULL) of 03), so the kind hpc and its issuer come with this ticket; M2-41 adds the redemption hash and reuses PairingCodes for issueHostPairingCode (plan gap in the report).

11. Migration. One goose file with the three tables exactly as 03 4.14: every column with type, nullability and default; every check of 03 as a named constraint (hosts_state_check, hosts_platform_check, hosts_llm_credentials_check, hosts_disabled_reason_check, hosts_noise_public_key_length_check, hosts_personal_holder_check, hosts_pairing_hash_check, hosts_keys_check, hosts_confirmed_check, hosts_retired_check, hosts_disabled_check, host_session_users_os_user_check, host_session_users_revoked_check, terminal_sessions_state_check, terminal_sessions_close_reason_check, terminal_sessions_closed_check, terminal_sessions_reason_check, terminal_sessions_detail_check, terminal_sessions_detached_check; follow M1-27's naming where it differs); every unique and index of 03 (partial uniques on auth_jkt, pairing_code_hash and lower(name) WHERE state <> 'retired'; (id, host_id, user_id), (host_id, lower(os_user)) over all rows and (host_id, user_id) WHERE revoked_at IS NULL on host_session_users; the indexes of terminal_sessions including BRIN (opened_at)); every foreign key of 03 with ON DELETE RESTRICT, including dev_environment_id -> dev_environments(id) only if that table exists on the base branch (otherwise a plain uuid column with a comment naming V2-01, which adds the key, as M1-27 did for its deferred keys and M2-05 for runs.dev_environment_id; the column stays NULL here); the composite foreign key of terminal_sessions (R-DM-40); and ALTER TABLE grants ADD CONSTRAINT grants_target_host_id_fkey FOREIGN KEY (target_host_id) REFERENCES hosts(id) ON DELETE RESTRICT. A host-target grant row without a host cannot exist in production (no path created one), so the constraint validates on the existing rows. make migrations-lint and make migrations-lock.

12. Signing-key purpose terminal. Add terminal to instance.Purpose; EnsureActive creates an active key for it like for the other purposes; the window table gets terminal: terminal.session_token_ttl plus auth.clock_leeway; no rotation interval, so the job instance.key_rotation rotates it only on owner request (rotateSigningKey with purpose terminal, already in SigningKeyPurpose) and in an emergency; getJwks keeps publishing only access_token and invocation. Ruling for docs/DECISIONS.md: like webhook_out, the purpose rotates on request, because 19 10 names no interval for it and the host service fetches the keys over its control connection; update the first row of 19 10.

13. Canary (SEC-T10). If services/server/test/canary (M1-69) exists on the base branch, add the five operations to its scenario with a run-unique canary host name: create, rename, get, list, state change, and a createHost with the canary name as a duplicate (409 conflict); its scenario fails anyway for an enabled operation it lacks. If it does not exist, write TestHostCanary_SECT10 in internal/terminal/adapters/httpapi with the same steps through the real HTTP chain, asserting the canary absent from the captured logs, the in-memory span and metric exporters of the test build, every audit_events.details, every problem body and every event_log row except the host.updated payloads of that host; M1-69 later covers the operations because its scenario must contain every enabled operation. Either way the pairing code of the run is a second canary with the same assertions, including host.updated and idempotency_keys.

14. Receivers of host.updated. The gateway delivery (M1-65) and the catch-up (M1-63) are not in this ticket's closure. The tests prove the receive decision with authorize(host.read, host) in filter mode for every principal of the test world; if listEvents (M1-63) is enabled on the base branch, they also read the event through it as owner, holder and a stranger (owner and holder receive it, the stranger does not). Empty scope makes the hub treat the event as instance level (M1-65 "Hub indices and candidates"), so nothing in the hub changes.

15. Locks, concurrency and rulings. Lock order (03 6.3): permission_version (phase 1 of the rights change), the hosts row FOR UPDATE, the host_session_users rows of the host in ascending id, the terminal_sessions rows in ascending id, then event_seq and audit_seq. Deadlocks and lock timeouts are retried by the unit of work (database.tx_retry_max). Two parallel createHost with the same name give one 201 and one 409 conflict; two parallel updateHost that give the same reported name to Alex and to Sam give one 200 and one 409 conflict with reason: session_user_taken; two writes with the same If-Match give one success and one 412 version_conflict. Rulings for docs/DECISIONS.md, one row each with reason, in English: Design note 2 (sessions end on every transition into a state other than active), 3 (I6 exception by SR11; the gate; owners' implicit terminal in access.changed; grants on retired hosts), 4 (listing retired hosts and the order; a taken name without reason; retired hosts refuse updateHost; disabled_at cleared; every write with effect is a rights change), 5 (unknown_user, mutually_exclusive), 6 (exact match of reported names), 7 (terminal_session.changed with M2-44), 8 (reported_session_users only after pairing; NoConnections), 9 (no key in host.updated; one audit entry per assignment; os_user), 10 (kind hpc here), 12 (purpose terminal on request), and the fix of AZ-M60: 07 11.1 names R1, whose holder is Alex (row AZ-M54), but a holder reads the host (07 2.12 point 5), so the row contradicts the visibility rule; it becomes a host R3 without holder on which Alex holds no grant. Facts already in the specification are not repeated there.

Tests (write them first)

World for the integration tests: real identity, authz, audit, sync, instance and terminal against PostgreSQL 18 through pgtest, the real HTTP chain, real access tokens with DPoP and real step-up (M1-34, M1-35 test helpers), personal tokens and agent tokens from the identity test helpers (not over HTTP), a fake clock. Fakes only for authz.StructureSource (empty structure) and authz.AgentPolicySource (no agents), so I1 holds trivially. People: Robin is the owner; Alex and Sam are people without owner rights; group Leser has the member Sam. Hosts through terminaltest: R1 "Build server" (active, holder Alex, keys set, reported session users bothy-s-alex, bothy-s-robin, bothy-s-sam, bothy-s-shared and BOTHY-S-SHARED), R2 "Lab machine" (active, no holder, reported bothy-s-robin, bothy-s-sam), R3 "Spare machine" (active, no holder); Robin grants terminal on R2 to Sam with an expires_at and on R3 to the group Leser (through createGrant). Test names carry the spec IDs. Every response is checked by the response validation of the test build.

Unit:

  • internal/terminal/domain: TestHostTransition_Table (every pair of a state before, also disabled with each disabled_reason, and a cause: allowed exactly as the table of Design note 2 with target state, disabled_reason, EndSessions and close reason; every other pair ErrInvalidTransition; the count comes from the table, not from a literal); TestNewHostName (blank, trimmed, control and bidi characters, exactly host.name_max_chars characters and one more); TestNewOSUser (pattern of 03, a path such as ../x or a/b, a leading ., exactly host.os_user_max_chars and one more); TestPlanAssignments (exact reported match; case variant not reported gives session_user_not_on_host; name of another person, valid or ended, also in another case, gives session_user_taken; second valid assignment of the same person; revoke then add for the same person in one request; revoke without assignment has no effect; all 400 before any 409); TestCloseLists (the lists of 24 10.3 equal the enums TerminalCloseReason and TerminalCloseDetail of openapi.yaml, read from the file).
  • internal/authz: TestHostRequirement (host.read: Robin allow; Alex on R1 allow (holder); Sam on R2 allow (grant), on R3 allow (group), on R2 at exactly the grant's expires_at not_found; Alex on R2 not_found; an agent, an app and Alex with a personal token: forbidden with subject_kind_not_allowed for R1 and for a random id alike; host.manage: Robin allow, Alex on R1 forbidden with owner_only, Sam on R2 forbidden with owner_only, Sam on R1 not_found, Robin with a personal token forbidden with subject_kind_not_allowed, Robin with a step-up older than auth.step_up_max_age step_up_required); TestAZM60_InstanceGrantGivesNoHost at decision level (Alex with an instance grant trigger with approve_actions, upload, export, manage: not_found with no_grant for R3; R1 stays visible because Alex holds it); TestSnapshot_HostGrants (direct, group, expired, revoked, ValidUntil); TestScope_Hosts (owner AllHosts; Alex [R1]; Sam [R2, R3] in ascending order; ViewAs intersection); TestDiffVisible_Hosts (added and removed host entries with capabilities: ["terminal"] and without level, owner gains every host, no entry for a holder change); TestDeferredActionsGolden (without host.read and host.manage); the source test of M1-28 for the text owner or assignee or +terminal; the AZ-P8 differential test with hosts.
  • internal/identity: TestHostPairingCode_Mint (kind hpc, length and checksum after ADR 0061, Hash equals the HMAC with the pepper, the secret prints [redacted]); TestAuthenticator_RejectsPairingCode (401 and zero repository calls); the checksum vectors with hpc.
  • internal/instance: TestEnsureActive_TerminalPurpose (an empty database gets an active key for each of the five purposes; getJwks lists no terminal key; a terminal key in retiring turns retired only after terminal.session_token_ttl plus auth.clock_leeway).
  • internal/audit: TestDetails_OSUserName (accepted only under os_user, a value outside the pattern is rejected at Record).

Integration (PostgreSQL 18):

  • TestMigration_Hosts: columns, defaults and every check of 03 (pairing exactly with a hash; keys required in confirming, active, disabled; confirmed_at in active and disabled; retired_at exactly with retired; disabled_reason exactly with disabled; personal exactly with a holder; 32-byte noise_public_key); the partial uniques (a second live host named "build SERVER" fails, a retired one does not); foreign keys ON DELETE RESTRICT.
  • TestMigration_HostSessionUsers (PLAN row): bothy-s-sam for Sam on R2, then Bothy-S-Sam for Alex on R2 fails on the unique index, also after Sam's row is ended; a second valid row for Sam on R2 fails, after the end of the first a new name succeeds; an ended row stays with its name; the same name on another host succeeds; deleting R2 with rows fails.
  • TestMigration_TerminalSessions_RDM40 (PLAN row): a session with the host_session_user_id of Alex on R1 succeeds for (R1, Alex), fails for (R2, Alex) (another host) and for (R1, Sam) (another person) on the composite foreign key; the checks of state, close_reason, close_detail, detached_at; the columns opened_at, last_input_at, detached_at, closed_at exist.
  • TestMigration_GrantsTargetHostFK: a host-target grant with a random target_host_id fails; with R2 it succeeds; deleting R2 then fails.
  • TestCreateHost_PairingCode: 201, pairing_code matches the pattern of HostPairingCode with a valid checksum, hosts.pairing_code_hash equals the HMAC of the code, pairing_expires_at equals the fake clock plus terminal.pairing_code_ttl, state = pairing, version = 1, Cache-Control: no-store; one host.updated; one audit entry create; access.changed for Robin with an added host entry; the code appears in no other row of the database, in no log line and in no event.
  • TestCreateHost_Validation: name taken by a live host in another case 409 conflict; the name of a retired host may be reused; control or bidi characters and a name of host.name_max_chars plus one characters 400 validation_failed; an unknown, agent or deleted holder 400 validation_failed with errors[].code unknown_user; a missing or blank reason 400 validation_failed with errors[].code required; Alex 403 forbidden with reason owner_only; nothing written in any case.
  • TestHostRead_Visibility (PLAN row host.read): listHosts as Robin gives R1, R2, R3 and the created hosts in the order lower(name), id; as Alex only R1; as Sam R2 and R3; after the clock passes Sam's expires_at only R3; getHost the same; every other combination 404; ETag equals the version; cursor round trip with limit 1 and a foreign cursor 400 cursor_invalid.
  • TestAZM60_InstanceGrantGivesNoHost (AZ-M60): Robin creates an instance grant for Alex with trigger and approve_actions, upload, export, manage; getHost(R3) as Alex gives 404 identical to a random id, listHosts lacks R3, R1 stays visible as Alex's own host.
  • TestAZP4_HostNotFoundEquality: for getHost, updateHost and setHostState, Sam on R1 and Sam on a random id get the same status, body and headers.
  • TestHostRequirement_HTTP: an agent token, an app token (if M2-24 is merged; otherwise only the agent) and Alex's personal token get 403 forbidden with reason subject_kind_not_allowed on all five operations, for R1 and for a random id alike; one aggregated deny entry each.
  • TestUpdateHost_NameAndHolder: rename and new holder Sam on R3 give 200, version + 1, one host.updated, one audit entry update with name_changed, from_assigned_user_id absent and to_assigned_user_id Sam, a raised permission_version and no access.changed; Sam now reads R3 as holder; clear_assigned_user removes the holder; both fields together 400 validation_failed with errors[].code mutually_exclusive.
  • TestUpdateHost_SessionUsers: Robin assigns bothy-s-sam to Sam and bothy-s-robin to Robin on R2; session_users lists both; one host.updated; two audit entries session_user with change, user_id and os_user; permission_version raised.
  • TestUpdateHost_NameNotReported_TMT28e (TM-T28 (e), server part of updateHost): on R1, bothy-s-typo and BOTHY-S-SAM (case variant of the reported bothy-s-sam) for Sam give 400 validation_failed with errors[].code session_user_not_on_host and pointer /add_session_users/0/os_user; no row, no version, no event, no allow audit entry, permission_version unchanged.
  • TestUpdateHost_SessionUserTaken_TMT28b (TM-T28 (b), server part of updateHost): Alex holds bothy-s-alex on R1 and Robin ends it; giving bothy-s-alex to Sam gives 409 conflict with reason: session_user_taken; giving BOTHY-S-SHARED to Sam after bothy-s-shared went to Robin gives the same; a second valid assignment for Robin gives the same; a request mixing a valid entry and a taken one changes nothing; revoke Sam's assignment and add a new name for Sam in one request succeeds.
  • TestUpdateHost_RevokeEndsSessions (PLAN row): Alex has two sessions on R1 (active, detached) and one closed, Sam one on R1; revoke_session_users: [Alex] closes exactly Alex's two open sessions with access_revoked in the same transaction as the end of the assignment (one commit: a failure injected after the session update rolls back both), the formerly detached session has detached_at NULL after the end, leaves Sam's session and the closed row unchanged, writes two terminal.terminate entries with actor kind system, and the ended row stays with its name.
  • TestUpdateHost_NoChange_IfMatch_Idempotency: a request without effect gives 200 without new version, event or rights version and one audit entry with no_change: true; missing If-Match 428 precondition_required; a stale one 412 version_conflict with current; a replay with the same Idempotency-Key returns the stored answer and writes nothing; a retired host 409 conflict.
  • TestSetHostState_Transitions (PLAN row): over HTTP from each state reachable here (fixtures for pairing, confirming, active, disabled with owner, disabled with duplicate_connection, retired) to each of active, disabled, retired: the allowed ones 200 with the new state, the same state 200 with no_change: true, every other 409 invalid_state_transition with nothing written, enabling after duplicate_connection included.
  • TestSetHostState_LeavingActiveEndsSessions (PLAN row): disabling R1 with sessions in opening, active and detached closes all three with host_disabled (the formerly detached session has detached_at NULL after the end), raises permission_version and writes host.updated and the audit entries in one transaction (a failure injected after the session update leaves the host active, the sessions open and the counter unchanged); retiring R2 closes its sessions with host_retired; sessions of other hosts are untouched.
  • TestHostManage_StepUpReasonAudit (PLAN row): for createHost, updateHost and setHostState: Robin with a step-up older than auth.step_up_max_age gets 401 step_up_required and nothing changes; a missing, blank, bidi or audit.reason_max_chars plus one characters reason gives 400 validation_failed; each successful write has exactly the audit entries of Design note 9 with the reason, the details validate against audit_details.v1 and contain no host name; each refusal by authorize has a deny entry.
  • TestHostUpdated_Event: every host.updated row validates against HostUpdatedEvent; scope empty; resource.version equals the new version; the payload of R1 (keys set by the fixture) has key_fingerprint but no noise_public_key and no other key, no pairing code, no null; key_changed absent; receive decision as Design note 14: Robin and Alex receive R1's event, Sam does not until he holds a grant.
  • TestCreateGrant_HostTarget: Robin grants terminal on R1 to Sam: 201, Sam gets access.changed with an added host entry, grant.changed with an empty scope; revoking it gives removed; a group grant gives the members the entry; Alex (holder) creating a grant on R1 gets 403 forbidden with reason owner_only; a random host id 404; a retired host 409 conflict.
  • TestAccessChanged_HostEntries: createHost gives Robin an added host entry and nobody else an entry; addOwner for Alex gives Alex added entries for every host; setHostState and a holder change give no access.changed; every payload validates against AccessChangedPayload.
  • TestConcurrency_Hosts: the three races of Design note 15, each in both orders.
  • TestHostCanary_SECT10 or the M1-69 scenario extension (Design note 13).
  • TestAZP1_HostRoutes: the five operations are registered with host.read and host.manage and their rate-limit classes, no other route is added, and the server starts with both entries no longer deferred.
  • TestWire_HostSource: the production wiring binds the terminal implementation as authz.HostSource and NoConnections as Presence; the golden registrations are current.

Red team focus of the milestone (PLAN 8 "Red team", terminal paragraph) that this ticket touches: opening through agent, app and personal tokens (TestHostRequirement, TestHostRequirement_HTTP), a session user of another person (TestUpdateHost_SessionUserTaken_TMT28b, TestMigration_TerminalSessions_RDM40), sessions after each kind of revocation that exists here (TestUpdateHost_RevokeEndsSessions, TestSetHostState_LeavingActiveEndsSessions), pairing with an intercepted or guessed code (TestCreateHost_PairingCode, TestHostPairingCode_Mint); name them in the red team request.

Commands that must pass, from the repository root:

cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/authz/... ./internal/identity/... ./internal/instance/... ./internal/audit/... ./internal/kernel/... ./internal/wire/... ./api/...
make migrations-lock
make migrations-lint
make codegen-check
make arch
make register-check
make api-lint
make api-checks
make api-tables
make config-reference
make config-doc-check
make gitleaks
make docs
make check

Acceptance criteria

  • hosts, host_session_users and terminal_sessions exist exactly as 03 4.14, and grants.target_host_id references hosts(id); the migration tests for T12 (name per host unique over all rows without regard to case, at most one valid assignment per person, ended rows stay) and R-DM-40 (composite foreign key) are green.
  • Every transition of 24 "States" is allowed and every other one refused with 409 invalid_state_transition; a transition out of active ends all sessions of the host and raises permission_version in the same transaction.
  • host.read: owners read every host, the holder the own one, a grant terminal in effect that host; everyone else gets 404 identical to an unknown id; AZ-M60 is green (a grant on the instance gives no host).
  • host.manage is owner only with step-up, a mandatory reason and audit; agents, apps and personal tokens get 403 forbidden with reason subject_kind_not_allowed.
  • updateHost with a name the host does not report gives 400 validation_failed with errors[].code session_user_not_on_host (TM-T28 (e)), and with a taken name 409 conflict with reason: session_user_taken (TM-T28 (b)), both without any change; the end of an assignment ends the sessions of that person in the same transaction.
  • createHost returns the pairing code exactly once and stores only its HMAC; the code appears nowhere else.
  • host.updated is written on every change, validates against HostUpdatedEvent and never carries a key; the canary scenario (SEC-T10) contains a host name and finds it nowhere it must not be.
  • host.read and host.manage are real requirements and no longer in the deferred golden list; host entries appear in access.changed; the signing-key purpose terminal has an active key after start.
  • 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 terminal, authz, identity, instance and audit updated (port Hosts, HostSource, the atoms and the gate, the life cycle, the extension points of EndSessions and Presence for M2-43, M2-44 and M2-46, the kind hpc, the purpose terminal, the member os_user).
  • Rulings of Design note 15 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 07, 08, 19, 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, for example: the delivery of host.updated through the gateway when M1-65 was not merged (only the receive decision is tested), and that no session can exist in production before M2-44 (the session tests use fixture rows).
  5. Follow-ups that could not be done in this scope, each with its reason; every difference to the M1 names of this brief (D-W-100); which of the conditional branches of Design notes 11, 13 and 14 you took and why; spec gaps found, among them that 24 3.1 has no transition from pairing or confirming to retired, so a host created by mistake can only be renamed, never retired.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.