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'sbothy.terminal.relay.unpairedwith 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_sweepof 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 inadapters/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 servicebothy-host" (only the parts onreportand on restarts); 2.3 "Server host"; 3.1 "States" ("Every transition out ofactiveends all sessions"); 5.1 "Protection goal"; 5.4 "Frames" (input framesdataandresize); 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" (rowterminal.terminate, "Never in the audit"), 10.2 "Events", 10.3 "Reasons for ending (terminal_sessions.close_reason)", 10.4 "Privacy"; 13 "Errors" (terminal_deniedreasons, close codes4100to4104); 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 theterminalcapability"; 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_authand its counting of failed credentials); 4.14 "Hosts and terminal" (rowsterminal.open,terminal.attach,terminal.stream,terminal.close,terminal.terminate,host.connect,host.manage); 6 (rowterminal_session.changed); 7 (terminal channels).docs/spec/03-data-model.md(core contract, read only): 4.14hosts,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_sessionsanddevices(revoke_reasonlists);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" (entrieskind: 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" (rowsidentity,terminal), 4.4 "Allowed dependencies" (rowterminal), 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" (rowterminal.sweep), 12.4 "Rules for jobs".docs/spec/20-privacy.md: rowsterminal_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: operationsopenTerminalSession,attachTerminalSession,closeTerminalSession,getTerminalSession,listTerminalSessions,updateHost,setHostState,setHostLlmCredentials,issueHostPairingCode,createGrant,revokeGrant,addGroupMember,removeGroupMember,revokeOwner,disableUser,revokeDevice,revokeSession,logout,createStepUpChallenge,completeStepUpWithDeviceKey; schemasTerminalSession,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), headerRetry-After.docs/api/asyncapi.yaml: channelsterminalStream,hostControl,hostDatawith theirx-close-codes(4004,4008,4012,4100,4110,4113); messageshost.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 rowsD-M2-39-<n>toD-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: theterminal.*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 7EndSessionsandSessionSelector; host entries inDiffVisible;HostSourceandHostFacts),M2-40-terminal-authz.md(notes 4ConsumeFresh, 5 guards and the extension point forterminal_session_limit, 7TerminalEffectivein the diff, 8 the report, 10 I6 ends, 11 SR10 on an assignee change withPeople.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, 6live_sessions, 7ControlLinkswithCloseSessionandConnected, 8 cSessionsEndedByReport, 10ValidateHostSessionEnd), and the briefs of M2-44 and M2-45 indocs/briefs/(index indocs/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 jobauthz.expiry_sweepwith the system actionsgrant.expireandgroup.member.expire), M1-34 (SessionEndedat logout), M1-45 (DeviceRevoked,SessionEnded, revoke reasons), M1-65 (per-process tail loopTail -> 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,appwithEndSessionsand the outgoing portPeople,adapters/postgres,adapters/httpapi,adapters/wsrelaywithControlLinks),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 ofterminalandauthz.
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. Host names and session user names in tests are neutral (r1,r2,r3,term-alex,term-sam,term-robin). - 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. - 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:
- 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
reportwithout its name (SR12), and a change that violates SR10 or I6. The connections close with4100, 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). close_reasononly from the closed list of 24 10.3 (equal to the check of 03terminal_sessionsand toTerminalCloseReason);close_detailonly from the closed list of 24 10.3 (equal toTerminalCloseDetail), never free text (24 10.3, 03terminal_sessions).- The limits of 24 6.3 work without the host service: the relay ends a session after
terminal.idle_timeoutplusterminal.idle_gracewithout bytes from the device, atterminal.max_session_durationafteropened_atalso across reattaching, and afterterminal.detach_graceindetached; the jobterminal.sweepchecks the same everyterminal.sweep_intervalin the database for sessions of a failed node (24 6.2, 6.3; 02 12.3; R-TM-11; D-T23; D-T39). terminal.max_sessions_per_personandterminal.max_sessions_per_hostcount sessions that are notclosed; reaching one deniesterminal.openwith the decisionterminal_session_limit, answered as403 terminal_deniedwithreasonsession_limit(24 6.1 step 2, 6.3, 13; 07 4.1).rl.terminalper human for opening, attaching and connecting a stream;rl.hostper 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 ofrl.public_authof the client address (05 3; 02 9.1; 24 6.3; D-T34).- Every end by the server or by the host writes one audit entry
terminal.terminate(system action, Audityes) withsession_id,close_reason,close_detail,bytes_in,bytes_outand the duration; never terminal content, commands, environment variables, paths or profile command lines (24 10.1; 19 SEC-AUD-5; R-TM-10). - 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).
- 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).
terminal_sessionsholds metadata only, with retention classsecurity(retention.security_after_endafterclosed_at); there is no content recording (24 10.4; 20 R-DP-07; OQ-04 default).- The stream is never an event; the event log carries only the personal event
terminal_session.changedper state change; no JSONnull(24 10.2; ADR 0053).
Terminal security requirements (D-W-101):
- Rights per machine: additional right
terminalon the targethost, SR10, SR11 with I6, SR12 (07 "Hosts and theterminalcapability"). This ticket enforces the consequence of rights changes that come from outsideterminal: ahostentry removed from a person'saccess.changed(grant revoked or expired, group left or expired, owner role lost, I6 for other owners; computed byauthzwithTerminalEffectiveof 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 insideterminalend 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 guardterminal_session_limit. Proven byTestTMT10_RevocationKinds,TestSR10Lost,TestReasonForLostHostandTestSessionLimit_*. The rights themselves (terminal.*, AZ-M54 to AZ-M61, TM-T08, TM-T09, TM-T28 (a)) belong to M2-39 and M2-40. - 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
freshand 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: nosession_tokenis issued for an ended session, also from the same device withinterminal.quick_reattach_window) and that a limit denial does not consume the step-up (TestSessionLimit_DenialKeepsStepUp). - 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 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 enforcesrl.terminal,rl.hostand the counting of failed credentials inrl.public_authon the existing paths against floods (T-DS-01), proven byTestRateLimit_Terminal,TestRateLimit_HostandTestRateLimit_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. - Audit without content: owned here.
terminal.terminatecarries exactly the codes and numbers of 24 10.1,terminal_sessionsholds metadata only, metrics have closed attributes without IDs of people or host names, and no span is created per frame. Proven byTestTMT06_NoContentAnywhere,TestSEC_T10_TerminalInputOutput,TestAuditTerminate_Details,TestTerminalMetrics_ClosedAttributes,TestTelemetry_NoSpanPerFrameandTestTerminalSessionsHoldOnlyMetadata. - 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_timeoutplusterminal.idle_grace,terminal.max_session_durationandterminal.detach_grace, andterminal.sweeprepeats the checks for a failed node. Proven by theTestTMT11_*tests, including a manipulated fake host that never reports idle. M2-45 measures idle on the host from the input framesdataandresize; M2-43 accepts the host'sidle_timeoutanddetach_expiredreports. - Service user and session users without administrator rights (T12, HD rules): M2-42 builds
install,doctorwith 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. - 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_NoContentAnywhereandTestSEC_T10_TerminalInputOutput; T-TM-11 (session or processes stay after revocation) byTestTMT10_RevocationKinds,TestTMT10_CloseReachesOtherProcess,TestTMT10_HostOfflineGetsCloseOnReconnect,TestTMT10_SessionCloseEndsProcessTreeand theTestTMT11_*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 ofTestTMT10_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) byTestRateLimit_*andTestSessionLimit_*, and "Host service R" (T-AZ-04) byTestAuditTerminate_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 holdsCloseReason): the closed setCloseDetailnext to M2-39'sCloseReasonif 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)andSR10Lost(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;GrantedAtis 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)andPersistDue(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/: argsSweep{}with kindterminal.sweepand its handler on queuemaintenance.internal/terminal/app/metrics.goandinternal/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 forbothy.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, eachORDER 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 setsclose_reason.services/server/test/terminal/:revocation_test.go,deadlines_test.go,limits_test.go,ratelimit_test.go,contentfree_test.go(packageterminal_test, integration tests of this ticket; M2-48 later turns them into its data-driven suite).
Change:
internal/terminal/app: the end functionEndSessionsof M2-39 (withSessionSelector.ExceptUserIDof M2-40 and what M2-43 and M2-44 added) gains the selector membersSessionID,DeviceIDandAuthSessionIDs []ids.ID(zero or empty: not used) and an end valuedomain.End{Reason CloseReason; Detail CloseDetail; ExitCode *int32; ClosedBy ids.ID; Source EndSource; Progress *Progress}withEndSourceserver,hostorclose_requestandProgress{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 forexit_code, and keep the shape M2-43 or M2-44 gave the end if they already carry detail or exit code.SessionsgainsEndSession(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.AfterFuncrelative toclock.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) gainsOpenSessionsOfPerson, OpenSessionsOfHost int;openTerminalSessionpassesResource.Terminalwith both counts (a nilTerminalforterminal.openis an internal error).internal/authz: guardterminal_session_limitat M2-40's extension point after SR12, before M2-44'shost_not_activeandterminal_profile_unknown; the mapping row exists since M2-40 (its Design note 12).internal/identityport: the read of Design note 8 c if missing, with its sqlc query ininternal/identity/adapters/postgres; the outgoing port ofterminalthat uses it.internal/wire: subscriptionskernel.AccessChanged -> terminal,identity.DeviceRevoked -> terminal,identity.SessionEnded -> terminalas method values of theterminalapp type after the existing ones, golden fileinternal/wire/testdata/txn_registrations.goldenregenerated; jobterminal.sweepfor the rolesworkerandallasPeriodicJob{Interval: terminal.sweep_interval, RunOnStart: true}; the configuration keys this ticket is the first consumer of amongterminal.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 inconfigchapters.goif 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/fakehostand 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 everysession.closereceived.services/host/internal/session(andinternal/controlif needed), only if the host service does not yet end a session with its process tree onsession.closeand on4100of the data connection: that handling; the testTestTMT10_SessionCloseEndsProcessTreein any case.
Docs (English, like the files):
docs/spec/24-terminal.md: 6.3 the persistence rule oflast_input_at,bytes_inandbytes_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 thatclose_detailis set exactly withopen_failedandhost_policy_violation(Design note 2); 8.3 one sentence that an established stream ends with4100also after an identity revocation (Design note 3).docs/spec/02-architecture.md: 12.3 rowterminal.sweep(Design note 8).docs/observability/signals.md4.3a: the "Source" column ofbothy.terminal.sessions.active(relay-held) andbothy.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 classsecurityand trigger),services/server/internal/authz/README.md(limit guard),services/server/internal/identity/README.mdif the port changed,services/server/test/canary/README.md,services/server/CHANGELOG.mdand, if host code changed,services/host/CHANGELOG.mdunder "Unreleased";docs/DECISIONS.mdsection 10 rowsD-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 atgrant.createandhost.manage,terminal_not_effective, host entries inaccess.changed(M2-39, M2-40); step-upfreshand its consumption (M2-40). - The session ends that the use cases inside
terminalalready 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_failedandcloseTerminalSession(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,doctorand HD1 to HD11 (M2-42); control connection,reportstorage, presence andlive_sessions(M2-43); opening, attaching, session token, relay pairing,open_timeoutin 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_sessionsrows afterretention.security_after_end(jobretention.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 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).
- 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(Got.TempDir()), removed at the end. The host-side test runs as the current unprivileged user; never usesudoor 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.mdon the base branch shows OQ-04 decided as "yes" (content recording), stop withVerdict: 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
claudein this ticket; the host side of the server tests is the fake host, and the host-side test drivesinternal/sessiondirectly. - Time only through
kernel/clockand 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¶
-
One end function. M2-39's
EndSessionsstays 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 withstate <> 'closed'FOR UPDATEin ascendingid, validates the pair of note 2, setsstate = 'closed',detached_at = NULL(03 check(state = 'detached') = (detached_at IS NOT NULL)),closed_at,close_reason,close_detail,exit_code(onlyprocess_exitedfrom the host),closed_by(onlyclient_closedandclosed_by_ownerfromcloseTerminalSession), takesbytes_in,bytes_outandlast_input_atfromend.Progress(and fromRelayControl.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), raisesversion, writesterminal_session.changedand, unlessend.Sourceisclose_request, oneterminal.terminateentry (note 11);bothy.terminal.sessions.closedis recorded after the commit (note 12). A row that is alreadyclosedis 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. -
Closed lists and pairs (24 10.3). M2-43 accepts from the host only
client_closed,process_exited,idle_timeout,detach_expired,open_failedandhost_policy_violation, withclose_detailrequired exactly foropen_failed(any of the 14 codes) andhost_policy_violation(subscription_login_detected,helper_untrusted,shared_location) (ValidateHostSessionEnd, M2-43 Design note 10).ValidateEndapplies the same pair rule to every end insideEndSessions, 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. -
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 with4100and the close textclose_reason, and the process that holds the host's control connection must callControlLinks.CloseSession(M2-43), which sendssession.close {session_id, close_reason}. M2-44 built this path and this ticket builds no other (M2-44 Design note 11): every end writesterminal_session.changedin its transaction, and the event-log tail of every api process hands each committed one withstate = closedto the relay observer, which closes the connections of the session held in that process and callsControlLinks.CloseSession(a no-op without the link). The tail reads the committed rows ofevent_logfrom the database, woken byNOTIFY event_log, which is on in every mode (03 6.6), and polls everysync.log_poll_interval(02 11.2). So an end committed in a worker process while api and worker run as separate processes withcluster.mode = single(02 16 level 1), and an end committed on another node withcluster.mode = multi(02 16: every node has its log tail), reach the process that holds the connections at once, withoutNOTIFY ephemeraland without M2-47. A host service that was offline getssession.closeat its next login throughlive_sessions(M2-43 Design note 6 b). Ruling for docs/DECISIONS.md: an established stream always ends with4100and the reason, also after an identity revocation (24 8.3), while4012stays the answer at streamidentifywhen 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). -
Rights changes from outside
terminal(OnAccessChanged(ctx, authorize.AccessChanged), phase 3.1 of the revoking transaction, 02 8.1 and 8.3). It never callsauthorize, because the snapshot cache does not see uncommitted rights (M1-29).a. For every principal in
Changes: each target of kindhostinRemovedends that person's sessions on that host (SessionSelector{HostID, UserID}).authzcomputes the host set withTerminalEffectiveof M2-40 (effectiveterminalof 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
Changesthat still has open sessions, readPeople.IsActiveOwner(M2-40) in the transaction; when it is false, end each remaining session for whichSR10Lostholds: the host'sAssignedUserIDis not the person, or the host hasDeclaredServerHostorReportedIsServerHost(HostFactsof 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 afterowner.remove.c. Reasons by
ReasonForLostHost:AccessChanged.Actionuser.disablegivesuser_disabled; a host withLLMCredentials = personalwhoseCredentialHolderIDis not the person givesi6_violation; every other caseaccess_revoked. Ruling for docs/DECISIONS.md: removed host entries and a lost owner status are the two signals of a rights change outsideterminal, and both are consequences of the commit, not new rights decisions. -
Identity revocations (method values of the
terminalapp type, synchronous in the transaction of the cause).a.
OnDeviceRevoked(ctx, identity.DeviceRevoked): the open sessions whose currentdevice_idis the device;ReasonForDeviceRevoke:user_disabledgivesuser_disabled, every other device revoke reason of 03devicesgivesdevice_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 whoseauth_session_idis one of the ended sessions (one ID or a list, as the merged event carries it);ReasonForSessionEnd:logoutgiveslogout,user_disabledgivesuser_disabled,device_revokedgivesdevice_revoked, every other session revoke reason of 03auth_sessionsgivessession_revoked.c. Ruling for docs/DECISIONS.md: device and login-session revocations end sessions through the identity events because they carry the exact reason (
logoutagainstsession_revoked); the rights path of note 4 ends what else became unusable; both are idempotent throughEndSessions, so the order ofDeviceRevoked,SessionEndedandAccessChangedwithinuser.disabledoes not matter. -
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. -
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
identifyseen by this process,last_input_at,started_atandopened_at; the session ends withidle_timeoutwhenclock.Now()reaches reference plusterminal.idle_timeoutplusterminal.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_atplusterminal.max_session_duration, unchanged by reattaching; reasonmax_duration. Detach: indetached,detached_atplusterminal.detach_grace; reasondetach_expired.c. A deadline is reached at the instant itself (a check one clock tick before keeps the session). The relay ends through
EndSessionwithSourceserverand its in-memoryProgress.d. Progress persistence:
ProgressandRelayControl.Counterscarry session totals: the row'sbytes_inandbytes_outread when the relay began the pairing plus the live pairing's counts (M2-44 Design note 7 "Counting"). The relay writes these totals andlast_input_atof a held session at most once perterminal.idle_grace(PersistDue) and when the pairing or the session is released, onlyWHERE state <> 'closed', only increasing values (GREATESTwith the stored value), without raisingversionand 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'sTestRelay_ByteCountersAndLastInputstays green unchanged as the regression test. Ruling for docs/DECISIONS.md: the stored value lags the true last input by less thanterminal.idle_grace, so the sweep, which ends at stored value plusterminal.idle_timeoutplusterminal.idle_grace, never ends a session beforeterminal.idle_timeoutof real inactivity; counters do not changeversion, like reactions on messages (04 9 point 3); update 24 6.3. -
terminal.sweep(worker; everyterminal.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
SweepVerdictends a session with the first match in this order: host notactive(HostStateEnd:disabledgiveshost_disabled,retiredgiveshost_retired,pairingandconfirminggivehost_rekeyed); login session ended (session_revoked);openingpast the last grant plusterminal.open_timeout(open_failedwithopen_timeout), except on a host whosehosts.reported_require_local_confirmis 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_timeoutwith the storedlast_input_at. Each end goes throughEndSessionswithSourceserver. The last grant is notopened_at, because an attach to anopeningsession restarts the opening with a new deadline whileopened_atnever changes (M2-44 Design note 6 d and f); its time is the UUIDv7 timestamp ofterminal_sessions.token_jti, which every grant renews (M2-44 Design notes 4 d and 6 e; if the merged attach writes ajtithat 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_atassession_revoked(07 4.1 "session revoked or expired"; no event marks the expiry, the same pattern asauthz.expiry_sweepfor grants, 07 8.3); theopeningfallback for a node that died beforeterminal.open_timeout, measured from the last grant and not applied on a host that reportsrequire_local_confirm; update 02 12.3.c. "Login session ended" comes from the
identityport: use the read M1-34 or M1-45 offers for the state of a list of sessions; if none exists, addAuth.EndedSessions(ctx context.Context, sessionIDs []ids.ID, at time.Time) ([]ids.ID, error)returning those revoked or withexpires_atnot afterat, and bind it to an outgoing port ofterminalininternal/wire.d. The pairing part of the 02 row ("discard expired pairing codes") cannot be built as written: 03
hostsrequirespairing_code_hashwhilestate = 'pairing', andpairHostalready 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). -
Session limits. In the unit of work of
openTerminalSession, beforeauthorize: takepg_advisory_xact_lock(hashtextextended('terminal.open:' || <user_id>, 0))for the person, then lock the host rowFOR UPDATE, then count the sessions of the person and of the host withstate <> 'closed'and pass them inTerminalFacts. The guard after SR12 denies withterminal_session_limitwhen the person's count is at leastterminal.max_sessions_per_personor the host's count at leastterminal.max_sessions_per_host; the answer is403 terminal_deniedwithreasonsession_limit(M2-40's mapping row), audited as every denial ofterminal.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'sConsumeFreshmust 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 ofterminalhas 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. -
Rate limits (05 3, the limiter of M1-19 before
authorize).a.
openTerminalSessionandattachTerminalSessionuserl.terminalkeyed by the human principal and answer429 rate_limitedwithRetry-After; the device stream counts against the same bucket atidentifyafter the credential check and closes with4008and the wait time in the close text.b. Host control and data connections use
rl.hostkeyed by the host ID after the proof check (M2-43 Design note 3) and close with4008likewise.c. A failed check of presented credentials on the three channels (device stream
4004or4012; host proof4110or4113) consumes one token ofrl.public_authof the client address; when that bucket is empty, a new connection from the address that presents credentials closes with4008before 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.rejectedwith its class. If M2-43 or M2-44 already applied a class, only add the tests. -
Audit.
terminal.terminate(system action, actor kindsystem, resourceterminal_session) withAttrsexactlysession_id(ID),close_reason(code),close_detail(code, only when set),bytes_in,bytes_outandduration_ms(integers;closed_atminusopened_at), as M2-39 Design note 7 started it. An end throughcloseTerminalSessionkeeps theterminal.closeentry of M2-44 and writes noterminal.terminateentry. Ruling for docs/DECISIONS.md if M2-39 did not record it: the keyduration_msfor "duration" of 24 10.1, and one entry per end. -
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.reasonfrom the 18 codes ofTerminalCloseReason): +1 after the commit of each end, in the process that committed it (api or worker).b.
bothy.terminal.sessions.active(bothy.terminal.stateopening,active,detached): sessions held by the relay of this api process; +1 inopeningwhen the relay registers the first side, moved on each committedterminal_session.changedof 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 whenControlLinks.Connectedof M2-43 turns true for a host in this process, -1 when it turns false.bothy.terminal.relay.unpairedis not registered here: M2-44 registers and records it, and M2-47 asserts it with two nodes (D-W-105). -
No content anywhere (TM-T06, SEC-T10). The device test client and the fake host perform the real handshake
Noise_NK_25519_ChaChaPoly_SHA256with the prologue of 24 5.2 and encrypt every frame; if the merged doubles of M2-44 do not, extend them in test code withgithub.com/flynn/noiseat the versionservices/host/go.modpins (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'sinternal/noisecannot be imported from another module. Sinks: the raw binary WebSocket messages both doubles sent and received (what passed the relay), server logs attelemetry.logs.leveldebugwithtelemetry.logs.exporterboth, traces withtelemetry.traces.sampler_ratio1.0, metrics, every row of every table of the domain schema and of the River schema read as text throughAdminDB()(what a backup would hold),event_logpayloads andaudit_events.details. Matching usesScanoftest/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. -
Retention and recording.
terminal_sessionskeeps retention classsecurity(README table, the 20 row) and its rows are deleted byretention.securityof M7-05, not here (P-13: no job ahead of its owner).TestTerminalSessionsHoldOnlyMetadatacompares 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). -
Concurrency and locks. Within
terminal: person lock (note 9), thenhostsrows, thenterminal_sessionsrows in ascendingid; neverterminal_sessionsbeforehosts. A subscriber in phase 3.1 locks onlyterminal_sessionsrows, afterpermission_version(phase 1) and beforeevent_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). -
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, theCHECKlist of 03terminal_sessions.close_reasonand the enum ofTerminalCloseReason; the files are found by walking up to the repository root);TestCloseDetailsMatchSpec(the Go set equals the list in 24 10.3 andTerminalCloseDetail);TestValidateEnd(every allowed pair passes;open_failedwithout detail,host_policy_violationwithtoken_invalid,access_revokedwith 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 withDeclaredServerHostorReportedIsServerHostloses);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 plusterminal.idle_timeoutplusterminal.idle_gracekeeps, the instant ends; maximum fromopened_atafter a reattach; detach fromdetached_at; the earliest deadline wins);TestSweepVerdict(each rule at its boundary, the precedence order of note 8, idle reference with and withoutlast_input_at; theopeningrule measured fromGrantedAt, notopened_at, and not applied withhostLocalConfirm);TestPersistDue.internal/authz:TestTerminalOpenLimitGuard(below both limits allows; at the per-person or per-host value denies withterminal_session_limit; the guard runs after SR12 and beforehost_not_activeandterminal_profile_unknown; missing counts forterminal.openare an internal error);TestDecisionErr_TerminalSessionLimit(problemterminal_denied,reasonsession_limit, audit reasonterminal_session_limit).internal/terminal/...:TestEndingSQLOnlyInEndSessions(exactly one statement inadapters/postgres/queries/*.sqlsetsclose_reason);TestTerminalMetrics_ClosedAttributes(the registryCheckof M1-68 passes for the three instruments of this ticket and M2-44'sbothy.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 expectedclose_reason:revokeGrantof Alex's grant onr1(access_revoked); the grant'sexpires_atpassed and theauthz.expiry_sweephandler run (access_revoked);removeGroupMemberof Sam from the group Engineering onr2(access_revoked), and the membership expiring (access_revoked); Sam is made a second owner and works onr1(not its assignee) and onr2, thenrevokeOwnerof Sam ends ther1session (access_revoked, SR10) while ther2session (assignee with the group grant) stays open;disableUserof Alex (user_disabled);revokeDeviceof Alex's device (device_revoked);revokeSessionby Robin (session_revoked); the login session'sexpires_atpassed and the sweep handler run (session_revoked);logoutby Alex (logout);setHostStatedisabled (host_disabled) and retired (host_retired);issueHostPairingCodeonr1(host_rekeyed);updateHostwithrevoke_session_usersfor Alex (access_revoked); a fake hostreportwithoutterm-alex(access_revoked);updateHostmoving the assignee ofr1to Sam (access_revokedfor Alex); areportwithis_server_hosttrue (access_revokedfor Alex, Robin's session stays); a hostr3(organization, no assignee, no grant, session usersterm-robinandterm-sam) where Robin and Sam (made a second owner for this row) have sessions, thensetHostLlmCredentialsofr3topersonalwith holder Robin (i6_violationfor Sam, Robin's session stays). For every row assert: the row isclosedwith that reason and no detail in the same commit as the cause (samecorrelation_idon the cause's audit entry or event and onterminal.terminate), oneterminal_session.changed, oneterminal.terminateentry, the device stream closed with4100and close text equal to the reason and the host data connection with4100within a test deadline after the request or job handler returned, onesession.close {session_id, close_reason}at the fake host,bothy.terminal.sessions.closedraised for that reason, and every session the cause does not concern untouched.TestTMT10_NoReattachAfterEnd: after each row above,attachTerminalSessionfor the ended session (from the same device withinterminal.quick_reattach_windowwhere its credentials still work) answers a problem and issues nosession_token; the row stays closed.TestTMT10_HostOfflineGetsCloseOnReconnect: the fake host drops its control connection;revokeGrant; the host reconnects with the session inlive_sessionsand receivessession.closewithaccess_revokedright afterready.TestTMT10_IdentityAndRightsBothFire:disableUserpublishesDeviceRevoked,SessionEndedandAccessChangedin one transaction; the session ends once withuser_disabled, one audit entry, one event.TestTMT10_CloseReachesOtherProcess: withcluster.mode = single, two server instances on one database (as AZ-P9 of M1-65 runs them) and a third in roleworker; the session's connections are held by instance B;revokeGranton instance A and the sweep handler on theworkerinstance both close B's connections with4100, the instance holding the host's control connection sendssession.close, and noNOTIFY ephemeralis involved.TestTMT11_IdleWithoutHostReport: the fake host in the mode "never reports idle"; the device sends nothing; one tick beforeterminal.idle_timeoutplusterminal.idle_gracethe session is open, at the instant it ends withidle_timeoutand4100;TestTMT11_InputResetsIdle(adataframe moves the deadline);TestTMT11_IdleReportedByHost(the fake host reportssession.endedwithidle_timeoutatterminal.idle_timeout; reasonidle_timeout,Sourcehost).TestTMT11_MaxDurationAcrossReattach: open, device drops (detached), Alex attaches from a second device with a fresh step-up; the session ends withmax_durationatopened_atplusterminal.max_session_duration.TestTMT11_DetachGraceWithoutHost: the device drops, the fake host does not end the PTY; atdetached_atplusterminal.detach_gracethe server ends withdetach_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 throughAdminDB(), login session expired,openingpastterminal.open_timeout, maximum, detach, idle with a storedlast_input_at) the sweep handler ends the session with the expected reason; a session within all windows stays; anopeningsession whose device dropped and that was attached again beforeterminal.open_timeoutstays when the sweep runs afteropened_atplusterminal.open_timeoutbut before the new grant plusterminal.open_timeout, and ends withopen_failedandopen_timeoutafter it; anopeningsession on a host that reportsrequire_local_confirmtrue stays past the last grant plusterminal.open_timeout; running the handler twice changes nothing more.TestTMT11_ProgressPersistence:last_input_at,bytes_inandbytes_outare written at most once perterminal.idle_graceand at release,versionunchanged, no event; theterminal.terminateentry carries the relay's current counts for a relay-decided end; after periodic writes, a detach, a reattach and an end, row andterminal.terminatecarry the sums of both pairings, each byte counted once (M2-44'sTestRelay_ByteCountersAndLastInputstays green).TestSessionLimit_PerPerson(terminal.max_sessions_per_personset low in the test config: the next open by Robin on another host gives403 terminal_deniedwithreasonsession_limitand 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 withinterminal.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.burstlow: open and attach share the bucket, the next gives429 rate_limitedwithRetry-After; then the device streamidentifycloses with4008; Sam's bucket is unaffected;bothy.ratelimit.rejectedwithrl.terminal);TestRateLimit_Host(ratelimit.rl.host.burstlow: further control or data connections ofr1close with4008after the proof check;r2is unaffected);TestRateLimit_FailedCredentials(ratelimit.rl.public_auth.burstlow: host connections with a bad proof close with4113until the bucket is empty, then the next connection from that address closes with4008without 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_sessionsholds only allowed pairs of note 2.TestAuditTerminate_Details(details are exactly the six keys, codes and integers only,duration_msequalsclosed_atminusopened_at) andTestAuditTerminate_ExactlyOnce(one entry per end; none for an end throughcloseTerminalSession, which keeps itsterminal.closeentry).TestEventsValidate: everyterminal_session.changedrow validates againstTerminalSessionChangedEvent, is personal to the session's person, has no JSONnull.TestTMT06_NoContentAnywhere: Alex opens ashellsession; the device sends canary keystrokes (classcontent) indataframes and aresize, 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 (classsecret) appears only in the response ofopenTerminalSessionand inside the encrypted message 1.TestTelemetry_NoSpanPerFrame: streaming 10 and then 100dataframes creates the same number of spans; no log line or span attribute carries frame bytes.TestTerminalSessionsHoldOnlyMetadata: the columns ofterminal_sessionsin the migrated database equal the 03 list.TestWire_TerminalSubscriptionsAndSweep: the golden registrations list the three subscriptions after the existing ones;terminal.sweepon queuemaintenance, periodic withterminal.sweep_interval,RunOnStart, rolesworkerandallonly.services/server/test/canary:TestSEC_T10_TerminalInputOutput(the main scenario gains the terminal step of note 13 with canary input and output;TestSEC_T10_CanaryAcrossAllOutputsstays green with it; ifTestSEC_T10_ScenarioCoversEveryEnabledOperationmisses 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), ashellsession starts a background process withsetsidand one withnohup(Windows: a detached child process, behind its build tag); asession.closewithaccess_revokedover a fake control connection, and in a second run a4100on 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 inmake 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 andterminal_session.changed; both connections close with4100on whichever api process holds them, the host service receivessession.close, also after reconnecting when it was offline. - TM-T10 (host side):
session.closeand4100on the data connection end the PTY and background processes started withsetsidandnohup(Windows: a detached child) on the build machine's platform. - TM-T11: idle, maximum duration and
terminal.detach_graceend the session withidle_timeout,max_durationanddetach_expired, also with a fake host that never reports idle and, throughterminal.sweep, for a dead node; an expired login session ends its sessions withsession_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_reasonandclose_detailcome only from the lists of 24 "Reasons for ending", in the pairs of note 2; one statement writes them. -
terminal.max_sessions_per_personandterminal.max_sessions_per_hostgive403 terminal_deniedwithreasonsession_limit(decisionterminal_session_limit), exactly under concurrency, without consuming the step-up. -
rl.terminalgives429 rate_limitedfor opening and attaching and4008on the device stream;rl.hostgives4008per host after the proof check; failed credentials on the terminal channels consumerl.public_auth. - A removed
hostentry ofaccess.changedand 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.closedandbothy.terminal.hosts.connectedare registered once with closed attributes, next to M2-44'sbothy.terminal.relay.unpaired;terminal.terminatecarries only codes and numbers. -
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,test/canaryand (if changed)identityupdated (subscriptions, job, the end function, the close path, deadlines, limits, metrics, tables with retention classsecurity). - Rulings of note 16 recorded in
docs/DECISIONS.mdsection 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.mdentry under 'Unreleased' (andservices/host/CHANGELOG.mdif 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 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 host-side test on the platforms other than the build machine's).
- 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>.