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, grantterminal"; 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, theAdminport withCreateGrant) and M1-32 (server signing keys,EnsureActive, which this ticket extends with the purposeterminal). 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 servicebothy-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 onterminal.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" (purposeterminal); 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.1server_signing_keys(purposeterminal) andsequence_counters; 4.3grants(target_host_id,levelNULL on hosts, the checks of R-DM-35, the index ontarget_host_id) and the paragraph after it (which host columns raisepermission_version); 4.14 "Terminal (terminal)" withhosts,host_session_usersandterminal_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; 9host.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" (rowhost,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 excepthost.pairandhost.connect,subject_kind_not_allowed); 6 rowshost.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 withkind: "host", affected principals of a target change).docs/spec/07-permissions.md: 2.2 "Targets and inheritance", 2.4 "Capabilities" (rowterminal), 2.5 "Validity of a grant", 2.12 "Hosts and theterminalcapability" (points 1 to 5), 3 "Principal context" (kindhost), 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", "Nonull", "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 3auth_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" (rowsauthz: declaresHostSource;terminal: portsHosts,Sessions,Relay, implementsauthz.HostSource;instance:Keys), 4.4 "Allowed dependencies" (rowterminal:instance,identity,authz,agents), 4.6 "Actions per module" and 4.7 "Event types and WebSocket ops per module" (rowsterminal), 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" (rowterminal.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 (schemaaudit_details.v1), SEC-TERM-3, SEC-TERM-5, SEC-T10, SEC-T18.docs/api/openapi.yaml: operationslistHosts,createHost,getHost,updateHost,setHostState(withx-authz-action,x-rate-limit-class,x-idempotency-exempt, parametersHostIdPath,IdempotencyKey,IfMatchRequired,Cursor,Limit); schemasHost,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; responsesBadRequest,Forbidden,NotFound,Conflict,PreconditionFailed,PreconditionRequired.docs/api/asyncapi.yaml: messageevent.host.updated(x-receive-action: host.read).docs/api/webhooks.md: the table "Not subscribable" (row terminal:host.updatednever goes to webhooks, D-T41).docs/spec/actions.yaml(M1-02): entrieshost.read(rightowner or assignee or +terminal),host.manage(rightowner),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(kindhpc),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.md5.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.mdsection 3 (for the network point only).docs/observability/signals.md4.3a "Terminal" (not built here, M2-46).- Briefs:
docs/briefs/M1-27-authz-grant-store.md(deferred foreign keys,Target.HostID,CheckCreaterule 3),M1-28-authz-core.md(sources, atoms, requirement table, deferred entries, visibility table rowhost, 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(onlydoc.goandREADME.md, P-24),internal/authz(sources.go,domainwith requirements, visibility, snapshot, effective rights andDiffVisible,appwith cache, scope and the rights-change hook,adapters),internal/identity(domain/apitoken, portTokens,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/httpserverandhttpservertest,internal/wire(configkeys_*.go,configchecks.go,configchapters.go,testdata/txn_registrations.golden),architecture/modules.yaml,api/operations.yaml,migrations/withmigrations.sha256,.gitleaks.toml,tools/ci/tests/test_gitleaks_rules.py.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- 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 fromconfirmingtopairingafterterminal.fingerprint_max_attemptsfailures (24 3.2); every other request gives409 invalid_state_transition; a host disabled withdisabled_reason = duplicate_connectioncannot be enabled, only paired again; onlyactiveallows sessions. Every state change raisespermission_version(03 4.3, paragraph aftergrants), and every transition out ofactiveends all sessions of the host (host_disabled,host_retired), in the same transaction. 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 grantterminalin effect on the host (directly or through a group in effect, also when SR10 blocks it) reads that host; everyone else gets404with 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).host.manage(05 4.14): owner only, step-upyes, a mandatory reason, Audityes, Permissionsyes. Agents, apps and personal tokens reach neitherhost.readnorhost.manage:403 forbiddenwithreasonsubject_kind_not_allowed(05 4.14, SR5, R-AZ-23).- 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 capabilityterminal;grants.target_host_idreferenceshosts(id)withON DELETE RESTRICT; a change of the effectiveterminalright appears inaccess.changedas an entry{kind: host, id, capabilities: [terminal]}withoutlevel(04 8.2). - 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 inhosts.reported_session_users, otherwise400 validation_failedwitherrors[].codesession_user_not_on_hostand 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, otherwise409 conflictwithreason: session_user_takenand 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). terminal_sessionsholds 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).host.updated(05 6, 24 10.2): durable, receive actionhost.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.- Audit (24 10.1, 19 SEC-AUD-5): every
host.managewrite 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 actionterminal.terminate. - Pairing code (24 3.2 point 1, ADR 0061, 03 principle 9, D-T32):
createHostreturnsbothy_hpc_<random>_<checksum>exactly once withCache-Control: no-store; only its HMAC-SHA-256 with the server pepper is stored (hosts.pairing_code_hash), valid untilpairing_expires_at= creation plusterminal.pairing_code_ttl; the code never appears in a log, an event, an audit entry, the idempotency store or a test failure message. - Signing-key purpose
terminal(03server_signing_keys, 24 5.3, D-T33):EnsureActivecreates anactivekey for it at every start; it rotates only on owner request, its retiring window isterminal.session_token_ttlplusauth.clock_leeway, andgetJwksdoes not publish it (the host service gets the keys over its control connection, M2-43). - Display texts without control or bidi characters (SEC-IN-4): host names at most
host.name_max_charscharacters, session user names at mosthost.os_user_max_charscharacters and never a path, reasons at mostaudit.reason_max_charscharacters; no JSONnullin any response or event (ADR 0053).
Terminal security requirements (D-W-101):
- Rights per machine: the additional right
terminal, SR10, SR11 with I6, SR12 (07 "Hosts and theterminalcapability"). This ticket builds the base all four stand on and proves it: the targethostoutside the tree (a grant on the instance gives no host, AZ-M60:TestAZM60_InstanceGrantGivesNoHost), the+terminalatom (an owner, or a grantterminalin effect) and the holder atom (TestHostRequirement), visibility ofhostafter 07 2.12 point 5 (TestHostRead_Visibility), grants on hosts only by owners and only withterminalto 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 forterminal.openandterminal.attach, the I6 check atgrant.createon a host and athost.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)). - Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not built here: step-up
freshwith 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: everyhost.manageoperation needs step-upyes(younger thanauth.step_up_max_age) of a human acting in a session; a stale step-up gives401 step_up_requiredand changes nothing, and agents, apps and personal tokens get403 forbiddenwithreasonsubject_kind_not_allowedbefore any lookup of the host (TestHostManage_StepUpReasonAudit,TestHostRequirement). - 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
tailscalereaches 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/v1on the existing API listener, and the server never contacts a host. Proven byTestAZP1_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. - Audit without content: this ticket writes
host.manageentries with codes, IDs, the mandatoryreasonand, per assignment or end, the session user name under the reserved memberos_user, and oneterminal.terminateentry per session it ends withsession_id,close_reason,bytes_in,bytes_outandduration_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 byTestHostManage_StepUpReasonAudit,TestSetHostState_LeavingActiveEndsSessionsand the canary scenario with a host name (SEC-T10, Design note 13). The audit ofterminal.open,terminal.attach,terminal.streamandterminal.closebelongs to M2-44, the content-free telemetry and TM-T06 to M2-46. - Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host, M2-46 adds the server checks (
terminal.idle_timeoutplusterminal.idle_grace,terminal.max_session_duration,terminal.detach_grace), the jobterminal.sweepand TM-T11. This ticket creates the columns they rely on (terminal_sessions.opened_atwith defaultnow(),last_input_at,detached_at,closed_at, the closed list ofclose_reasonwithidle_timeout,max_durationanddetach_expired), proven byTestMigration_TerminalSessions_RDM40; it starts no timer and no job. - Service user and session users without administrator rights (T12, HD rules): M2-42 builds
install,session-user addandremoveanddoctorwith 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 notretired), and only names the host reported. Proven byTestMigration_HostSessionUsers,TestUpdateHost_SessionUserTaken_TMT28bandTestUpdateHost_NameNotReported_TMT28e. - 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 validterminal.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 leavesactive(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 athost.readandhost.manage(TestHostRequirement; TM-T17 and AZ-M59 by M2-40); T-TM-01 (content in the server) throughhost.updatedwithout 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 tableshosts,host_session_usersandterminal_sessionsexactly as 03 4.14, and the foreign keygrants.target_host_id -> hosts(id) ON DELETE RESTRICTthat M1-27 deferred (Design note 11). Thenmake migrations-lock.internal/terminal/hosts.go(root package, port): interfaceHostswithList,Get,Create,Update,SetStateand their DTOs (Design note 1);doc.gogets 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(CloseReasonandCloseDetailwith the closed lists of 24 10.3),reported.go(Go mirrors and decoders ofhost_profiles.v1,host_reported_session_users.v1,host_doctor.v1), with table tests.internal/terminal/app/:ports.go(outgoing portsStore,PairingCodes,People,Presence),hosts.go(the five use cases),sessions_end.go(EndSessions, Design note 7),hostsource.go(the implementation ofauthz.HostSource),view.go(row toHostView, Design note 8), with tests.internal/terminal/adapters/postgres/:queries/hosts.sql,queries/host_session_users.sql,queries/terminal_sessions.sql, the repository implementingapp.Store, thesqlc.yamlentry forterminal; regenerated sqlc code.internal/terminal/adapters/httpapi/: the strict-server methods oflistHosts,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 aterminal_sessionsrow 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.godeclaresHostSourceandHostFacts;domaingets the snapshot mapHosts, the effective functionTerminalOn, the atomsHumanSession,AssigneeandTerminal, the gate of Design note 3, the requirement entries and the mapping row forhost.readandhost.manage, the visibility and resolution ofhost, host entries inDiffVisible;appgets the host part ofScope, the affected principals of ahosttarget in the rights-change hook, the host target inAdmin.CreateGrant; the golden list of deferred actions loseshost.readandhost.manage; the AZ-P8 reference and its world generator learn hosts (Design note 3).internal/kernel/authorize:ScopegetsHostIDs []ids.ID(ascending) andAllHosts bool.internal/kernel/auditingandinternal/audit/domain: the value constructorOSUserName(s string) Value, accepted only under the reserved memberos_user(Design note 9).internal/identity: kindhpcindomain/apitoken; portTokensgetsNewHostPairingCode(ctx context.Context) (PairingCode, error); the authenticator refuses kindhpcwithout a database call;testdata/checksum_vectors.jsongetshpcvectors whose random part starts withEXAMPLE(Design note 10).internal/instance: purposeterminalinPurpose,EnsureActiveand the window table (Design note 12).internal/wire: binding ofterminal.Hosts, of the terminal implementation asauthz.HostSource, ofapp.PairingCodestoidentity.Tokens, ofapp.Peopletoidentity.Directory, ofapp.PresencetoNoConnections(Design note 8); the event typehost.updated; the configuration keys this ticket is the first consumer of amonghost.name_max_chars,host.os_user_max_chars,terminal.pairing_code_ttl,terminal.session_token_ttl(defaults from their chapter tables, chapter constants inconfigchapters.go);testdata/txn_registrations.goldenif registrations change.api/operations.yaml: movelistHosts,createHost,getHost,updateHost,setHostStatefromlatertoenabled; rerun the route generator (M1-17).architecture/modules.yaml: only if the entryterminallacks 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
hostand a randomtarget_host_id: insert ahostsrow first throughterminaltest(the foreign key now exists); the M1-28 snapshot test that expects host grants to be skipped now expects theHostsmap. M1-32'sTestEnsureActive_*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 rowshost.manageandterminal.terminatewith the detail members of Design note 9; 13 rowvalidation_failedwith theerrors[].codevaluesunknown_userandmutually_exclusive(Design note 5).docs/spec/08-identity.md12 "Form": kindhpc(pairing code of a host; onlyhost.manageissues it, onlyhost.pairredeems 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 memberos_user.docs/api/openapi.yaml: description ofHostUpdatedPayload.host: "Never carriesnoise_public_key; clients read keys withgetHost." Thenmake api-lint,make api-checks,make api-tablesand the server code generation.docs/operations/configuration.md: regenerate withmake config-referenceif the schema text changes.- READMEs of
terminal(English like the file: status, portHosts, tables, actions, events,HostSource, the extension points for M2-40 to M2-46),authz,identity,instance,audit;services/server/CHANGELOG.mdunderUnreleased;docs/DECISIONS.mdsection 10 rowsD-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 withhost_rekeyedandkey_changed, writing keys,reported_profiles,reported_session_usersandplatformathost.pair, the redemption lookupHashHostPairingCode, the test thatmake gitleaksfinds a generated code, TM-T19 and TM-T20, TM-T28 (b) and (e) throughconfirmHost(M2-41). M2-41 drives its transitions throughdomain.Transitionof this ticket and reusesPairingCodes. - Rights of the terminal: the entries
host.pair,host.connect,terminal.open,terminal.attach,terminal.stream,terminal.read_own,terminal.closestay deferred; SR10, SR11 with I6, SR12 as guards, the I6 check atgrant.createon a host and athost.manage(03 6.8,409 i6_violation),setHostLlmCredentials, step-upfreshandauth_sessions.step_up_consumed_at, session users and reported names in the rights snapshot, the end of sessions when the holder changes (SR10), the hintterminal_not_effectiveinaccess.preview, AZ-M54 to AZ-M59 and AZ-M61, TM-T08, TM-T09 (M2-40). - The control connection
/hosts/connect,report, the columnsreported_*andlast_seen_atas written data,host.connect,host.presence_changed, the presence registry,duplicate_connection, the close codes4110to4114, closing the host's connections atdisabledandretired(M2-43). - Sessions:
openTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSession,closeTerminalSession, the session token, the relay, the eventterminal_session.changed, the close code4100andsession.closeto the host service (M2-44; they hook intoEndSessions, 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 jobterminal.sweep, the metricsbothy.terminal.*,rl.terminalandrl.host(M2-46); retention jobs of the classsecurity(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_idand SR10 for an own dev environment (V2).- A transition
pairingorconfirming->retired: the diagram of 24 3.1 has none, so it is409 invalid_state_transitionhere (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 ofAGENTS.md. - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- A new migration needs its line in
migrations.sha256: runmake migrations-lockafter creating the file and beforemake check; never edit a merged migration (M1-01, ADR 0007). - Tests bind only
127.0.0.1and port 0; PostgreSQL runs only as a throwaway container on a random port throughpgtest(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.yamllackshost.read,host.manageorterminal.terminate, or itsgrantsmigration lacks the columntarget_host_id, the M1 state is missing on the base branch: stop withVerdict: 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, likeStructureSource(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.goover the terminal store;internal/wirebinds it. -
Snapshot (
domain/snapshot.go): replace "host grants are skipped" by a mapHosts map[ids.ID]Capsbuilt from the grants in effect with targethost(the principal's own and, for people, those of their groups in effect;Grant.InEffectandMembership.InEffectof M1-27); theirexpires_atenterValidUntil. Host grants never enterInstance,SpacesorChannels. - Effective right:
TerminalOn(s Snapshot, hostID ids.ID) boolis true for an effective owner, or whens.Hosts[hostID]containsterminal(07 2.12 point 3). Ruling for docs/DECISIONS.md: the exception of I6 for owners on apersonalhost (07 2.12 point 3, first bullet) is decided by the SR11 guard of M2-40 with reasoni6_holder_only(AZ-M56), which comes after the requirement (07 4.2 step 9); before M2-40 no host can bepersonal(onlyconfirmHostandsetHostLlmCredentialssetllm_credentials), so the atom does not repeat it. - Atoms (requirement table of M1-28):
HumanSession(kinduser, acting in a session, not with an API token; reasonsubject_kind_not_allowed);Assignee(the resource is a host whoseAssignedUserIDis the principal; reasonnot_self);Terminal(TerminalOnfor the resolved host; reasoncapability_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)})(instanceis the resource oflistHosts, aslistGroupsdoes 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.connectandterminal.*except the system action stay deferred. - Gate before resolution: when the requirement of an action is
All(HumanSession, ...),authorizeevaluatesHumanSessionright after step 1 of 07 4.2 (aftercheck_activeand the pending checks, before snapshot, resolution and visibility) and answersforbiddenwithsubject_kind_not_allowed. The answer depends only on the principal, never on the resource, so it reveals nothing (an agent gets the same403for an existing and for a random host id, AZ-P4). Ruling for docs/DECISIONS.md: 05 4.14 refuses agents, apps and personal tokens withsubject_kind_not_allowed(TM-T17, AZ-M59), which visibility first would turn into404; update 07 4.2 with one paragraph. M2-40 uses the same gate forterminal.*. - Resolution: a resource of kind
hostresolves throughHostSource.Host; missing givesnot_foundwithno_grant. Agrantresource with targethostkeeps M1-28's rule (no path in the tree, onlyOwnerpasses) and resolves the host for existence. - Visibility (
domain/visibility.go, rowhost): effective owner, orAssignee, orTerminal; withViewAsboth snapshots must see it; agents and apps never pass the gate. The rowterminal_sessionstays an internal error until M2-40. Scopeforhost.read:AllHostsfor an effective owner; otherwiseHostIDs= keys ofHostswithterminalunited withHostSource.AssignedTo(principal), ascending; withViewAsintersected like the channel lists. Memoised per request (WithRequestScope), never stored in the cache entry.DiffVisible: host entries compare the set{H : TerminalOn(snapshot, H)}, overHostSource.HostIDsfor an effective owner and over the keys ofHostsotherwise; a host in the set after but not before isaddedwithcapabilities: ["terminal"], the reverse isremovedwithkindandidonly; hosts have noleveland nochangedentry (one capability). Ruling for docs/DECISIONS.md: owners' implicitterminalis listed per host, soowner.add,owner.removeandcreateHostreport hosts like every other target, and M2-46 can end sessions fromremovedentries alone; the set does not depend on state or holder (07 2.12 point 3), sosetHostStateand a holder change write noaccess.changed.- Affected principals of a
hosttarget intxn.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 withAddAffectedbefore it writes (a new holder, the people ofadd_session_usersandrevoke_session_users). The difference step drops everyone without a change. Admin.CreateGrantwith targethost: the host must exist (404otherwise, like an unknown space); a host in stateretiredgives409 conflictlike 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 withCheckCreateof M1-27 and the checks of 03 (owner only, people and groups, exactlyterminal).grant.changedfor a host grant has an emptyscope.- 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.readdecisions andScopehost 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}), thenScope; queryWHERE ($all OR id = ANY($ids))ordered bylower(name)thenid, keyset cursor over both (cursor helper of M1-31; a foreign or broken cursor400 cursor_invalid),limitdefaultapi.page_size_default, capped atapi.page_size_max, no totals; thenauthorize(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,retiredincluded. Ruling for docs/DECISIONS.md: retired hosts stay readable records (retentionsecurityafterretired_at), and the contract has no filter parameter.getHost(host.read,rl.read):authorize(host.read, host);200withHostViewandETag: "<version>".createHost(host.manageoninstance,rl.admin,x-idempotency-exempt: auth_flow, so noIdempotency-Keyhandling and no stored answer):authorize; then body:nametrimmed, not empty (required),textcheck.DisplayText(its rule codes), at mosthost.name_max_charscharacters (too_long);assigned_user_idabsent or a principal of kinduserwhose status is notdeleted(People.User), else400 validation_failedwitherrors[].codeunknown_userand pointer/assigned_user_id;reasontrimmed, not empty,textcheck.DisplayText, at mostaudit.reason_max_charscharacters.- Name taken by a host that is not
retired(case-insensitive, also when the unique index fires in a race):409 conflictwithout areason. Ruling for docs/DECISIONS.md, like group names in M1-29. PairingCodes.Issuebefore the unit of work (no slow work inside a transaction).- Unit of work with a rights change (
Action: host.manage,Resource: hostwith the new id,Targets: [host],Principals: [holder]when set): insert the row (UUIDv7,state = pairingthroughTransition(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 writesaccess.changedfor every active owner (addedhost entry). 201withHostPairingCode{host_id, pairing_code, expires_at}andCache-Control: no-store; the handler callsidempotency.MarkOneTimeSecret(ctx)as a second guard if the M1-20 middleware runs for exempt operations.
updateHost(host.manageonhost,rl.admin,Idempotency-Key,If-Matchrequired):authorize; body: at least one ofname,assigned_user_id,clear_assigned_user = true, a non-emptyadd_session_usersorrevoke_session_users, else400 validation_failedwith an empty pointer anderrors[].coderequired;nameandreasonas increateHost;assigned_user_idwithclear_assigned_user = truegives400 validation_failedwitherrors[].codemutually_exclusiveand pointer/clear_assigned_user; eachadd_session_usersentry:user_idasassigned_user_id(pointer/add_session_users/<i>/user_id,unknown_user),os_userthroughdomain.NewOSUser(pattern of 03, at mosthost.os_user_max_chars,too_long).- Compare
If-Matchwith the currentversion(428 precondition_requiredwhen absent,412 version_conflictwithcurrentwhen different), then decide "No effect" in a read before any rights change (phase 1 raisespermission_versionunconditionally): same name, same holder, nothing to add and only people without a valid assignment to revoke gives200with the current host, no new version, no event, no rights version, and one audit entry withno_change: true. - Unit of work with a rights change (
Targets: [host],Principals: old and new holder, every person in both lists): lock the host rowFOR UPDATE, compareversionagain (412on a race); aretiredhost gives409 conflict(ruling: retired is final, 24 3.1); revocations first, then additions (Design note 6); the name and holder change;version + 1; onehost.updated; the audit entries of Design note 9. Any400or409inside rolls everything back. 200with theHostViewandETag.
setHostState(host.manageonhost,rl.admin,Idempotency-Key,If-Matchrequired, bodyHostStateSet):authorize;reasonas above;If-Matchas above.- Requested state equal to the current one (also
disabledon a host disabled withduplicate_connection, andretiredon a retired host):200without change, auditno_change: true. - Otherwise the cause (
activefromdisabledisenable,disabledisdisable,retiredisretire) throughdomain.Transition;ErrInvalidTransitiongives409 invalid_state_transitionbefore any write. - Unit of work with a rights change (
Targets: [host]): lock the row, compareversion, runTransitionagain, writestate,disabled_atanddisabled_reason(set withdisable, cleared withenableandretire),retired_atwithretire,version + 1; whenEndSessionsis set,EndSessions(host, all people, CloseReason)(Design note 7);host.updated; audit. Ruling for docs/DECISIONS.md:disabled_atdescribes the current disabled state and is cleared on enable and retire; the audit keeps the history. 200with theHostViewandETag.
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. ThenEndSessions(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_userexactly, including case (else400 validation_failedwitherrors[].codesession_user_not_on_hostand pointer/add_session_users/<i>/os_user); then a row of this host with the samelower(os_user), valid or ended and of any person, or a remaining valid assignment of the same person, gives409 conflictwithreason: session_user_taken. All400checks of all entries come before any409check, 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
osuexactly withhost_service.session_users(24 5.3) and a case variant would fail there with4104; uniqueness stays case-insensitive (03). Update 24 2.2 point 5 with one sentence. - Rows are never deleted or updated except
revoked_atandrevoked_by; the database unique indexes are the last line (map23505on them to the same409). HostView.session_userslists the valid assignments ordered bycreated_atthenid, 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.updatedthrougheventlog.Appenderin the same unit of work, one per transaction with an effect:Type host.updated,SchemaVersion 1,ResourceKind host,ResourceID,ResourceVersion= the newversion, empty scope, no audience,ReceiveAction host.read, actor from the request, payloadHostUpdatedPayload{host}wherehostis theHostViewwithoutnoise_public_key;key_changedabsent (only M2-41 sets it totrue). 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 ofHostUpdatedPayload.hostin openapi.yaml.- Audit of
host.manage, written in the unit of work throughauditing.Recorder.Recordwith the action, resourcehostand id. Details (flat, 19 SEC-AUD-5):operation(codecreate,update,stateorsession_user),reason(auditing.ReasonText), and per operation:create:to_assigned_user_idwhen set;update:name_changed(bool),from_assigned_user_idandto_assigned_user_idwhen the holder changes;state:from_state,to_state,disabled_reasonwhen set;session_user:change(assignedorrevoked),user_id,os_user(auditing.OSUserName). AnupdateHostwrites oneupdateentry when name or holder change and onesession_userentry per assignment and per end, all with the request'scorrelation_id; a request without effect writes one entry of its operation withno_change: true. Never the host name (24 10.4: no host names outside the API andhost.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 memberos_user, validated by the audit domain against^[A-Za-z0-9_][A-Za-z0-9_.-]*$(the pattern of 03host_session_users.os_user; the length is the caller's, checked withhost.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 forhost.connect.- Denials are recorded by the authz layer as for every action (aggregated per
audit.deny_aggregate_window, R-AZ-04);host.readallows are not audited (Auditno).
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: kindhpcwith 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)withPairingCode{Secret <the redacting secret type of M1-44>; Hash []byte},Hash= HMAC-SHA-256 with the pepper ofauth.token_pepper_ref, likeapi_tokens.token_hash. No database access in this method.- The authenticator refuses a bearer value of kind
hpcwith401 unauthenticatedwithout a database call (likewhi, M1-44). testdata/checksum_vectors.json:hpcvectors withEXAMPLEin the random part;tools/ci/tests/test_gitleaks_rules.pythen covershpcthrough its cross-check, and.gitleaks.tomlalready listshpc.- Ruling for docs/DECISIONS.md:
createHostmust return a code (openapiHostPairingCodeand the check(state = 'pairing') = (pairing_code_hash IS NOT NULL)of 03), so the kindhpcand its issuer come with this ticket; M2-41 adds the redemption hash and reusesPairingCodesforissueHostPairingCode(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, alsodisabledwith eachdisabled_reason, and a cause: allowed exactly as the table of Design note 2 with target state,disabled_reason,EndSessionsand close reason; every other pairErrInvalidTransition; the count comes from the table, not from a literal);TestNewHostName(blank, trimmed, control and bidi characters, exactlyhost.name_max_charscharacters and one more);TestNewOSUser(pattern of 03, a path such as../xora/b, a leading., exactlyhost.os_user_max_charsand one more);TestPlanAssignments(exact reported match; case variant not reported givessession_user_not_on_host; name of another person, valid or ended, also in another case, givessession_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; all400before any409);TestCloseLists(the lists of 24 10.3 equal the enumsTerminalCloseReasonandTerminalCloseDetailof 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'sexpires_atnot_found; Alex on R2not_found; an agent, an app and Alex with a personal token:forbiddenwithsubject_kind_not_allowedfor R1 and for a random id alike;host.manage: Robin allow, Alex on R1forbiddenwithowner_only, Sam on R2forbiddenwithowner_only, Sam on R1not_found, Robin with a personal tokenforbiddenwithsubject_kind_not_allowed, Robin with a step-up older thanauth.step_up_max_agestep_up_required);TestAZM60_InstanceGrantGivesNoHostat decision level (Alex with an instance granttriggerwithapprove_actions,upload,export,manage:not_foundwithno_grantfor R3; R1 stays visible because Alex holds it);TestSnapshot_HostGrants(direct, group, expired, revoked,ValidUntil);TestScope_Hosts(ownerAllHosts; Alex[R1]; Sam[R2, R3]in ascending order;ViewAsintersection);TestDiffVisible_Hosts(added and removed host entries withcapabilities: ["terminal"]and withoutlevel, owner gains every host, no entry for a holder change);TestDeferredActionsGolden(withouthost.readandhost.manage); the source test of M1-28 for the textowner or assignee or +terminal; the AZ-P8 differential test with hosts.internal/identity:TestHostPairingCode_Mint(kindhpc, length and checksum after ADR 0061,Hashequals the HMAC with the pepper, the secret prints[redacted]);TestAuthenticator_RejectsPairingCode(401and zero repository calls); the checksum vectors withhpc.internal/instance:TestEnsureActive_TerminalPurpose(an empty database gets anactivekey for each of the five purposes;getJwkslists noterminalkey; aterminalkey inretiringturnsretiredonly afterterminal.session_token_ttlplusauth.clock_leeway).internal/audit:TestDetails_OSUserName(accepted only underos_user, a value outside the pattern is rejected atRecord).
Integration (PostgreSQL 18):
TestMigration_Hosts: columns, defaults and every check of 03 (pairingexactly with a hash; keys required inconfirming,active,disabled;confirmed_atinactiveanddisabled;retired_atexactly withretired;disabled_reasonexactly withdisabled;personalexactly with a holder; 32-bytenoise_public_key); the partial uniques (a second live host named "build SERVER" fails, a retired one does not); foreign keysON DELETE RESTRICT.TestMigration_HostSessionUsers(PLAN row):bothy-s-samfor Sam on R2, thenBothy-S-Samfor 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 thehost_session_user_idof 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 ofstate,close_reason,close_detail,detached_at; the columnsopened_at,last_input_at,detached_at,closed_atexist.TestMigration_GrantsTargetHostFK: a host-target grant with a randomtarget_host_idfails; with R2 it succeeds; deleting R2 then fails.TestCreateHost_PairingCode:201,pairing_codematches the pattern ofHostPairingCodewith a valid checksum,hosts.pairing_code_hashequals the HMAC of the code,pairing_expires_atequals the fake clock plusterminal.pairing_code_ttl,state = pairing,version = 1,Cache-Control: no-store; onehost.updated; one audit entrycreate;access.changedfor Robin with anaddedhost 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 case409 conflict; the name of a retired host may be reused; control or bidi characters and a name ofhost.name_max_charsplus one characters400 validation_failed; an unknown, agent or deleted holder400 validation_failedwitherrors[].codeunknown_user; a missing or blank reason400 validation_failedwitherrors[].coderequired; Alex403 forbiddenwithreasonowner_only; nothing written in any case.TestHostRead_Visibility(PLAN rowhost.read):listHostsas Robin gives R1, R2, R3 and the created hosts in the orderlower(name),id; as Alex only R1; as Sam R2 and R3; after the clock passes Sam'sexpires_atonly R3;getHostthe same; every other combination404;ETagequals the version; cursor round trip withlimit1 and a foreign cursor400 cursor_invalid.TestAZM60_InstanceGrantGivesNoHost(AZ-M60): Robin creates an instance grant for Alex withtriggerandapprove_actions,upload,export,manage;getHost(R3)as Alex gives404identical to a random id,listHostslacks R3, R1 stays visible as Alex's own host.TestAZP4_HostNotFoundEquality: forgetHost,updateHostandsetHostState, 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 get403 forbiddenwithreasonsubject_kind_not_allowedon 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 give200,version + 1, onehost.updated, one audit entryupdatewithname_changed,from_assigned_user_idabsent andto_assigned_user_idSam, a raisedpermission_versionand noaccess.changed; Sam now reads R3 as holder;clear_assigned_userremoves the holder; both fields together400 validation_failedwitherrors[].codemutually_exclusive.TestUpdateHost_SessionUsers: Robin assignsbothy-s-samto Sam andbothy-s-robinto Robin on R2;session_userslists both; onehost.updated; two audit entriessession_userwithchange,user_idandos_user;permission_versionraised.TestUpdateHost_NameNotReported_TMT28e(TM-T28 (e), server part ofupdateHost): on R1,bothy-s-typoandBOTHY-S-SAM(case variant of the reportedbothy-s-sam) for Sam give400 validation_failedwitherrors[].codesession_user_not_on_hostand pointer/add_session_users/0/os_user; no row, no version, no event, no allow audit entry,permission_versionunchanged.TestUpdateHost_SessionUserTaken_TMT28b(TM-T28 (b), server part ofupdateHost): Alex holdsbothy-s-alexon R1 and Robin ends it; givingbothy-s-alexto Sam gives409 conflictwithreason: session_user_taken; givingBOTHY-S-SHAREDto Sam afterbothy-s-sharedwent 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 withaccess_revokedin the same transaction as the end of the assignment (one commit: a failure injected after the session update rolls back both), the formerlydetachedsession hasdetached_atNULL after the end, leaves Sam's session and the closed row unchanged, writes twoterminal.terminateentries with actor kindsystem, and the ended row stays with its name.TestUpdateHost_NoChange_IfMatch_Idempotency: a request without effect gives200without new version, event or rights version and one audit entry withno_change: true; missingIf-Match428 precondition_required; a stale one412 version_conflictwithcurrent; a replay with the sameIdempotency-Keyreturns the stored answer and writes nothing; a retired host409 conflict.TestSetHostState_Transitions(PLAN row): over HTTP from each state reachable here (fixtures forpairing,confirming,active,disabledwithowner,disabledwithduplicate_connection,retired) to each ofactive,disabled,retired: the allowed ones200with the new state, the same state200withno_change: true, every other409 invalid_state_transitionwith nothing written, enabling afterduplicate_connectionincluded.TestSetHostState_LeavingActiveEndsSessions(PLAN row): disabling R1 with sessions inopening,activeanddetachedcloses all three withhost_disabled(the formerlydetachedsession hasdetached_atNULL after the end), raisespermission_versionand writeshost.updatedand the audit entries in one transaction (a failure injected after the session update leaves the hostactive, the sessions open and the counter unchanged); retiring R2 closes its sessions withhost_retired; sessions of other hosts are untouched.TestHostManage_StepUpReasonAudit(PLAN row): forcreateHost,updateHostandsetHostState: Robin with a step-up older thanauth.step_up_max_agegets401 step_up_requiredand nothing changes; a missing, blank, bidi oraudit.reason_max_charsplus one characters reason gives400 validation_failed; each successful write has exactly the audit entries of Design note 9 with the reason, the details validate againstaudit_details.v1and contain no host name; each refusal byauthorizehas a deny entry.TestHostUpdated_Event: everyhost.updatedrow validates againstHostUpdatedEvent; scope empty;resource.versionequals the new version; the payload of R1 (keys set by the fixture) haskey_fingerprintbut nonoise_public_keyand no other key, no pairing code, nonull;key_changedabsent; receive decision as Design note 14: Robin and Alex receive R1's event, Sam does not until he holds a grant.TestCreateGrant_HostTarget: Robin grantsterminalon R1 to Sam:201, Sam getsaccess.changedwith anaddedhost entry,grant.changedwith an empty scope; revoking it givesremoved; a group grant gives the members the entry; Alex (holder) creating a grant on R1 gets403 forbiddenwithreasonowner_only; a random host id404; a retired host409 conflict.TestAccessChanged_HostEntries:createHostgives Robin anaddedhost entry and nobody else an entry;addOwnerfor Alex gives Alexaddedentries for every host;setHostStateand a holder change give noaccess.changed; every payload validates againstAccessChangedPayload.TestConcurrency_Hosts: the three races of Design note 15, each in both orders.TestHostCanary_SECT10or the M1-69 scenario extension (Design note 13).TestAZP1_HostRoutes: the five operations are registered withhost.readandhost.manageand 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 asauthz.HostSourceandNoConnectionsasPresence; 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_usersandterminal_sessionsexist exactly as 03 4.14, andgrants.target_host_idreferenceshosts(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 ofactiveends all sessions of the host and raisespermission_versionin the same transaction. -
host.read: owners read every host, the holder the own one, a grantterminalin effect that host; everyone else gets404identical to an unknown id; AZ-M60 is green (a grant on the instance gives no host). -
host.manageis owner only with step-up, a mandatory reason and audit; agents, apps and personal tokens get403 forbiddenwithreasonsubject_kind_not_allowed. -
updateHostwith a name the host does not report gives400 validation_failedwitherrors[].codesession_user_not_on_host(TM-T28 (e)), and with a taken name409 conflictwithreason: 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. -
createHostreturns the pairing code exactly once and stores only its HMAC; the code appears nowhere else. -
host.updatedis written on every change, validates againstHostUpdatedEventand never carries a key; the canary scenario (SEC-T10) contains a host name and finds it nowhere it must not be. -
host.readandhost.manageare real requirements and no longer in the deferred golden list; host entries appear inaccess.changed; the signing-key purposeterminalhas anactivekey after start. -
make checkis 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,instanceandauditupdated (portHosts,HostSource, the atoms and the gate, the life cycle, the extension points ofEndSessionsandPresencefor M2-43, M2-44 and M2-46, the kindhpc, the purposeterminal, the memberos_user). - Rulings of Design note 15 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 07, 08, 19, 24 andopenapi.yamlof the Scope made in the same change. services/server/CHANGELOG.mdentry 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- What is not proven, for example: the delivery of
host.updatedthrough 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). - 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
pairingorconfirmingtoretired, 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>.