| D-M2-46-1 |
2026-10-20 |
One end function: every end of a terminal session (server, host report, relay, terminal.sweep, subscriber, closeTerminalSession) goes through EndSessions and its one end path finish, with the one closing statement EndTerminalSession (WHERE state <> 'closed'). The first committed end wins for every path: a later report or job for a closed session changes nothing and writes no audit entry, so every end has exactly one entry (terminal.terminate, or terminal.close for an end by a person). |
A relay deadline, the sweep, a revocation and a host report can race on one session; one writer with a guarded statement ends it once and keeps one row of evidence (threat model 5.16 "Host service R", T-AZ-04). Place: app/sessions_end.go, adapters/postgres/queries/ending.sql, 24 8.3. |
| D-M2-46-2 |
2026-10-20 |
The pair rule of 24 10.3 holds for every writer, not only for host reports: domain.ValidateEnd checks every end inside finish; close_detail is set exactly with open_failed (any detail of the list) and host_policy_violation (subscription_login_detected, helper_untrusted, shared_location), never with another reason; any other pair is an internal error and is never stored. |
M2-43 checked only the host's session.ended; a server end with a stray detail would store a pair the closed lists forbid and the client could not read. Place: domain/ending.go, 24 10.3. |
| D-M2-46-3 |
2026-10-20 |
An established stream ends with 4100 and the close_reason as close text after every revocation, also after an identity revocation (24 8.3; only an open_failed with a token or handshake detail keeps 4101 or 4104 of StreamCodeForEnd, M2-44); 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). |
The relay closes after the commit from the log tail and sees only the reason of the end; one code for every end of a running session keeps the client reaction of 24 13 (do not reconnect, the host ends the PTY), and the setup mapping of 04 stays untouched. Place: adapters/wsrelay/relay.go, 24 8.3. |
| D-M2-46-4 |
2026-10-20 |
Removed host targets of access.changed and a lost owner status are the two signals of a rights change outside terminal. OnAccessChanged ends that person's sessions on each removed host (reason user_disabled for user.disable, i6_violation on a personal host of another holder, else access_revoked) and then, when People.IsActiveOwner is false in the transaction, every remaining session that domain.SR10Lost names. Both are consequences of the commit, not new rights decisions: the subscriber never calls authorize. |
authz computes the removed hosts with TerminalEffective (I6 aware), so grants, groups, expiry, owner role and I6 all arrive as one signal; SR10 needs the owner status itself (07 SR10 "on every change of the owner status"); the snapshot cache does not see uncommitted rights (M1-29). Place: app/revocation.go, domain/ending.go. |
| D-M2-46-5 |
2026-10-20 |
A device revocation ends the open sessions whose current device_id is the device (the device of the last opening or attaching, the column the index of 03 serves), not those it once opened and another device took over; user_disabled as the device's revoke reason gives user_disabled, every other device_revoked. |
The current device holds the session token and the stream; a device that handed the session over has no access to it any more. Place: app/revocation.go (OnDeviceRevoked), queries/ending.sql (LockOpenSessionsOfDevice). |
| D-M2-46-6 |
2026-10-20 |
Device and login-session revocations end sessions through identity.DeviceRevoked and identity.SessionEnded, because they carry the exact reason (logout against session_revoked, user_disabled, device_revoked); the rights path of D-M2-46-4 ends what else became unusable. All three subscribers are idempotent through the one end function, so the order of DeviceRevoked, SessionEnded and AccessChanged within user.disable does not matter. |
The person sees why the session ended; the first end in the unit wins (D-M2-46-1) and the later subscribers find the rows closed. Place: app/revocation.go, domain/ending.go. |
| D-M2-46-7 |
2026-10-20 |
Progress persistence, DEVIATION from the brief's "session totals with GREATEST" (Design note 7 d): the relay keeps M2-44's additive form. It writes the counts since its last write (taken from the slot under its lock; a failed periodic write gives them back) as additions to bytes_in and bytes_out, at most once per terminal.idle_grace while bytes flow and when a pairing is released, last_input_at only forward, without a new version and without an event; the end adds the counts the committing process has not written yet. So no byte is counted twice, and the stored last_input_at lags the true last input by less than terminal.idle_grace, so terminal.sweep never ends a session before terminal.idle_timeout of real inactivity. |
Totals with GREATEST need the row value read at the start of a pairing and break when two pairings of one session (an attach on another node, a write after a reconnect) report overlapping totals; differences taken under the slot lock add up exactly in every order. Counters never raise version, like reactions on messages (04 9 point 3). Place: adapters/wsrelay/relay.go (persistDue, giveBack), app/sessions_relay.go, queries/ending.sql, 24 6.3. |
| D-M2-46-8 |
2026-10-20 |
Order of terminal.sweep, security reasons before limits: host not active, login session ended, opening past the last grant plus terminal.open_timeout, max_duration, detach_expired, idle_timeout with the stored last_input_at. A session whose login session passed expires_at ends as session_revoked. The opening fallback is measured from the last grant, the UUIDv7 time of token_jti, which every grant renews and which the use cases now generate on their clock (ids.Generator over kernel/clock); it is not applied on a host that reports require_local_confirm. |
A revocation reason says more than a limit when both hold; 07 4.1 reads session_revoked as "session revoked or expired", and no event marks an expiry, the pattern of authz.expiry_sweep (07 8.3). opened_at never moves when an attach restarts an opening; on a local confirmation the consent window may exceed terminal.open_timeout and the host service bounds it (M2-44, M2-45). Place: domain/deadlines.go (SweepVerdict), app/sweep.go, 02 12.3, 24 6.3. |
| D-M2-46-9 |
2026-10-20 |
Spec gap: the pairing part of the 02 12.3 row of terminal.sweep ("discard expired pairing codes") cannot be built as written. The row now says that an expired code stays stored until the next code or the retirement and is rejected at redemption. |
03 hosts requires pairing_code_hash while state = 'pairing', and pairHost already rejects an expired code (M2-41, TM-T20). Place: 02 12.3. |
| D-M2-46-10 |
2026-10-20 |
Session limits: the unit of work of terminal.open takes an advisory lock for the person first (pg_advisory_xact_lock over terminal.open: and the person ID), then consumes the step-up as the first row lock, then locks the host row and counts. The test is "at least the key value" of terminal.max_sessions_per_person and terminal.max_sessions_per_host. The decision runs before the unit with unlocked counts; the unit compares the locked counts with them and starts the opening again when they moved, at most database.tx_retry_max times, then 503 unavailable. The recheck after the commit does not repeat the limit. |
No table of terminal has one row per person, so only an advisory lock serializes the openings of one person; taken first it leaves 03 6.3 free of cycles. The comparison keeps the decision in authz and makes the limit exact under concurrency, while a denial rolls back and keeps the step-up; the recheck would count the new session itself. Place: app/sessions_open.go, queries/ending.sql, 24 6.3. |
| D-M2-46-11 |
2026-10-20 |
The rule of 02 9.1 applies to the WebSocket channels of the terminal: a failed check of presented credentials (device stream 4004 or 4012, host proof 4110 or 4113) consumes one token of rl.public_auth of the client address, reserved before the check and given back when it succeeds; with the bucket empty the connection closes with 4008 before any check. rl.terminal counts the person at identify of the device stream after the credential check, rl.host the host after the proof check; every refusal closes with 4008 and the close text retry_after=<seconds>. |
M2-43 left the counting of failed proofs to this ticket; without it a flood of invalid credentials on the channels would bypass the REST rule (T-DS-01). Counting after the check keeps a stranger from draining the bucket of a person or a host. Place: adapters/wsrelay/limits.go, stream.go, control.go, internal/wire/terminal_streams.go, 24 6.3. |
| D-M2-46-12 |
2026-10-20 |
Audit of an end: one entry per committed end (D-M2-46-1); the key duration_ms for "duration" of 24 10.1 stays as D-M2-39-10 set it (closed_at minus opened_at, never negative), and close_detail appears only when set. |
D-M2-39-10 records the key and the system actor; this row adds only the one-entry rule across all paths. Place: app/sessions_end.go (recordEnd). |
| D-M2-46-13 |
2026-10-20 |
bothy.terminal.sessions.active counts the sessions the relay of each api process holds with at least one connection, in the state of the last committed terminal_session.changed (opening until the first one is known); the sum over the api processes counts sessions with a connection on some node; a session without any connection (host offline) is not counted. |
Only the relay knows what a process holds; counting rows would need a database read per scrape and would count each session once per node. Place: adapters/wsrelay/relay.go (countState), docs/observability/signals.md 4.3a. |
| D-M2-46-14 |
2026-10-20 |
The server module gains the test-only dependency github.com/flynn/noise at the version services/host/go.mod pins, for the device test client and the Fake-Host that perform the real handshake Noise_NK_25519_ChaChaPoly_SHA256. |
The host's internal/noise cannot be imported from another module; the license is already in the gate through M2-38. Place: services/server/go.mod, test/terminal. |
| D-M2-46-15 |
2026-10-20 |
OQ-04 keeps its default as built: no content recording of any kind; terminal_sessions holds metadata only with retention class security and is deleted by retention.security of M7-05. |
The owner question of 24 10.4 and 20 R-DP-07 (OQ-04 of the brief; this base carries no docs/OPEN-QUESTIONS.md) stays open with the default "no"; a recording would need an ADR. Place: internal/terminal/README.md, 24 10.4. |
| D-M2-46-16 |
2026-10-20 |
The subscribers are a module type app.Subscribers, built with the txn registry (before the module exists) and bound in the terminal step of internal/wire (Bind). Unbound, a subscriber does nothing without open sessions and fails the unit (internal error) only when it finds a session it would have to end. |
The registry is sealed before the modules are built and its golden file lists one registration list for every role; a process that never binds (migrate, setup) holds no sessions, and a revocation must never commit around a session it should end (fail closed, D-L23). Place: app/subscribers.go, internal/wire/txn.go. |
| D-M2-46-17 |
2026-10-20 |
In authz a session limit of zero (a hand-built configuration) means not configured and is never reached; the production binding requires at least 1 for terminal.max_sessions_per_person and terminal.max_sessions_per_host. |
Tests and callers that build authz by hand without the keys must not refuse every opening; the configuration keys carry their minimum. Place: internal/authz/domain/terminal.go (TerminalSessionLimit), internal/wire/configkeys_terminal.go. |
| D-M2-46-18 |
2026-10-20 |
The relay's deadlines use the existing scheduler port app.Timers (time.AfterFunc relative to the clock in production; tests use fakehost.ClockTimers on the fake clock) instead of a new port, one timer per session at the earliest of its deadlines, only on the node that held a side of it since its grant (with cluster.mode = multi every node expects the session, but only the node of the pairing sees the device bytes, so a new grant clears the mark; code review M2-46), and idle applies in every state that is not closed. |
M2-44 already schedules the open deadline through app.Timers; a second port would duplicate it. A session that never starts or stays detached must not escape the idle limit (R-TM-11). Place: adapters/wsrelay/relay.go (armLimit, limitDeadline), domain/deadlines.go, test/terminal/fakehost/clocktimers.go. |
| D-M2-46-19 |
2026-10-20 |
The end value is domain.End with ExitCode *int (the merged type of M2-43), HostSessionEnd stays the type of the host report (domain.HostEnd converts it); the one closing statement EndTerminalSession replaces CloseSession, CloseSessionReported and CloseTerminalSessionByPerson. |
Names follow the merged code (Boundaries of the brief); one statement is the only place that sets close_reason. Place: domain/end.go, queries/ending.sql. |
| D-M2-46-20 |
2026-10-20 |
When a session ends because its host left active (setHostState disabled or retired, issueHostPairingCode, a duplicate control connection), the device stream and the data connection close with 4100 and the reason, but the host service gets no session.close: the use case closes the host's control connection with 4111 or 4110 right after its commit, and the host service ends every PTY at that close (24 3.5). The terminal tests of M2-46 assert exactly this instead of a session.close for these rows. |
The close code of the control connection already tells the host service to end everything; a session.close per session before it would race the close and add nothing. Place: test/terminal/revocation_test.go. |
| D-M2-46-21 |
2026-10-20 |
The audit entry terminal.terminate is a system action without the client IP of the request that caused the end (no ip_hmac, no ip_key_id), so its details hold exactly the keys of 24 10.1; the correlation id of the request stays. |
A system action has no client (05 rule 1.10), like the folded expiries of authz (R-AZ-15); the request's IP belongs to the cause's own entry. Place: app/sessions_end.go (systemAudit). |
| D-M2-46-22 |
2026-10-20 |
Class fix of the progress write (red team M2-46: M1, M3 and a failed write followed by silence): one function of the relay (persist) decides every progress write, and a flush timer, not the arrival of the next frame, carries unwritten input to the store. It writes when terminal.idle_grace passed since the last attempt, at once when unwritten input is a full grace newer than the last input sent, and otherwise arms one flush timer at the last attempt plus the grace while unwritten input waits; a failed write gives its counts back and goes through the same function, so it is retried at most once per grace. Three rules hold in every state: no byte is lost or counted twice; once the store has worked for a full grace the stored last_input_at lags the true last input by less than the grace (D-M2-46-7), with or without further frames; the relay writes at most twice per grace, also during an outage. |
Every earlier finding of this area was a transition that depended on which event came next (an output-only write, a failure reset, no later frame); a seeded randomized model test over device input, host output, pauses and store outages (TestRelayProgress_Model) checks the three rules after every step and fails on each of the three known variants. Place: internal/terminal/adapters/wsrelay/relay.go (persist, flush, giveBack). |
| D-M2-46-23 |
2026-10-22 |
The metric catalogue (internal/platform/otel/metrics/catalogue.go) names the reasons access_revoked and user_disabled once, as the constants reasonAccessRevoked and reasonUserDisabled, used by the run reasons, bothy.agent.invocation.denied and the terminal end reasons. |
With the terminal end reasons of M2-46 each string appears three times in the catalogue, which goconst rejects; the values and lists are unchanged. |
| D-M2-46-24 |
2026-10-22 |
A limit end of the relay (limitDeadline) waits until no progress write of the session is in flight before it takes the remaining counts and calls RelayEnd; while it waits, the slot starts no new progress write, and a failed write gives its counts back for the end. Every progress write (the periodic and flush writes of persist and the write at a released pairing) goes through startWrite and wrote, which count it as pending on the slot. |
Under load the flush write at the grace and the idle end run back to back (one Advance of the test scheduler, or two timers close together); when the end committed first, the write found the session closed and its counts were lost for the row and for terminal.terminate (TestTMT11_ProgressPersistence failed under a parallel run). The end path of a request or subscriber (Counters inside the unit of work) does not wait, because the in-flight write would wait for the row lock of that unit; there the window stays one database round trip. |
| D-M2-46-25 |
2026-10-23 |
When RelayEnd of a limit end fails (the session stays open), the relay gives the taken counts back to the slot and clears the wait mark of D-M2-46-24, so the progress writes go on; the relay does not arm the limit again, terminal.sweep repeats the end. |
Otherwise the mark stayed set after a failed end and the slot never wrote progress again, also after a new grant, so the stored last_input_at stopped moving while the user typed (red team and code review of the LAND rebase, reproduced by TestRelayLimits_FailedEndKeepsProgress). The sweep already repeats every relay limit in the database. Place: adapters/wsrelay/relay.go (limitDeadline). |