Skip to content

M2-46: Terminal revocation, limits, audit and metrics

Status: ready | Depends on: M1-30, M1-69, M2-45 | Milestone: M2

Goal

After this ticket every kind of revocation of 24 "Revocation" ends the affected terminal sessions in the transaction that causes it, and every limit of 24 "Limits" takes effect without the help of the host service: the relay ends a session after terminal.idle_timeout plus terminal.idle_grace without bytes from the device, at terminal.max_session_duration after opened_at (across reattaching) and after terminal.detach_grace in detached; the job terminal.sweep repeats these checks in the database for the sessions of a failed node and ends sessions whose login session expired; terminal.open is refused with 403 terminal_denied and reason session_limit once terminal.max_sessions_per_person or terminal.max_sessions_per_host is reached; and rl.terminal and rl.host bound openings, attachments and connections. The module terminal subscribes to authorize.AccessChanged (a removed host entry or a lost owner status ends sessions), identity.DeviceRevoked and identity.SessionEnded (they end the sessions bound to the device or login session with the exact reason). Every end of a session goes through the one end function of M2-39 (EndSessions), which this ticket completes: close_reason and close_detail only from the closed lists of 24 "Reasons for ending", one audit entry terminal.terminate with codes and numbers only, the personal event terminal_session.changed, the metric bothy.terminal.sessions.closed; after the commit every api process that holds a connection of the session closes it with 4100, and the process holding the host's control connection sends session.close, after which the PTY helper ends the whole process tree. A run with canary values in terminal input and output finds them in no relay capture, log, trace, metric, audit row, event or database row (TM-T06, SEC-T10). This is the server side of TM-T06, TM-T10 and TM-T11 (24 "Tests"), of 07 8.2 point 7 and of the session ends that SR10 demands on a change of the owner status, of R-TM-10 and R-TM-11, and of the threats T-TM-01 and T-TM-11; M2-48 runs the same scenarios later against a real host service in a container. Why: a terminal is remote access to a machine, so a revocation must end it at once (fail closed, D-L23), a manipulated host service must not keep a session open (R-TM-11), and nothing a person types or sees may leave the end-to-end stream (SEC-TERM-1).

Read first

  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (rows "Test doubles" and "Security"); section 8 "M2 Agent platform, extensions and terminal": "Goal", "Scope" (terminal part), "Out of scope", "Exit criteria" item 8, "Test strategy" (terminal part: fake host in the server test, chaos), "Red team" (terminal paragraph: content in relay, proxy, logs, audit, telemetry, event log and backup (TM-T06); "the session after every kind of access removal"), "Dependencies", "Risks"; block "terminal" with the rows M2-38 to M2-45 (what exists), M2-46 (this ticket, the contract), M2-47 (sibling: multi-node relay; it asserts M2-44's bothy.terminal.relay.unpaired with two nodes, D-W-105), M2-48 (depends on this ticket; runs TM-T06, TM-T10 and TM-T11 against the real host service); the rows M1-30 and M1-69 (in this ticket's plan column), M1-19, M1-45, M1-65, M1-68 and M1-71 (outside it, see Boundaries), M4-35, M4-36, M5-12 and M6-18 (client reactions to the close codes), M7-05 (retention jobs) and M7-08 (maintenance mode); section 18 row "Terminal content never in the server, in telemetry or in the audit (SEC-TERM-1)"; section 19: P-09, P-10, P-11, P-13, P-20, P-25; section 20 row "Recording of terminal content for evidence obligations".
  • Dependencies: the header names M1-30, M1-69 and M2-45 (M1-30 for the job authz.expiry_sweep of the expiry rows of TM-T10, D-W-108 point 9). D-W-102 struck M2-44 from this ticket's plan column (it follows from M2-45); read its row and brief all the same, because M2-44 built the sessions, the session token, the relay in adapters/wsrelay, the close path after an end and the fake host that this ticket extends. Through M2-45 this ticket also builds on M2-38 to M2-44 and on the M1 tickets in their closure, among them M1-13, M1-14, M1-16, M1-24, M1-28, M1-29, M1-34, M1-48 and M1-49.
  • docs/spec/24-terminal.md (English, normative): 1 "Purpose and limits" (point 4, anti-goals); 2.1 "Overview"; 2.2 "Host service bothy-host" (only the parts on report and on restarts); 2.3 "Server host"; 3.1 "States" ("Every transition out of active ends all sessions"); 5.1 "Protection goal"; 5.4 "Frames" (input frames data and resize); 6.1 "Opening" (step 2: limits), 6.2 "Detach and reattach" (detached, terminal.detach_grace, live_sessions, host_restarted), 6.3 "Limits" (whole section), 6.5 "End of a session on the host", 6.6 "State of a session"; 7.3 "Invariant I6" (enforcement points 1 to 3); 8.2 "Protection rules"; 8.3 "Revocation"; 9 "Multiple nodes"; 10.1 "Audit" (row terminal.terminate, "Never in the audit"), 10.2 "Events", 10.3 "Reasons for ending (terminal_sessions.close_reason)", 10.4 "Privacy"; 13 "Errors" (terminal_denied reasons, close codes 4100 to 4104); 14 "Tests" (TM-T06, TM-T10, TM-T11 here; TM-T07, TM-T12, TM-T15, TM-T16 for orientation); 16 (R-TM-01, R-TM-10, R-TM-11, R-TM-15, R-TM-27); 17 "Configuration values of this chapter".
  • docs/spec/07-permissions.md: 2.12 "Hosts and the terminal capability"; 4.1 "Decisions and reasons" (session_revoked: "session revoked or expired", terminal_session_limit); 4.2 "Flow" (steps 8 and 9, audit of denials); SR10 ("Checked ... on every change of ... the owner status: if the condition no longer holds, the sessions end"), SR11, SR12; 8.2 "Immediate effect on revocation" point 7; 8.3 "Expiry by time"; test matrix rows AZ-M54 to AZ-M61.
  • docs/spec/05-actions.md (core contract, read only): rule 1 ("One decision point"), rules 7, 10 and 11; 2.4 (column Audit); 3 "Rate-limit classes" (rl.terminal, rl.host, rl.public_auth and its counting of failed credentials); 4.14 "Hosts and terminal" (rows terminal.open, terminal.attach, terminal.stream, terminal.close, terminal.terminate, host.connect, host.manage); 6 (row terminal_session.changed); 7 (terminal channels).
  • docs/spec/03-data-model.md (core contract, read only): 4.14 hosts, host_session_users, terminal_sessions (every column, the checks, the indexes (user_id), (host_id), (auth_session_id), (device_id) WHERE state <> 'closed', retention); auth_sessions and devices (revoke_reason lists); audit_events.details; 6.3 "Lock order"; 6.6 "Notifying the gateway processes"; 6.8 "I6 check".
  • docs/spec/04-sync-protocol.md (core contract, read only): 6.4 "Close codes" (table "Mapping during connection setup" and the paragraph on terminal channels); 8.2 "What happens on a change" (entries kind: host: "Whoever loses it, their terminal sessions on the host end in the same transaction"); 8.5; 9 "Delivery guarantee" point 3.
  • docs/spec/08-identity.md: 6.3 "Fresh step-up for the terminal", 10 "Lost device", 11 "Disabling a person"; test ID-T28.
  • docs/spec/02-architecture.md: 4.3 "Ports, events and sources per module" (rows identity, terminal), 4.4 "Allowed dependencies" (row terminal), 8.1 "Flow" (phase 3.1 subscribers), 8.2 "Lock order", 8.3 "In-Tx events", 9.1 (the paragraph on floods with invalid credentials), 11.2 "Log tail per api process", 12.3 "Job catalogue" (row terminal.sweep), 12.4 "Rules for jobs".
  • docs/spec/20-privacy.md: rows terminal_sessions, host_session_users, hosts; 4.1 "Periods per retention class" (security); R-DP-07; DP-T05, DP-T09. docs/spec/19-security.md: SEC-TERM-1, SEC-TERM-5, SEC-TERM-11, SEC-AUD-5, SEC-LOG-1 to SEC-LOG-7, SEC-T10, SEC-T18. docs/spec/17-observability.md: "Basic rules".
  • docs/api/openapi.yaml: operations openTerminalSession, attachTerminalSession, closeTerminalSession, getTerminalSession, listTerminalSessions, updateHost, setHostState, setHostLlmCredentials, issueHostPairingCode, createGrant, revokeGrant, addGroupMember, removeGroupMember, revokeOwner, disableUser, revokeDevice, revokeSession, logout, createStepUpChallenge, completeStepUpWithDeviceKey; schemas TerminalSession, TerminalSessionState, TerminalCloseReason, TerminalCloseDetail, TerminalSessionChangedEvent, TerminalSessionOpen, TerminalSessionGrant, TerminalSessionAttach, HostUpdate, HostReport, HostIdentify, HostSessionClose, HostSessionEnded, HostSessionStarted, AccessTarget, AccessTargetKind, Problem (x-problem-codes: terminal_denied, rate_limited, session_revoked, invalid_state_transition), header Retry-After. docs/api/asyncapi.yaml: channels terminalStream, hostControl, hostData with their x-close-codes (4004, 4008, 4012, 4100, 4110, 4113); messages host.session.close, host.session.ended, host.identify, host.report, event.terminal_session.changed.
  • ADRs: docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md, 0070-terminal-in-the-web-console.md, 0009-*, 0017-*, 0021-*, 0034-*, 0049-*, 0052-*, 0053-*.
  • docs/DECISIONS.md: the rulings T1 to T13 (D-T1 to D-T11 in section 7.1; T12 is D-W-57, T13 is D-W-62), D-T16, D-T23, D-T26, D-T34, D-T35, D-T39, D-T41, D-T42, D-L23, D-OP-03, D-W-89, D-W-90, D-W-100, D-W-101, D-W-102, and the rows D-M2-39-<n> to D-M2-45-<n> of section 10 that the merged terminal tickets wrote; section 10 for the format of new rows. D-W-102, D-W-105, D-W-107 and D-W-108 (point 9) name M2-46 by its ID (D-W-107 adds the header's edge to M1-69, D-W-108 point 9 the edge to M1-30); D-W-101 covers the whole terminal block.
  • docs/security/threat-model.md: 5.16 "Terminal" (rows "Relay I", "Relay D", "Host service E" with T-TM-11, "Host service R"), 6.7 "Terminal" (T-TM-01 to T-TM-14), rows T-AZ-04, T-DS-01, T-LG-01. docs/RISKS.md: RK-26, RK-29. docs/OPEN-QUESTIONS.md: OQ-04 (content recording; default no, due before this ticket), OQ-24 (terminal only over a private network; default no), OQ-03 (no effect here).
  • docs/observability/signals.md: 2 "Cardinality", 4.1 (bothy.ratelimit.rejected), 4.3a "Terminal" (all four instruments). docs/observability/privacy.md ("Canary test (gate)"). docs/operations/configuration.md: the terminal.* keys of 24 17, ratelimit.rl.terminal.per_minute, ratelimit.rl.terminal.burst, ratelimit.rl.host.per_minute, ratelimit.rl.host.burst, ratelimit.rl.public_auth.per_minute, ratelimit.rl.public_auth.burst, retention.security_after_end, database.tx_retry_max, telemetry.logs.level, telemetry.logs.exporter, telemetry.traces.sampler_ratio.
  • Briefs of the terminal block: docs/briefs/M2-38-noise-core.md, M2-39-terminal-hosts.md (Design note 7 EndSessions and SessionSelector; host entries in DiffVisible; HostSource and HostFacts), M2-40-terminal-authz.md (notes 4 ConsumeFresh, 5 guards and the extension point for terminal_session_limit, 7 TerminalEffective in the diff, 8 the report, 10 I6 ends, 11 SR10 on an assignee change with People.IsActiveOwner, 12 decision mapping), M2-41-host-pairing.md (re-pairing ends), M2-42-host-install-doctor.md, M2-43-host-control.md (notes 3 login and proof, 5 duplicate, 6 live_sessions, 7 ControlLinks with CloseSession and Connected, 8 c SessionsEndedByReport, 10 ValidateHostSessionEnd), and the briefs of M2-44 and M2-45 in docs/briefs/ (index in docs/briefs/README.md).
  • Briefs of M1: M1-13 (txn.Subscribe, golden registrations), M1-14 (internal/platform/jobs: Args, Handler, PeriodicJob), M1-16 (eventlog.Appender, sync.Tail), M1-19 (internal/platform/ratelimit), M1-24 (auditing.Recorder, Entry, Attrs), M1-28, M1-29 (authorize.AccessChanged, PrincipalAccessChange, phase 3.1), M1-30 (the job authz.expiry_sweep with the system actions grant.expire and group.member.expire), M1-34 (SessionEnded at logout), M1-45 (DeviceRevoked, SessionEnded, revoke reasons), M1-65 (per-process tail loop Tail -> Authorizer.Observe -> Hub.Dispatch, AZ-P9 with two instances), M1-68 (internal/platform/otel/metrics, internal/<module>/app/metrics.go, adapters/otelmetrics), M1-69 (services/server/test/canary: registry, sinks, Scan, scenario), M1-71 (services/server/test/harness); M2-09 as the precedent for in-transaction revocation subscribers.
  • Code on the base branch: services/server/internal/terminal (port, domain, app with EndSessions and the outgoing port People, adapters/postgres, adapters/httpapi, adapters/wsrelay with ControlLinks), internal/authz (TerminalGuard, TerminalEffective, HostSource), internal/kernel/authorize (TerminalFacts), internal/identity (in-transaction events, Owners), internal/kernel/auditing, internal/kernel/eventlog, internal/kernel/txn, internal/kernel/clock, internal/platform/jobs, internal/platform/ratelimit, internal/platform/otel/metrics, internal/wire (testdata/txn_registrations.golden), services/server/test/harness, services/server/test/canary, services/server/test/terminal/fakehost, services/host/internal/session, services/host/internal/control, services/host/internal/pty, the READMEs of terminal and authz.

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. Host names and session user names in tests are neutral (r1, r2, r3, term-alex, term-sam, term-robin).
  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. This ticket adds no configuration key. Test deadlines that only bound how long a test waits are test constants, not product limits.
  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. Every kind of revocation of 24 8.3 ends the affected sessions at once, fail closed, in the transaction that causes it: revocation or expiry of a grant, leaving a group, losing the owner role, disabling the person, revoking the device, revoking or ending the login session, logout, disabling, re-pairing or retiring the host, ending the session user assignment or a report without its name (SR12), and a change that violates SR10 or I6. The connections close with 4100, the host service is told to end the session, and its PTY helper ends the whole process tree (24 8.3, 6.5; 04 8.2; 07 8.2 point 7, SR10, SR12; D-L23).
  2. close_reason only from the closed list of 24 10.3 (equal to the check of 03 terminal_sessions and to TerminalCloseReason); close_detail only from the closed list of 24 10.3 (equal to TerminalCloseDetail), never free text (24 10.3, 03 terminal_sessions).
  3. The limits of 24 6.3 work without the host service: the relay ends a session after terminal.idle_timeout plus terminal.idle_grace without bytes from the device, at terminal.max_session_duration after opened_at also across reattaching, and after terminal.detach_grace in detached; the job terminal.sweep checks the same every terminal.sweep_interval in the database for sessions of a failed node (24 6.2, 6.3; 02 12.3; R-TM-11; D-T23; D-T39).
  4. terminal.max_sessions_per_person and terminal.max_sessions_per_host count sessions that are not closed; reaching one denies terminal.open with the decision terminal_session_limit, answered as 403 terminal_denied with reason session_limit (24 6.1 step 2, 6.3, 13; 07 4.1).
  5. rl.terminal per human for opening, attaching and connecting a stream; rl.host per host, counted after the check of the host's key, for control and data connections; a failed check of presented credentials on these channels consumes one token of rl.public_auth of the client address (05 3; 02 9.1; 24 6.3; D-T34).
  6. Every end by the server or by the host writes one audit entry terminal.terminate (system action, Audit yes) with session_id, close_reason, close_detail, bytes_in, bytes_out and the duration; never terminal content, commands, environment variables, paths or profile command lines (24 10.1; 19 SEC-AUD-5; R-TM-10).
  7. Telemetry carries only counters and durations without IDs of people and without host names, no span per frame, and only the instruments of signals.md 4.3a with closed attribute sets (24 10.4; signals.md 2 and 4.3a; D-T41).
  8. No terminal content in relay, proxy, logs, audit, telemetry, event log or database (TM-T06; SEC-TERM-1; T-TM-01); the canary scenario SEC-T10 contains a terminal input and a terminal output (19 SEC-T10, SEC-T18).
  9. terminal_sessions holds metadata only, with retention class security (retention.security_after_end after closed_at); there is no content recording (24 10.4; 20 R-DP-07; OQ-04 default).
  10. The stream is never an event; the event log carries only the personal event terminal_session.changed per state change; no JSON null (24 10.2; ADR 0053).

Terminal security requirements (D-W-101):

  1. Rights per machine: additional right terminal on the target host, SR10, SR11 with I6, SR12 (07 "Hosts and the terminal capability"). This ticket enforces the consequence of rights changes that come from outside terminal: a host entry removed from a person's access.changed (grant revoked or expired, group left or expired, owner role lost, I6 for other owners; computed by authz with TerminalEffective of M2-40) ends that person's sessions on the host in the same transaction, and a person who is no longer an active owner loses every session that SR10 no longer allows (not the assignee, or a server host), with the predicate M2-40 and M2-43 already use. The changes inside terminal end their sessions in the tickets that built them (assignment end M2-39, I6 and assignee change M2-40, re-pairing M2-41, report M2-43); this ticket proves them all in TM-T10 and adds the guard terminal_session_limit. Proven by TestTMT10_RevocationKinds, TestSR10Lost, TestReasonForLostHost and TestSessionLimit_*. The rights themselves (terminal.*, AZ-M54 to AZ-M61, TM-T08, TM-T09, TM-T28 (a)) belong to M2-39 and M2-40.
  2. Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). Not changed here: M2-40 builds step-up fresh and its consumption (ID-T28 without the console part), M2-44 TM-T07 and TM-T12, M6-18 the console with passkey only, M4-36 and M5-12 the step-up key in the apps. This ticket proves that an ended session cannot be continued without a new opening (TestTMT10_NoReattachAfterEnd: no session_token is issued for an ended session, also from the same device within terminal.quick_reattach_window) and that a limit denial does not consume the step-up (TestSessionLimit_DenialKeepsStepUp).
  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 opens no listener and no port: the close path uses the event-log tail of each api process and the existing outbound control connection. It enforces rl.terminal, rl.host and the counting of failed credentials in rl.public_auth on the existing paths against floods (T-DS-01), proven by TestRateLimit_Terminal, TestRateLimit_Host and TestRateLimit_FailedCredentials. The connections themselves belong to M2-43 (control) and M2-44 (device stream, data connection); on an answer "yes" to OQ-24 those tickets change, not this one.
  4. Audit without content: owned here. terminal.terminate carries exactly the codes and numbers of 24 10.1, terminal_sessions holds metadata only, metrics have closed attributes without IDs of people or host names, and no span is created per frame. Proven by TestTMT06_NoContentAnywhere, TestSEC_T10_TerminalInputOutput, TestAuditTerminate_Details, TestTerminalMetrics_ClosedAttributes, TestTelemetry_NoSpanPerFrame and TestTerminalSessionsHoldOnlyMetadata.
  5. Idle timeout and maximum duration (24 "Limits"): owned here on the server side. The relay of the api process holding a session ends it by terminal.idle_timeout plus terminal.idle_grace, terminal.max_session_duration and terminal.detach_grace, and terminal.sweep repeats the checks for a failed node. Proven by the TestTMT11_* tests, including a manipulated fake host that never reports idle. M2-45 measures idle on the host from the input frames data and resize; M2-43 accepts the host's idle_timeout and detach_expired reports.
  6. Service user and session users without administrator rights (T12, HD rules): M2-42 builds install, doctor with HD1 to HD11 and the key storage (TM-T13, TM-T22, TM-T24), M2-45 runs every PTY in the helper of the session user and ends its process tree (6.5). This ticket adds no privileged code; its host-side test of TM-T10 runs as an unprivileged user and ends the tree only through the process group, cgroup or Job Object of 24 6.5 (TestTMT10_SessionCloseEndsProcessTree). The console hint to remove a revoked person's session user (16 4.4 point 1) is M6's.
  7. Threats of threat model 6.7 for this ticket, each with its test: T-TM-01 (content in relay, proxy, log, database dump, backup) by TestTMT06_NoContentAnywhere and TestSEC_T10_TerminalInputOutput; T-TM-11 (session or processes stay after revocation) by TestTMT10_RevocationKinds, TestTMT10_CloseReachesOtherProcess, TestTMT10_HostOfflineGetsCloseOnReconnect, TestTMT10_SessionCloseEndsProcessTree and the TestTMT11_* tests (the residual risk of long runners stays as documented); T-TM-03 (stolen session or device) for the revocation part by the rows device, session, logout, expiry and person of TestTMT10_RevocationKinds (opening: M2-40, M2-44 with TM-T07, TM-T08); T-TM-08 for the consequence of SR11 and SR12 by the rows I6, assignment and report (prevention: M2-40, M2-42, M2-45); the 5.16 rows "Relay D" (T-DS-01) by TestRateLimit_* and TestSessionLimit_*, and "Host service R" (T-AZ-04) by TestAuditTerminate_ExactlyOnce. Not touched here: T-TM-02 (M2-38, M2-45), T-TM-04 (M2-42), T-TM-05 (M2-42, M2-43), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-09 (M2-40), T-TM-10 (M4-35), T-TM-12 (M2-42, M2-45), T-TM-13 (M2-42, M2-45), T-TM-14 (M2-42, M2-43, M2-45).

Scope

All paths under services/server/ unless noted. Names of M2-38 to M2-45 below are the ones their briefs give; where the merged code names them differently, follow the code (Boundaries). Where an earlier terminal ticket already built a part listed here, reuse it and complete only what is missing; never build a second copy.

Create:

  • internal/terminal/domain/ending.go (or the file of M2-39 that holds CloseReason): the closed set CloseDetail next to M2-39's CloseReason if missing, ValidateEnd(r CloseReason, d CloseDetail) error (Design note 2), ReasonForLostHost(action authorize.Action, h HostFacts, userID ids.ID) CloseReason, ReasonForDeviceRevoke(revokeReason string) CloseReason, ReasonForSessionEnd(revokeReason string) CloseReason, HostStateEnd(state string) (CloseReason, bool) and SR10Lost(isActiveOwner bool, userID ids.ID, h HostFacts) bool (reuse the predicate if M2-40 or M2-43 already wrote it).
  • internal/terminal/domain/deadlines.go: Windows{Idle, IdleGrace, MaxDuration, DetachGrace, OpenTimeout time.Duration}, SessionTimes{State string; OpenedAt, GrantedAt, StartedAt, DetachedAt, LastInputAt time.Time} (zero time when unset; GrantedAt is the time of the last grant, note 8 a), RelayDeadline(s SessionTimes, lastDeviceInput time.Time, w Windows) (at time.Time, reason CloseReason), SweepVerdict(hostState string, hostLocalConfirm, authSessionEnded bool, s SessionTimes, now time.Time, w Windows) (CloseReason, CloseDetail, bool) and PersistDue(lastPersisted, now time.Time, every time.Duration) bool (Design notes 7 and 8).
  • internal/terminal/app/revocation.go: OnAccessChanged, OnDeviceRevoked, OnSessionEnded (Design notes 4 and 5).
  • internal/terminal/app/sweep.go: the sweep use case (Design note 8); internal/terminal/adapters/jobs/: args Sweep{} with kind terminal.sweep and its handler on queue maintenance.
  • internal/terminal/app/metrics.go and internal/terminal/adapters/otelmetrics/metrics.go: the three instruments of Design note 12 on the outbound metrics port (no-op default) and the adapter that M2-44 created for bothy.terminal.relay.unpaired (M1-68 pattern).
  • internal/terminal/adapters/postgres/queries/ending.sql: open sessions by user IDs, by device, by auth sessions and by session ID, each ORDER BY id FOR UPDATE; the progress update of Design note 7; sweep candidates with the state of their host; the counts and the person lock of Design note 9. The one closing statement of M2-39 stays the only statement that sets close_reason.
  • services/server/test/terminal/: revocation_test.go, deadlines_test.go, limits_test.go, ratelimit_test.go, contentfree_test.go (package terminal_test, integration tests of this ticket; M2-48 later turns them into its data-driven suite).

Change:

  • internal/terminal/app: the end function EndSessions of M2-39 (with SessionSelector.ExceptUserID of M2-40 and what M2-43 and M2-44 added) gains the selector members SessionID, DeviceID and AuthSessionIDs []ids.ID (zero or empty: not used) and an end value domain.End{Reason CloseReason; Detail CloseDetail; ExitCode *int32; ClosedBy ids.ID; Source EndSource; Progress *Progress} with EndSource server, host or close_request and Progress{BytesIn, BytesOut int64; LastInputAt time.Time} in place of the bare reason; update every caller of M2-39 to M2-45 (domain.ServerEnd(reason) for the existing server ends). Use the integer type the merged code uses for exit_code, and keep the shape M2-43 or M2-44 gave the end if they already carry detail or exit code. Sessions gains EndSession(ctx context.Context, sel SessionSelector, end domain.End) error, which opens its own unit of work, if no such entry exists yet.
  • The open use case of M2-44: the person lock, the host row lock and the counts of Design note 9.
  • internal/terminal/adapters/wsrelay: per held session the deadlines of Design note 7 with a deadline scheduler port (real: time.AfterFunc relative to clock.Now(); fake in tests: fires when the fake clock passes the deadline); the progress update with session totals in place of M2-44's additive pairing-end update (Design note 7 d); the metrics of Design note 12; the rate limits of Design note 10.
  • internal/kernel/authorize: TerminalFacts (M2-40) gains OpenSessionsOfPerson, OpenSessionsOfHost int; openTerminalSession passes Resource.Terminal with both counts (a nil Terminal for terminal.open is an internal error).
  • internal/authz: guard terminal_session_limit at M2-40's extension point after SR12, before M2-44's host_not_active and terminal_profile_unknown; the mapping row exists since M2-40 (its Design note 12).
  • internal/identity port: the read of Design note 8 c if missing, with its sqlc query in internal/identity/adapters/postgres; the outgoing port of terminal that uses it.
  • internal/wire: subscriptions kernel.AccessChanged -> terminal, identity.DeviceRevoked -> terminal, identity.SessionEnded -> terminal as method values of the terminal app type after the existing ones, golden file internal/wire/testdata/txn_registrations.golden regenerated; job terminal.sweep for the roles worker and all as PeriodicJob{Interval: terminal.sweep_interval, RunOnStart: true}; the configuration keys this ticket is the first consumer of among terminal.idle_timeout, terminal.idle_grace, terminal.max_session_duration, terminal.detach_grace, terminal.sweep_interval, terminal.max_sessions_per_person, terminal.max_sessions_per_host (defaults from 24 17, chapter constant of 24 in configchapters.go if missing); metrics wiring.
  • services/server/test/canary/scenario.go (and its README): the terminal step of Design note 13 (SEC-T10).
  • services/server/test/terminal/fakehost and the device test client of M2-44: Noise as in Design note 13 if they do not speak it yet; a mode that never reports idle; recording of every session.close received.
  • services/host/internal/session (and internal/control if needed), only if the host service does not yet end a session with its process tree on session.close and on 4100 of the data connection: that handling; the test TestTMT10_SessionCloseEndsProcessTree in any case.

Docs (English, like the files):

  • docs/spec/24-terminal.md: 6.3 the persistence rule of last_input_at, bytes_in and bytes_out, the order of the sweep checks and the end of a session whose login session expired (Design notes 7 and 8); 10.3 one sentence that close_detail is set exactly with open_failed and host_policy_violation (Design note 2); 8.3 one sentence that an established stream ends with 4100 also after an identity revocation (Design note 3).
  • docs/spec/02-architecture.md: 12.3 row terminal.sweep (Design note 8).
  • docs/observability/signals.md 4.3a: the "Source" column of bothy.terminal.sessions.active (relay-held) and bothy.terminal.sessions.closed (api and worker, after the commit).
  • services/server/internal/terminal/README.md (subscriptions, job, the end function, close path, deadlines, limits, metrics, the table list with retention class security and trigger), services/server/internal/authz/README.md (limit guard), services/server/internal/identity/README.md if the port changed, services/server/test/canary/README.md, services/server/CHANGELOG.md and, if host code changed, services/host/CHANGELOG.md under "Unreleased"; docs/DECISIONS.md section 10 rows D-M2-46-<n> for every ruling below.

This ticket creates no migration, no API operation, no event type, no configuration key and no new top-level make target.

Out of scope

  • The rights terminal.*, SR10, SR11 and SR12 at opening and attaching, I6 at grant.create and host.manage, terminal_not_effective, host entries in access.changed (M2-39, M2-40); step-up fresh and its consumption (M2-40).
  • The session ends that the use cases inside terminal already cause: host state changes and assignment ends (M2-39), I6 and assignee change (M2-40), re-pairing (M2-41), host_restarted, the duplicate control connection and the report with a vanished name or a new server host flag (M2-43), open_failed and closeTerminalSession (M2-44), host-side ends (M2-45). This ticket only completes the end function they call and proves them in TM-T10.
  • Pairing and fingerprint (M2-41); install, doctor and HD1 to HD11 (M2-42); control connection, report storage, presence and live_sessions (M2-43); opening, attaching, session token, relay pairing, open_timeout in the relay, 4103, 4014, the fake host itself (M2-44); host-side token checks, PTY, profiles, idle measurement on the host and the process tree of 6.5 (M2-45).
  • Multi-node relay, hash routing and the two-node proof of bothy.terminal.relay.unpaired (M2-47, TM-T15); the counter itself is registered and recorded by M2-44 (D-W-105). The close path of every end through the event-log tail (M2-44 Design note 11).
  • The data-driven conformance suite, the real host service in a container, the PTY runs on macOS and Windows, chaos TM-T16 (M2-48).
  • Client views, warnings, pins and the console hint after a revocation (M4-35, M4-36, M5-12, M6-18).
  • Deleting terminal_sessions rows after retention.security_after_end (job retention.security, M7-05, DP-T05); maintenance mode (M7-08, D-T42).
  • Content recording of any kind (OQ-04; never in this version).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-46-terminal-revocation-limits; one writer per worktree.
  • Conventional Commits with the module as scope (for example feat(terminal): ..., feat(authz): ..., test(canary): ..., fix(host): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md.
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through pgtest (P-09, P-10). Without a Docker-compatible runtime the integration tests fail; they never skip.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d (Go t.TempDir()), removed at the end. The host-side test runs as the current unprivileged user; never use sudo or create operating system users.
  • Never read or print secrets. No network calls except package downloads for pinned tools and Go modules.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for M2-38 to M2-45: their merged names win over the expected names here; list each difference.
  • M1-65 (with M1-19 and M1-45) lies in this ticket's closure through M2-43, which depends on M1-67 (D-W-105). M1-69 (with M1-68 and M1-71) and M1-30 are named in this ticket's header (D-W-107 and D-W-108 point 9). If M1-69 or M1-30 is not merged on the base branch, stop with Verdict: BLOCKED <ticket> not merged.
  • If docs/OPEN-QUESTIONS.md on the base branch shows OQ-04 decided as "yes" (content recording), stop with Verdict: BLOCKED OQ-04 needs an ADR; the default "no" is what this brief builds.
  • No real host service, no real model call and no real claude in this ticket; the host side of the server tests is the fake host, and the host-side test drives internal/session directly.
  • Time only through kernel/clock and the deadline scheduler port; tests move a fake clock and call job handlers directly, they never sleep for a window.
  • Session tokens, access tokens, host proofs, step-up proofs and canary values never appear in logs, audit details, problem bodies or test failure messages (print a hash prefix if needed).

Design notes

  1. One end function. M2-39's EndSessions stays the only writer of an end (M2-39 Design note 7; M2-43 and M2-44 extended it with detail, exit code, event and close path). This ticket completes it: it locks the selected rows with state <> 'closed' FOR UPDATE in ascending id, validates the pair of note 2, sets state = 'closed', detached_at = NULL (03 check (state = 'detached') = (detached_at IS NOT NULL)), closed_at, close_reason, close_detail, exit_code (only process_exited from the host), closed_by (only client_closed and closed_by_owner from closeTerminalSession), takes bytes_in, bytes_out and last_input_at from end.Progress (and from RelayControl.Counters, M2-44 Design note 11) when they are higher than the stored values, never as a sum, because both carry session totals (note 7 d), raises version, writes terminal_session.changed and, unless end.Source is close_request, one terminal.terminate entry (note 11); bothy.terminal.sessions.closed is recorded after the commit (note 12). A row that is already closed is skipped. Ruling for docs/DECISIONS.md: the first committed end wins for every path (server, host, job, subscriber); a later report or job for a closed session changes nothing and writes no audit entry.

  2. Closed lists and pairs (24 10.3). M2-43 accepts from the host only client_closed, process_exited, idle_timeout, detach_expired, open_failed and host_policy_violation, with close_detail required exactly for open_failed (any of the 14 codes) and host_policy_violation (subscription_login_detected, helper_untrusted, shared_location) (ValidateHostSessionEnd, M2-43 Design note 10). ValidateEnd applies the same pair rule to every end inside EndSessions, server ends included: a detail with any other reason, a missing detail with those two, or an unknown code is an internal error, never stored. Ruling for docs/DECISIONS.md: the pair rule holds for every writer, not only for host reports; update 24 10.3.

  3. Close path after the commit. No connection is touched inside a transaction. Ends are committed by request handlers of any module (subscribers), by the worker (terminal.sweep, authz.expiry_sweep) and by relays; every api process that holds a device stream or a host data connection of the session must close it with 4100 and the close text close_reason, and the process that holds the host's control connection must call ControlLinks.CloseSession (M2-43), which sends session.close {session_id, close_reason}. M2-44 built this path and this ticket builds no other (M2-44 Design note 11): every end writes terminal_session.changed in its transaction, and the event-log tail of every api process hands each committed one with state = closed to the relay observer, which closes the connections of the session held in that process and calls ControlLinks.CloseSession (a no-op without the link). The tail reads the committed rows of event_log from the database, woken by NOTIFY event_log, which is on in every mode (03 6.6), and polls every sync.log_poll_interval (02 11.2). So an end committed in a worker process while api and worker run as separate processes with cluster.mode = single (02 16 level 1), and an end committed on another node with cluster.mode = multi (02 16: every node has its log tail), reach the process that holds the connections at once, without NOTIFY ephemeral and without M2-47. A host service that was offline gets session.close at its next login through live_sessions (M2-43 Design note 6 b). Ruling for docs/DECISIONS.md: an established stream always ends with 4100 and the reason, also after an identity revocation (24 8.3), while 4012 stays the answer at stream identify when the credential is already revoked (04 6.4 "Mapping during connection setup"); the client learns the identity revocation from its gateway connection (4012, M1-65).

  4. Rights changes from outside terminal (OnAccessChanged(ctx, authorize.AccessChanged), phase 3.1 of the revoking transaction, 02 8.1 and 8.3). It never calls authorize, because the snapshot cache does not see uncommitted rights (M1-29).

    a. For every principal in Changes: each target of kind host in Removed ends that person's sessions on that host (SessionSelector{HostID, UserID}). authz computes the host set with TerminalEffective of M2-40 (effective terminal of 07 2.12 point 3, I6 aware), so a revoked or expired grant, a group left or expired, an owner role lost on a host without own grant, and an I6 switch for other owners all arrive here (04 8.2: "Whoever loses it, their terminal sessions on the host end in the same transaction").

    b. Then, for every principal in Changes that still has open sessions, read People.IsActiveOwner (M2-40) in the transaction; when it is false, end each remaining session for which SR10Lost holds: the host's AssignedUserID is not the person, or the host has DeclaredServerHost or ReportedIsServerHost (HostFacts of M2-39, read fresh). This is 07 SR10 "on every change of ... the owner status", the case M2-40 Design note 11 leaves to this ticket; it uses the same predicate as M2-40's assignee change and M2-43's report (SessionsEndedByReport). A person who was never an owner cannot hold such a session (SR10 at opening), so the check only ends sessions after owner.remove.

    c. Reasons by ReasonForLostHost: AccessChanged.Action user.disable gives user_disabled; a host with LLMCredentials = personal whose CredentialHolderID is not the person gives i6_violation; every other case access_revoked. Ruling for docs/DECISIONS.md: removed host entries and a lost owner status are the two signals of a rights change outside terminal, and both are consequences of the commit, not new rights decisions.

  5. Identity revocations (method values of the terminal app type, synchronous in the transaction of the cause).

    a. OnDeviceRevoked(ctx, identity.DeviceRevoked): the open sessions whose current device_id is the device; ReasonForDeviceRevoke: user_disabled gives user_disabled, every other device revoke reason of 03 devices gives device_revoked. Ruling for docs/DECISIONS.md: a device revocation ends the sessions whose last opening or attaching came from that device (terminal_sessions.device_id, the column the index of 03 serves), not those it once opened and another device took over.

    b. OnSessionEnded(ctx, identity.SessionEnded): the open sessions whose auth_session_id is one of the ended sessions (one ID or a list, as the merged event carries it); ReasonForSessionEnd: logout gives logout, user_disabled gives user_disabled, device_revoked gives device_revoked, every other session revoke reason of 03 auth_sessions gives session_revoked.

    c. Ruling for docs/DECISIONS.md: device and login-session revocations end sessions through the identity events because they carry the exact reason (logout against session_revoked); the rights path of note 4 ends what else became unusable; both are idempotent through EndSessions, so the order of DeviceRevoked, SessionEnded and AccessChanged within user.disable does not matter.

  6. Ends already built inside terminal. TM-T10 covers them as rows; the expected owners and reasons are: host disabled, retired (host_disabled, host_retired, M2-39) and re-paired (host_rekeyed, M2-41); assignment ended (access_revoked, M2-39); I6 switch or holder change (i6_violation, M2-40); assignee changed (access_revoked, M2-40); report without a name or with a new server host flag (access_revoked, M2-43); duplicate control connection (host_disabled, M2-43). A row that fails because the merged ticket did not end the session is fixed in that use case with the end function, and the fix is listed in the report.

  7. Deadlines in the relay (api role; one set per session the process holds a side of).

    a. Idle: the reference is the latest of the last binary message from the device after identify seen by this process, last_input_at, started_at and opened_at; the session ends with idle_timeout when clock.Now() reaches reference plus terminal.idle_timeout plus terminal.idle_grace. Only device bytes count, as 24 6.3 says; the server cannot see frame types. When the deadline fires and input arrived meanwhile, the relay schedules the new deadline instead.

    b. Maximum duration: opened_at plus terminal.max_session_duration, unchanged by reattaching; reason max_duration. Detach: in detached, detached_at plus terminal.detach_grace; reason detach_expired.

    c. A deadline is reached at the instant itself (a check one clock tick before keeps the session). The relay ends through EndSession with Source server and its in-memory Progress.

    d. Progress persistence: Progress and RelayControl.Counters carry session totals: the row's bytes_in and bytes_out read when the relay began the pairing plus the live pairing's counts (M2-44 Design note 7 "Counting"). The relay writes these totals and last_input_at of a held session at most once per terminal.idle_grace (PersistDue) and when the pairing or the session is released, only WHERE state <> 'closed', only increasing values (GREATEST with the stored value), without raising version and without an event. This write replaces M2-44's additive updates: the pairing-end update that adds the pairing's counts to the row (M2-44 Design note 7 "Counting") and the end value "row value plus the live pairing's counts" (M2-44 Design note 11) become this total write and the higher-value rule of note 1; M2-44's TestRelay_ByteCountersAndLastInput stays green unchanged as the regression test. Ruling for docs/DECISIONS.md: the stored value lags the true last input by less than terminal.idle_grace, so the sweep, which ends at stored value plus terminal.idle_timeout plus terminal.idle_grace, never ends a session before terminal.idle_timeout of real inactivity; counters do not change version, like reactions on messages (04 9 point 3); update 24 6.3.

  8. terminal.sweep (worker; every terminal.sweep_interval; one unit of work per affected session, so no batch key is needed; no actor).

    a. It reads the open sessions with the state of their host and SweepVerdict ends a session with the first match in this order: host not active (HostStateEnd: disabled gives host_disabled, retired gives host_retired, pairing and confirming give host_rekeyed); login session ended (session_revoked); opening past the last grant plus terminal.open_timeout (open_failed with open_timeout), except on a host whose hosts.reported_require_local_confirm is true, where the pairing meets the open deadline and the host service bounds start and consent (M2-44 Design note 7, M2-45 Design note 15 d), so such an opening is bounded only by the rules after this one; max_duration; detach_expired; idle_timeout with the stored last_input_at. Each end goes through EndSessions with Source server. The last grant is not opened_at, because an attach to an opening session restarts the opening with a new deadline while opened_at never changes (M2-44 Design note 6 d and f); its time is the UUIDv7 timestamp of terminal_sessions.token_jti, which every grant renews (M2-44 Design notes 4 d and 6 e; if the merged attach writes a jti that is not a UUIDv7, make it one), so no column is needed.

    b. Ruling for docs/DECISIONS.md: the order (security reasons before limits); the end of a session whose login session passed expires_at as session_revoked (07 4.1 "session revoked or expired"; no event marks the expiry, the same pattern as authz.expiry_sweep for grants, 07 8.3); the opening fallback for a node that died before terminal.open_timeout, measured from the last grant and not applied on a host that reports require_local_confirm; update 02 12.3.

    c. "Login session ended" comes from the identity port: use the read M1-34 or M1-45 offers for the state of a list of sessions; if none exists, add Auth.EndedSessions(ctx context.Context, sessionIDs []ids.ID, at time.Time) ([]ids.ID, error) returning those revoked or with expires_at not after at, and bind it to an outgoing port of terminal in internal/wire.

    d. The pairing part of the 02 row ("discard expired pairing codes") cannot be built as written: 03 hosts requires pairing_code_hash while state = 'pairing', and pairHost already rejects an expired code (M2-41, TM-T20). Change the 02 row to say that an expired code stays stored until the next code or the retirement and is rejected at redemption (spec gap in the report).

  9. Session limits. In the unit of work of openTerminalSession, before authorize: take pg_advisory_xact_lock(hashtextextended('terminal.open:' || <user_id>, 0)) for the person, then lock the host row FOR UPDATE, then count the sessions of the person and of the host with state <> 'closed' and pass them in TerminalFacts. The guard after SR12 denies with terminal_session_limit when the person's count is at least terminal.max_sessions_per_person or the host's count at least terminal.max_sessions_per_host; the answer is 403 terminal_denied with reason session_limit (M2-40's mapping row), audited as every denial of terminal.open (07 4.2). A denial rolls back, so the step-up stays unconsumed. Attaching creates no session and is not counted. If M2-40's ConsumeFresh must stay the first row lock of the opening (M2-40 Design note 4), take the person lock before it and the host row after it; an advisory lock is no row lock. Ruling for docs/DECISIONS.md: the person lock (an advisory lock, because no table of terminal has one row per person; it is taken first, so 03 6.3 stays free of cycles) and "at least the key value" as the limit test.

  10. Rate limits (05 3, the limiter of M1-19 before authorize).

    a. openTerminalSession and attachTerminalSession use rl.terminal keyed by the human principal and answer 429 rate_limited with Retry-After; the device stream counts against the same bucket at identify after the credential check and closes with 4008 and the wait time in the close text.

    b. Host control and data connections use rl.host keyed by the host ID after the proof check (M2-43 Design note 3) and close with 4008 likewise.

    c. A failed check of presented credentials on the three channels (device stream 4004 or 4012; host proof 4110 or 4113) consumes one token of rl.public_auth of the client address; when that bucket is empty, a new connection from the address that presents credentials closes with 4008 before the check, as 02 9.1 rules for REST. Ruling for docs/DECISIONS.md: the rule of 02 9.1 applies to the WebSocket channels of the terminal (M2-43 left the counting of failed proofs to this ticket).

    d. Each rejection counts bothy.ratelimit.rejected with its class. If M2-43 or M2-44 already applied a class, only add the tests.

  11. Audit. terminal.terminate (system action, actor kind system, resource terminal_session) with Attrs exactly session_id (ID), close_reason (code), close_detail (code, only when set), bytes_in, bytes_out and duration_ms (integers; closed_at minus opened_at), as M2-39 Design note 7 started it. An end through closeTerminalSession keeps the terminal.close entry of M2-44 and writes no terminal.terminate entry. Ruling for docs/DECISIONS.md if M2-39 did not record it: the key duration_ms for "duration" of 24 10.1, and one entry per end.

  12. Metrics (signals.md 4.3a; declared once in the registry of M1-68; never registered twice when an earlier ticket already did).

    a. bothy.terminal.sessions.closed (bothy.reason from the 18 codes of TerminalCloseReason): +1 after the commit of each end, in the process that committed it (api or worker).

    b. bothy.terminal.sessions.active (bothy.terminal.state opening, active, detached): sessions held by the relay of this api process; +1 in opening when the relay registers the first side, moved on each committed terminal_session.changed of a held session, -1 on release. The sum over api processes counts sessions with at least one connection on some node; a session without any connection (host offline) is not counted. Ruling for docs/DECISIONS.md; update the "Source" column of signals.md.

    c. bothy.terminal.hosts.connected: +1 when ControlLinks.Connected of M2-43 turns true for a host in this process, -1 when it turns false. bothy.terminal.relay.unpaired is not registered here: M2-44 registers and records it, and M2-47 asserts it with two nodes (D-W-105).

  13. No content anywhere (TM-T06, SEC-T10). The device test client and the fake host perform the real handshake Noise_NK_25519_ChaChaPoly_SHA256 with the prologue of 24 5.2 and encrypt every frame; if the merged doubles of M2-44 do not, extend them in test code with github.com/flynn/noise at the version services/host/go.mod pins (license already in the gate through M2-38). Ruling for docs/DECISIONS.md if needed: a test-only dependency of the server module, because the host's internal/noise cannot be imported from another module. Sinks: the raw binary WebSocket messages both doubles sent and received (what passed the relay), server logs at telemetry.logs.level debug with telemetry.logs.exporter both, traces with telemetry.traces.sampler_ratio 1.0, metrics, every row of every table of the domain schema and of the River schema read as text through AdminDB() (what a backup would hold), event_log payloads and audit_events.details. Matching uses Scan of test/canary (case, base64 and hex variants). A positive control asserts that the canaries arrive in clear at the fake host and at the device client, and the detector meta test finds a canary written into a log line on purpose.

  14. Retention and recording. terminal_sessions keeps retention class security (README table, the 20 row) and its rows are deleted by retention.security of M7-05, not here (P-13: no job ahead of its owner). TestTerminalSessionsHoldOnlyMetadata compares the columns of the table in the migrated database with the 03 list, so no column can hold content. OQ-04 keeps its default (no recording).

  15. Concurrency and locks. Within terminal: person lock (note 9), then hosts rows, then terminal_sessions rows in ascending id; never terminal_sessions before hosts. A subscriber in phase 3.1 locks only terminal_sessions rows, after permission_version (phase 1) and before event_seq (3.2), as 03 6.3 requires. Deadlocks are retried by the unit of work (database.tx_retry_max). A relay deadline, the sweep and a revocation racing on one session end it once (note 1).

  16. Rulings for docs/DECISIONS.md, one row each with reason: notes 1, 2, 3, 4 c, 5 a, 5 c, 7 d, 8 b, 8 d, 9, 10 c, 11 (if new), 12 b, 13 (if the dependency is added), and the OQ-04 default as built. Facts already in the specification (the reason lists, 4100, the limits) and rulings of M2-39 to M2-45 are not repeated there.

Tests (write them first)

Unit:

  • internal/terminal/domain: TestCloseReasonsMatchSpec (the Go set equals the codes of the table in 24 10.3, the CHECK list of 03 terminal_sessions.close_reason and the enum of TerminalCloseReason; the files are found by walking up to the repository root); TestCloseDetailsMatchSpec (the Go set equals the list in 24 10.3 and TerminalCloseDetail); TestValidateEnd (every allowed pair passes; open_failed without detail, host_policy_violation with token_invalid, access_revoked with any detail and unknown strings fail); TestReasonForLostHost (user.disable, a personal host of another holder, a personal host of the person, grant.revoke, grant.expire, group.member.remove, group.member.expire, owner.remove); TestSR10Lost (owner keeps everything; non-owner assignee keeps; non-owner not assignee loses; non-owner assignee on a host with DeclaredServerHost or ReportedIsServerHost loses); TestReasonForDeviceRevoke (all seven device revoke reasons of 03), TestReasonForSessionEnd (all nine session revoke reasons of 03), TestHostStateEnd; TestRelayDeadline (idle moved by device input; one tick before reference plus terminal.idle_timeout plus terminal.idle_grace keeps, the instant ends; maximum from opened_at after a reattach; detach from detached_at; the earliest deadline wins); TestSweepVerdict (each rule at its boundary, the precedence order of note 8, idle reference with and without last_input_at; the opening rule measured from GrantedAt, not opened_at, and not applied with hostLocalConfirm); TestPersistDue.
  • internal/authz: TestTerminalOpenLimitGuard (below both limits allows; at the per-person or per-host value denies with terminal_session_limit; the guard runs after SR12 and before host_not_active and terminal_profile_unknown; missing counts for terminal.open are an internal error); TestDecisionErr_TerminalSessionLimit (problem terminal_denied, reason session_limit, audit reason terminal_session_limit).
  • internal/terminal/...: TestEndingSQLOnlyInEndSessions (exactly one statement in adapters/postgres/queries/*.sql sets close_reason); TestTerminalMetrics_ClosedAttributes (the registry Check of M1-68 passes for the three instruments of this ticket and M2-44's bothy.terminal.relay.unpaired, all attribute values from closed sets, no ID or host name attribute).

Integration (PostgreSQL 18 through pgtest, the real internal/wire graph through the shared harness, the fake host and the device test client of M2-44 with Noise, fake clock and fake deadline scheduler; world: Robin is the owner, Alex and Sam are people; host r1 is organization, assigned to Alex, with a terminal grant to Alex and the reported and assigned session users term-alex, term-sam and term-robin; host r2 is organization, assigned to Sam, with a group Engineering (member Sam) holding a terminal grant on r2 and the session users term-sam and term-robin; a row that needs a second owner or another host sets it up itself; each session is opened with a fresh step-up through createStepUpChallenge and completeStepUpWithDeviceKey, started by the fake host and active):

  • TestTMT10_RevocationKinds, one subtest per kind of 24 8.3, each with the expected close_reason: revokeGrant of Alex's grant on r1 (access_revoked); the grant's expires_at passed and the authz.expiry_sweep handler run (access_revoked); removeGroupMember of Sam from the group Engineering on r2 (access_revoked), and the membership expiring (access_revoked); Sam is made a second owner and works on r1 (not its assignee) and on r2, then revokeOwner of Sam ends the r1 session (access_revoked, SR10) while the r2 session (assignee with the group grant) stays open; disableUser of Alex (user_disabled); revokeDevice of Alex's device (device_revoked); revokeSession by Robin (session_revoked); the login session's expires_at passed and the sweep handler run (session_revoked); logout by Alex (logout); setHostState disabled (host_disabled) and retired (host_retired); issueHostPairingCode on r1 (host_rekeyed); updateHost with revoke_session_users for Alex (access_revoked); a fake host report without term-alex (access_revoked); updateHost moving the assignee of r1 to Sam (access_revoked for Alex); a report with is_server_host true (access_revoked for Alex, Robin's session stays); a host r3 (organization, no assignee, no grant, session users term-robin and term-sam) where Robin and Sam (made a second owner for this row) have sessions, then setHostLlmCredentials of r3 to personal with holder Robin (i6_violation for Sam, Robin's session stays). For every row assert: the row is closed with that reason and no detail in the same commit as the cause (same correlation_id on the cause's audit entry or event and on terminal.terminate), one terminal_session.changed, one terminal.terminate entry, the device stream closed with 4100 and close text equal to the reason and the host data connection with 4100 within a test deadline after the request or job handler returned, one session.close {session_id, close_reason} at the fake host, bothy.terminal.sessions.closed raised for that reason, and every session the cause does not concern untouched.
  • TestTMT10_NoReattachAfterEnd: after each row above, attachTerminalSession for the ended session (from the same device within terminal.quick_reattach_window where its credentials still work) answers a problem and issues no session_token; the row stays closed.
  • TestTMT10_HostOfflineGetsCloseOnReconnect: the fake host drops its control connection; revokeGrant; the host reconnects with the session in live_sessions and receives session.close with access_revoked right after ready.
  • TestTMT10_IdentityAndRightsBothFire: disableUser publishes DeviceRevoked, SessionEnded and AccessChanged in one transaction; the session ends once with user_disabled, one audit entry, one event.
  • TestTMT10_CloseReachesOtherProcess: with cluster.mode = single, two server instances on one database (as AZ-P9 of M1-65 runs them) and a third in role worker; the session's connections are held by instance B; revokeGrant on instance A and the sweep handler on the worker instance both close B's connections with 4100, the instance holding the host's control connection sends session.close, and no NOTIFY ephemeral is involved.
  • TestTMT11_IdleWithoutHostReport: the fake host in the mode "never reports idle"; the device sends nothing; one tick before terminal.idle_timeout plus terminal.idle_grace the session is open, at the instant it ends with idle_timeout and 4100; TestTMT11_InputResetsIdle (a data frame moves the deadline); TestTMT11_IdleReportedByHost (the fake host reports session.ended with idle_timeout at terminal.idle_timeout; reason idle_timeout, Source host).
  • TestTMT11_MaxDurationAcrossReattach: open, device drops (detached), Alex attaches from a second device with a fresh step-up; the session ends with max_duration at opened_at plus terminal.max_session_duration.
  • TestTMT11_DetachGraceWithoutHost: the device drops, the fake host does not end the PTY; at detached_at plus terminal.detach_grace the server ends with detach_expired.
  • TestTMT11_SweepCatchesDeadNode: the relay of the process is stopped without ending rows (a dead node); for each rule of note 8 (host disabled behind the module's back through AdminDB(), login session expired, opening past terminal.open_timeout, maximum, detach, idle with a stored last_input_at) the sweep handler ends the session with the expected reason; a session within all windows stays; an opening session whose device dropped and that was attached again before terminal.open_timeout stays when the sweep runs after opened_at plus terminal.open_timeout but before the new grant plus terminal.open_timeout, and ends with open_failed and open_timeout after it; an opening session on a host that reports require_local_confirm true stays past the last grant plus terminal.open_timeout; running the handler twice changes nothing more.
  • TestTMT11_ProgressPersistence: last_input_at, bytes_in and bytes_out are written at most once per terminal.idle_grace and at release, version unchanged, no event; the terminal.terminate entry carries the relay's current counts for a relay-decided end; after periodic writes, a detach, a reattach and an end, row and terminal.terminate carry the sums of both pairings, each byte counted once (M2-44's TestRelay_ByteCountersAndLastInput stays green).
  • TestSessionLimit_PerPerson (terminal.max_sessions_per_person set low in the test config: the next open by Robin on another host gives 403 terminal_denied with reason session_limit and one denial audit entry), TestSessionLimit_PerHost, TestSessionLimit_ClosedNotCounted, TestSessionLimit_AttachNotCounted, TestSessionLimit_DenialKeepsStepUp (after the denial and the end of another session, the same step-up opens within terminal.step_up_max_age), TestSessionLimit_Concurrent (Robin opens in parallel from more devices than the limit, each with its own fresh step-up, on two hosts: exactly the limit succeeds).
  • TestRateLimit_Terminal (ratelimit.rl.terminal.burst low: open and attach share the bucket, the next gives 429 rate_limited with Retry-After; then the device stream identify closes with 4008; Sam's bucket is unaffected; bothy.ratelimit.rejected with rl.terminal); TestRateLimit_Host (ratelimit.rl.host.burst low: further control or data connections of r1 close with 4008 after the proof check; r2 is unaffected); TestRateLimit_FailedCredentials (ratelimit.rl.public_auth.burst low: host connections with a bad proof close with 4113 until the bucket is empty, then the next connection from that address closes with 4008 without a proof check, and a valid host from another address still connects; the same for device streams with an invalid access token).
  • TestCloseValuesOnlyFromLists: after all tests of the package, SELECT DISTINCT close_reason, close_detail FROM terminal_sessions holds only allowed pairs of note 2.
  • TestAuditTerminate_Details (details are exactly the six keys, codes and integers only, duration_ms equals closed_at minus opened_at) and TestAuditTerminate_ExactlyOnce (one entry per end; none for an end through closeTerminalSession, which keeps its terminal.close entry).
  • TestEventsValidate: every terminal_session.changed row validates against TerminalSessionChangedEvent, is personal to the session's person, has no JSON null.
  • TestTMT06_NoContentAnywhere: Alex opens a shell session; the device sends canary keystrokes (class content) in data frames and a resize, the fake host answers with output containing a second canary; the device closes. Scan the sinks of note 13: zero findings; positive controls pass; the session token (class secret) appears only in the response of openTerminalSession and inside the encrypted message 1.
  • TestTelemetry_NoSpanPerFrame: streaming 10 and then 100 data frames creates the same number of spans; no log line or span attribute carries frame bytes.
  • TestTerminalSessionsHoldOnlyMetadata: the columns of terminal_sessions in the migrated database equal the 03 list.
  • TestWire_TerminalSubscriptionsAndSweep: the golden registrations list the three subscriptions after the existing ones; terminal.sweep on queue maintenance, periodic with terminal.sweep_interval, RunOnStart, roles worker and all only.
  • services/server/test/canary: TestSEC_T10_TerminalInputOutput (the main scenario gains the terminal step of note 13 with canary input and output; TestSEC_T10_CanaryAcrossAllOutputs stays green with it; if TestSEC_T10_ScenarioCoversEveryEnabledOperation misses terminal operations enabled by M2-39 to M2-45, add them with canary values in every free-text input such as host names and reasons).

Host side (services/host, module of M2-38):

  • internal/session: TestTMT10_SessionCloseEndsProcessTree: with the PTY test setup of M2-45 (helper as the current unprivileged user), a shell session starts a background process with setsid and one with nohup (Windows: a detached child process, behind its build tag); a session.close with access_revoked over a fake control connection, and in a second run a 4100 on the data connection, end the PTY and both background processes within a test deadline; the host reports nothing that overrides the server's reason. The test runs on the platform of the build machine in make check; the runs on the other two platforms are in M2-48's platform list.

Contract: responses of the operations used validate against their schemas; problems carry code and, where named above, reason.

Red team focus of the milestone for this ticket (PLAN 8 "Red team", terminal: content in relay, proxy, logs, audit, telemetry, event log and backup; a session after every kind of revocation) is covered by TestTMT06_NoContentAnywhere, TestSEC_T10_TerminalInputOutput, TestTMT10_RevocationKinds, TestTMT10_CloseReachesOtherProcess, TestTMT10_HostOfflineGetsCloseOnReconnect, TestTMT10_SessionCloseEndsProcessTree and TestTMT11_IdleWithoutHostReport; 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/kernel/... ./internal/identity/... ./internal/wire/... ./test/terminal/... ./test/canary/...
cd services/host && go test -race -count=1 ./internal/session/... ./internal/control/...
make arch
make codegen-check
make register-check
make config-doc-check
make docs
make check

Acceptance criteria

  • TM-T10 (server side): every kind of revocation of 24 8.3 ends the session in the transaction of its cause with the right close_reason, an audit entry and terminal_session.changed; both connections close with 4100 on whichever api process holds them, the host service receives session.close, also after reconnecting when it was offline.
  • TM-T10 (host side): session.close and 4100 on the data connection end the PTY and background processes started with setsid and nohup (Windows: a detached child) on the build machine's platform.
  • TM-T11: idle, maximum duration and terminal.detach_grace end the session with idle_timeout, max_duration and detach_expired, also with a fake host that never reports idle and, through terminal.sweep, for a dead node; an expired login session ends its sessions with session_revoked.
  • TM-T06: canary input and output of a terminal session appear in no relay capture, log, trace, metric, audit row, event or database row; no span per frame.
  • SEC-T10: the canary scenario contains a terminal input and a terminal output and stays green.
  • close_reason and close_detail come only from the lists of 24 "Reasons for ending", in the pairs of note 2; one statement writes them.
  • terminal.max_sessions_per_person and terminal.max_sessions_per_host give 403 terminal_denied with reason session_limit (decision terminal_session_limit), exactly under concurrency, without consuming the step-up.
  • rl.terminal gives 429 rate_limited for opening and attaching and 4008 on the device stream; rl.host gives 4008 per host after the proof check; failed credentials on the terminal channels consume rl.public_auth.
  • A removed host entry of access.changed and a lost owner status end the affected sessions in the revoking transaction; device and login-session revocations end them with their exact reasons.
  • bothy.terminal.sessions.active, bothy.terminal.sessions.closed and bothy.terminal.hosts.connected are registered once with closed attributes, next to M2-44's bothy.terminal.relay.unpaired; terminal.terminate carries only codes and numbers.
  • 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, test/canary and (if changed) identity updated (subscriptions, job, the end function, the close path, deadlines, limits, metrics, tables with retention class security).
  • Rulings of note 16 recorded in docs/DECISIONS.md section 10 (one row each with reason); the changes to 24, 02 and signals.md of the Scope made in the same change.
  • services/server/CHANGELOG.md entry under 'Unreleased' (and services/host/CHANGELOG.md if host code changed).
  • 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 host-side test on the platforms other than the build machine's).
  5. Follow-ups that could not be done in this scope, each with its reason, spec gaps found, and every fix made in a use case of M2-39 to M2-45 (Design note 6).

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