Skip to content

M2-39 Host records and the target host

No Date Ruling Reason
D-M2-39-1 2026-10-14 The ticket is built on feat/M2-39-terminal-hosts from int/m1-wave14 (fd504d9) in its own worktree and is not merged locally; the PR lane ports it (task of the workflow over the brief's local merge). The base copy of chapter 24, the terminal part of chapter 03 (hosts.reported_session_users, host_session_users, the composite key of terminal_sessions, R-DM-39 to R-DM-41, host.os_user_max_chars) and the host schemas of openapi.yaml lag plan/spezifikation; this branch carries the plan text of these parts, merged three-way so the base edits of M1-03, M1-15, M1-50 and M2-38 stay, and regenerates the REST models and the SDK clients. Ruling T12, the heart of the ticket, exists only in the plan text; carrying it verbatim is no deviation from the core contract, and the plan port then brings identical content.
D-M2-39-2 2026-10-14 Every transition into a state other than active ends the open sessions of the host in the same transaction; where the table names no close reason (into pairing or confirming) the reason is host_rekeyed. A request for the state a host already has is no transition and answers without effect. pairing and confirming cannot be retired (24 3.1 draws no such edge; spec gap in the report). When leaving disabled there are normally no open sessions; ending them anyway costs one indexed query and closes any row a crash left behind.
D-M2-39-3 2026-10-14 TerminalOn is true for an effective owner on every host and for a grant terminal in effect; the exception of I6 for owners on a personal host (07 2.12 point 3) is decided by the guard SR11 of M2-40 with i6_holder_only, after the requirement. Before M2-40 no host can be personal (only confirmHost and setHostLlmCredentials set it), so the atom does not repeat the guard.
D-M2-39-4 2026-10-14 host.read is All(HumanSession, Any(Owner, Assignee, Terminal)), host.manage All(HumanSession, Owner). A requirement whose first part is HumanSession is a gate: authorize and Scope evaluate it right after the liveness and pending checks, before snapshot, resolution and visibility, and answer forbidden with subject_kind_not_allowed; 07 4.2 says so in one paragraph. 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; the answer depends only on the principal, so it reveals nothing (AZ-P4).
D-M2-39-5 2026-10-14 access.changed lists the hosts with TerminalOn per principal: over every host for an effective owner and over the host grants otherwise; a host is added with capabilities: ["terminal"] and no level, removed with kind and id only, never changed. State and holder do not enter the set, so setHostState and a holder change write no access.changed; owner.add, owner.remove and createHost report hosts like every other target. A rights change on a host target affects every active owner, the current holder and the subjects of the grants on the host (groups expanded). M2-46 can end sessions from removed entries alone; the set follows 07 2.12 point 3, which names neither state nor holder.
D-M2-39-6 2026-10-14 A grant on a host needs an existing host (404 otherwise) and refuses a retired one with 409 conflict without a reason; grant.changed of a host grant has an empty scope. getGrant and listGrants with target_kind=host read host grants like instance grants (owner only); a non-owner gets 404 for a host grant and for the host filter, the answer of an unknown id. A retired host is final (24 3.1), a grant on it could never work; the holder sees the host, not the grants on it.
D-M2-39-7 2026-10-14 listHosts asks Scope(host.read) (new members HostIDs, ascending, and AllHosts, memoised per request only) instead of authorizing host.read on the instance, then filters each item with authorize in filter mode; it lists hosts in every state, retired ones included, ordered by lower(name) then id, with a keyset cursor bound to the caller. The catalogue gives host.read only the resource host, so the brief's ByKind{instance} cannot be reached; Scope runs the same liveness checks and the gate. Retired hosts stay readable records (retention security after retired_at), and the contract has no filter parameter.
D-M2-39-8 2026-10-14 A name taken by a live host (without regard to case, also when the unique index fires in a race) is 409 conflict without a reason; updateHost on a retired host is 409 conflict; disabled_at describes the current disabled state and is cleared on enable and retire; every host.manage write with an effect is a rights change, also a pure rename; a write without effect raises nothing and audits no_change. Like group names in M1-29; the audit keeps the history; 05 rule 1.7 (Rechte ja) and 06 section 2 "Ohne Wirkung".
D-M2-39-9 2026-10-14 A session user name must equal a reported name exactly, including case (else 400 validation_failed with errors[].code session_user_not_on_host); uniqueness per host stays without regard to case (03). Two new errors[].code values of validation_failed: unknown_user (a person id that is unknown, not a human or deleted) and mutually_exclusive (assigned_user_id with clear_assigned_user = true); 24 section 13 names them. The host service compares osu exactly with host_service.session_users (24 5.3), so a case variant would fail there with 4104.
D-M2-39-10 2026-10-14 terminal_session.changed comes with M2-44; EndSessions is the one path of every server end and writes terminal.terminate as the system (no actor) with session_id, close_reason, bytes_in, bytes_out and duration_ms. No session can be opened before M2-44, so no client can miss the event.
D-M2-39-11 2026-10-14 reported_session_users is shown only once a host has been paired (paired_at set); connected comes from Presence, bound to NoConnections until M2-43 replaces it with the registry of the control connections. The schema says "Absent before pairing"; without the route /hosts/connect no host is connected, so false is the truth.
D-M2-39-12 2026-10-14 host.updated carries the host without noise_public_key (the fingerprint stays, it is a digest); key_changed stays absent until M2-41. host.manage audits one update entry when name or holder change and one session_user entry per assignment and per end with the name in the new reserved member os_user (auditing.OSUserName, pattern of host_session_users.os_user); never the host name. "never keys" (05 6) means no key member; one entry per assignment matches 24 10.1 "per assignment or end" and keeps the details flat.
D-M2-39-13 2026-10-14 The pairing code is the token kind hpc of ADR 0061, issued by identity.Tokens.NewHostPairingCode (redacting secret and HMAC with the token pepper, no database); the authenticator refuses it before any lookup. createHost keeps the Idempotency-Key of the base contract (M1-03/6) instead of auth_flow of the brief: the handler marks the one-time secret, a replay answers 409 conflict with reason: one_time_secret. createHost must return a code (openapi HostPairingCode, the check (state = 'pairing') = (pairing_code_hash IS NOT NULL)), and identity owns the token format and the pepper; the merged contract wins over the brief. M2-41 adds the redemption and reuses PairingCodes.
D-M2-39-14 2026-10-14 The signing-key purpose terminal gets an active key at every start; like webhook_out it rotates only on owner request, its retiring window is terminal.session_token_ttl plus auth.clock_leeway, and getJwks does not publish it. rotateSigningKey accepts terminal now; the clause of M1-32/2 that refused it until M2-39 is superseded. 19 10 names no interval for it, and the host service fetches the keys over its control connection (M2-43).
D-M2-39-15 2026-10-14 AZ-M60 of 07 11.1 names a host R3 without holder on which Alex holds no grant, instead of R1. R1's holder is Alex (row AZ-M54), and a holder reads the host (07 2.12 point 5), so the old row contradicted the visibility rule.
D-M2-39-16 2026-10-14 Names that differ from the brief: the fixtures live in services/server/test/terminaltest and the HTTP world in services/server/test/terminalworld (test code, outside the coverage measurement), the tests are split over terminal/app, adapters/postgres and adapters/httpapi; Precondition replaces IfMatch int64 in the commands; People.User returns a found flag; the checksum vectors of apitoken carry no kind, so hpc adds no vector (the format test covers it); the test world authenticates with the test authenticator of httpservertest (sessions, step-up times and tokens through the fake credential source), like the worlds of M1-48 and M1-51. audit.reason_max_chars enters the schema here (M2-24 adds the same key). make coverage measures each package by its own tests (D-M2-24-23); go-arch-lint admits test support only under test/.
D-M2-39-17 2026-10-14 httpserver.DecodeCursor refuses a cursor key with a NUL character in any string with 400 cursor_invalid, for every list. The cursor has no signature (M1-18), so a client can forge a key whose string PostgreSQL text cannot hold (SQLSTATE 22021, a 500); the red team of M2-39 found it in listHosts after M1-31/16 had the same class, so the codec fixes it once for all lists.
D-M2-39-18 2026-10-18 Port onto main from the integration squash of int/m1-wave15 with the integration fixes INT-W15/3 to /5: the migration is 00028_terminal_hosts.sql (00023 to 00027 are M1-52, M1-53 and M1-55 on main; content and checksum unchanged, terminalVersion 28); authz.AccessViewOf takes spaces as paths with the archived flag and the space levels of M1-52 and the hosts of M2-39 (spaces []Path, hosts []ids.ID, all four maps); the start steps register chat and then terminal, and authorizer, rights hook and admin get both the structure and the host source. The contract of the session users (03 4.14, 24, openapi.yaml) is already on main, so its port commit is not taken; the spec additions of this ticket (07 3 and 4.2, 08 12, 19 secrets and SEC-AUD-5, 24 2.2, 10.1 and 11) are written into the English chapters, the module README is English, and generated files are regenerated, not merged. Tickets are built in parallel on integration bases; main takes each as one pull request with the integration fixes it needs, and main is English.