M2-47: Terminal relay across several nodes (T6)¶
Status: ready | Depends on: M2-44 | Milestone: M2
Goal¶
With cluster.mode = multi several api nodes serve the terminal as one. The node that processes openTerminalSession sends the instruction session.open over NOTIFY ephemeral, only with IDs and only after its transaction committed, to whichever node holds the control connection of the host service; the edge routes both stream paths of a session (/api/v1/terminal/sessions/{session_id}/stream of the device and /api/v1/hosts/sessions/{session_id}/stream of the host service) by consistent hashing over the {session_id} segment to the same api node, where the relay of M2-44 pairs them in memory. A side that lands on another node because the edge lacks the hash rule finds no counterpart within terminal.open_timeout, closes with 4102, is counted in bothy.terminal.relay.unpaired and leaves /readyz ready: a misconfigured edge fails visibly instead of passing bytes through a broker (24 "Multiple nodes", Ruling T6, R-TM-09, R-OP-19, RK-29). The same signal path carries what else the specification asks of several nodes for the terminal: a host transition decided on any node closes the host's connections on every node at once (24 3.1, 3.3), the probe of an older control connection reaches the node that holds it (24 "Second control connection"), and every node knows whether a host is connected anywhere, so 409 host_unavailable behaves as with one node (24 6.1 point 2, 13). The end of a session needs no signal: M2-44's relay learns every committed end from the event-log tail of its own process on every node (24 "Revocation", 02 11.2), and this ticket proves that with two nodes. With cluster.mode = single nothing changes and no NOTIFY is sent. The ticket also ships the example edge with hash routing as a Compose overlay with two api nodes in infra/compose and updates 16 "Terminal: forwarding and host service" (4.4). This is TM-T15 at the server level with the fake host of M2-44; M2-48 runs it again end to end with the real host service in a container. The multi-node relay comes in M2, independent of the rest of the multi-node operation in M7-17 (P-25, OQ-07).
Read first¶
docs/PLAN.md: section 3 "Way of working per ticket"; section 5 (rows "Test doubles", "End to end", "Load, endurance, chaos"); section 8 "M2 Agent platform, extensions and terminal" (Goal, Scope, Out of scope, Exit criteria 8 and 10, Test strategy with "two api nodes with and without hash routing (TM-T15)", Red team (Terminal) with "misrouting across several nodes", Risks), the introduction of "Block terminal" and the rows M2-38 to M2-44 (what exists), M2-47 (this ticket, the contract), M2-45, M2-46 and M2-48 (who builds what next); row M7-17 (the rest of the multi-node operation); section 19 P-09, P-10, P-11, P-20, P-25.- Dependencies: the header names M2-44. Through it this ticket builds on M2-43, M2-42, M2-41, M2-40, M2-39, M2-38 and on the M1 tickets M1-07, M1-08, M1-10, M1-11, M1-12, M1-13, M1-14, M1-16, M1-17, M1-18, M1-23, M1-24, M1-28, M1-32, M1-33, M1-34, M1-35, M1-44, M1-48 and M1-49 (all implied, so not in the header). D-W-102 struck no dependency from the column of this ticket (it always named only M2-44); it struck M2-40 from the column of M2-44 and M2-39 from the column of M2-43; both lie in this ticket's closure and stay in this list. Because M2-43 depends on M1-67 (D-W-105), M1-64, M1-65, M1-66 and M1-67 with their own closure, among them M1-21 (trusted proxies), are in this ticket's closure as well. Not in the closure, read for coordination only: M1-04 (operations checks), M1-68 (metric registry,
signal-check), M1-70 (visibility lint), M1-73 (compose smoke) and M2-46 (revocation, limits, the other terminal metrics). docs/spec/24-terminal.md: 2.1 "Overview" (diagram with the edge and hash routing), 3.1 "States" (every transition out ofactiveends all sessions), 3.3 "Re-pairing" (closes the host's connections), 3.4 "Second control connection" (withcluster.mode = multioverNOTIFY ephemeralto the node of the older connection), 5.3 "Terminal session token" (the host service checks the token with the JWKS fromhello), 5.4 "Frames" (the relay sees only length and time), 6.1 "Opening" points 2 to 7, 6.2 "Detach and reattach", 6.3 "Limits", 8.3 "Revocation", 9 "Multiple nodes" (all), 10.1 to 10.4, 13 "Errors" (close codes4100to4114and their channels), 14 "Tests" (TM-T15; TM-T16 and TM-T23 for context), 16 (R-TM-09, R-TM-13, R-TM-16), 17 (terminal.open_timeout,terminal.idle_timeout,terminal.idle_grace,terminal.host_heartbeat_interval,terminal.host_heartbeat_miss_limit,terminal.host_duplicate_probe_timeout).docs/spec/16-operations.md: 4.1 "Basic rules" rule 1, 4.2 "Two listeners" (/readyz), 4.3 "Edge and identity" (server.trusted_proxies, profiledirect), 4.4 "Terminal: forwarding and host service" ("Edge and proxy" points 1 to 3), 15 R-OP-18 and R-OP-19.docs/spec/02-architecture.md: 4.3 (rowterminal), 4.4, 5 "Layers within a module" (platform only in adapters), 11.2 "Log tail per api process", 11.6 "Ephemeral events", 11.7 "Cost of serialization", 12.3 (rowterminal.sweep), 14.4 (rowLISTENconnection), 15.1, 15.2 "Health", 15.3 "Shutdown" (api step 4), 16 "Scaling path" (level 2 and "Multiple nodes in detail"), 19 R-AR-10, 22 (cluster.mode,database.listen_reconnect_backoff_cap).docs/spec/03-data-model.md: tableshostsandterminal_sessions; 6.6 "Notifying the gateway processes" (the channelephemeralcarries only IDs and versions, among them host and terminal session, and is off with one node).docs/spec/04-sync-protocol.md: 6.4 (last paragraph: terminal channels have their own codes), 12 point 3, R-SY-25.docs/spec/05-actions.md: 4.14 "Hosts and terminal" (host.connect,terminal.open,terminal.attach,terminal.stream,terminal.close,terminal.terminate), 6 (terminal_session.changed,host.updated,host.presence_changed).docs/spec/07-permissions.md2.12 "Hosts and theterminalcapability", SR10, SR11, SR12;docs/spec/08-identity.md6.3 "Fresh step-up for the terminal" and ID-T28;docs/spec/19-security.mdSEC-TERM-1, SEC-TERM-4, SEC-TERM-5, SEC-LOG-3, SEC-LOG-5;docs/spec/17-observability.md2 "Basic rules".docs/api/asyncapi.yaml: channelsterminalStream,hostControlandhostDatawith theirx-close-codes; messageshost.hello,host.heartbeat,host.report,host.session.open,host.session.close,host.session.started,host.session.ended.docs/api/openapi.yaml: operationsopenTerminalSession,attachTerminalSession,closeTerminalSession,setHostState,issueHostPairingCode; schemasHostSessionOpen,HostSessionClose,HostSessionEnded,TerminalCloseReason,TerminalCloseDetail,Problem(x-problem-codeshost_unavailable,terminal_denied,step_up_required).docs/observability/signals.md2 "Cardinality", 4.3a "Terminal" (bothy.terminal.relay.unpairedwithbothy.terminal.side), 5.3 "Attributes in spans and logs", 7 "Logs";infra/otel/collector.yaml(redaction/allowlist.allowed_keys).docs/operations/configuration.md: 4 (the keys above,cluster.mode,database.listen_reconnect_backoff_cap,database.statement_timeout,sync.heartbeat_interval,secrets.signing_key_dir,server.trusted_proxies), 9 "Compose variables", 10 "Maintenance" (a key is never reinterpreted).infra/compose/compose.yaml,infra/compose/.env.example,infra/compose/README.md,docs/operations/install-compose.md5 "Edge".- ADR 0066 (writable key store), ADR 0067 (JOSE profile), ADR 0068 (section "Confirmation").
docs/DECISIONS.md: D-T6, D-T13, D-N06, D-L23, D-OP-03, D-W-100, D-W-101, D-W-102; section 10 for the format of new rows.docs/security/threat-model.md5.3 "Network edge and proxy", 5.16 "Terminal", 6.7 (T-TM-01, T-TM-02, T-TM-05, T-TM-11), 9 "Maintenance rule".docs/RISKS.mdRK-29, RK-37.docs/OPEN-QUESTIONS.mdOQ-07 (multi-node in M7, the terminal relay with this ticket) and OQ-24 (terminal only over a private network, default no); OQ-03 and OQ-04 are not touched here.- Briefs:
docs/briefs/M1-11-lifecycle-health.md(lifecycle.Component,lifecycle.StopStep,lifecycle.PhaseEventTail,Runner.Fail),M1-12-postgres-migrations.md(pgtest, testcontainers-go with Ryuk off and loopback ports),M1-13-unit-of-work.md(txn.UnitOfWork.Do, phasework,pg.Txfrom the context),M1-16-event-log.md(pg.DialDedicated, the tail'sLISTENloop as the pattern),M1-07-config-schema-loader.mdandM1-08-config-reference.md(keys enter the schema with their first consumer,configkeys_*.go, chapter constants),M1-10-telemetry-foundation.md(internal/platform/otel,otel/attr,TestAttrKeysEqualCollectorAllowlist),M1-23-server-image.md,M1-32-signing-keys.md(key store, purposeterminal, JWKS), and the briefs of M2-38 to M2-44 where they exist indocs/briefs/; otherwise their plan rows. - Code on the base branch, read with
git showbefore changing anything:internal/terminal(port,domain,app,adapters/postgres,adapters/httpapi,adapters/wsrelay),test/terminal/fakehost(M2-44) and the device client of M2-44's relay tests,internal/platform/pgandpgtest,internal/platform/lifecycle,internal/platform/otel,internal/kernel/ids,internal/kernel/clock,internal/wire,architecture/modules.yaml.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - Limits, windows and thresholds only through named configuration keys listed in
docs/operations/configuration.md; code comments and docs name the key, never the number. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/api/in the same change. The core contract (docs/spec/00-glossary.md,03-data-model.md,04-sync-protocol.md,05-actions.md) is not edited by this ticket.
Product requirements implemented here:
- With
cluster.mode = multithe node that processesterminal.opensends the instructionsession.openoverNOTIFY ephemeral, only with IDs, to the node of the control connection; the receiving node reads everything else from the database (24 9 point 1, 03 6.6, 02 11.6, R-AR-10, R-SY-25, D-T6). - The host service opens one data connection per session on
/api/v1/hosts/sessions/{session_id}/stream, the device connects to/api/v1/terminal/sessions/{session_id}/stream, and the edge routes both by consistent hashing over the{session_id}segment to the same api node, where the relay connects the two sides in memory; no broker, and no byte of a stream ever goes through the database, the event log orNOTIFY(24 9 points 2 and 3, 16 4.4 "Edge and proxy" point 2, R-TM-09, R-OP-19, 24 10.2). - A side that lands on another node finds no counterpart within
terminal.open_timeoutand closes with4102; the node counts this inbothy.terminal.relay.unpaired(M2-44's counter, D-W-105) and keeps/readyzready (24 9 point 4, signals.md 4.3a, TM-T15, PLAN row M2-47). - The channel
ephemeralis active only withcluster.mode = multi; with one node nothing of this ticket runs (03 6.6, 02 11.6, R-AR-10). - With
cluster.mode = multithe probe of an older control connection reaches the node of that connection overNOTIFY ephemeral(24 3.4), and a transition of a host decided on any node ends the affected connections on every node at once, fail closed (24 3.1, 3.3, D-L23); the end of a session reaches every node through the event-log tail of M2-44 (its Design note 11; 24 8.3, 02 11.2), which this ticket proves with two nodes and does not duplicate with a signal. - The edge forwards WebSocket upgrades for
/api/v1/terminal/sessions/{session_id}/stream,/api/v1/hosts/connectand/api/v1/hosts/sessions/{session_id}/streamwith an idle timeout that never cuts a quiet terminal, does not buffer these connections and writes no content to logs (16 4.4 "Edge and proxy" points 1 to 3); published ports bind127.0.0.1of the host only (16 4.1 rule 1). - Telemetry only as the catalogue allows: this ticket registers no instrument and no attribute; it asserts M2-44's
bothy.terminal.relay.unpaired(D-W-105) with the closed attribute setbothy.terminal.side(client,host) and uses that attribute, which M2-44 registers, in its log records; no IDs of people, no host names, no content (signals.md 2 and 4.3a, 24 10.4, SEC-TERM-1, SEC-LOG-3, SEC-LOG-5). - Every signal carries only a kind and IDs: never a session token,
jti, profile, name, path, address or byte of a stream (03 6.6, 02 11.6, SEC-TERM-1).
Terminal security requirements (D-W-101):
- Rights per machine: the additional right
terminalon the targethost, SR10, SR11 with I6 and SR12 (07 "Hosts and theterminalcapability"). M2-40 builds them as decisions ofauthorizeand M2-44 applies them atterminal.openandterminal.attach(AZ-M54 to AZ-M61, TM-T08, TM-T09, TM-T28 (a)); this ticket adds no right, action or decision. What it enforces: a decision stays bound to the node and the transaction that made it. A signal carries only IDs and causes nothing the committed rows do not allow; the control node sendssession.openonly for a committed session inopeningof anactivehost, so a rolled-back or forged signal opens nothing (TestCrossNode_SessionOpenOnlyAfterCommit,TestCrossNode_ForgedSignalOpensNothing), and an end of a session decided on any node closes its connections on every node (TestCrossNode_CloseReachesRelayAndControl). - Fresh step-up: device key in the apps, only passkey in the console (08 6.3, ID-T28). M2-40 builds step-up
freshwith its consumption in the opening transaction (ID-T28 without the console part), M2-44 TM-T07 without the console part and TM-T12, M6-18 the console with passkey only, M4-36 and M5-12 the step-up key in the apps. What this ticket enforces: the consumption lives in the database, not in a node; two opens with one confirmation on two nodes give exactly one session and one401 step_up_required(TestCrossNode_StepUpConsumedOnce). - Network: there is no public inbound path to the terminal; the host service opens only outbound connections (R-TM-01); an installation with the profile
tailscalereaches the server only over the tailnet with a policy only for port 443 and without Funnel (D-OP-03,docs/operations/tailscale.md); the product does not require Tailscale; whether the terminal must be reachable only over a private network is owner question OQ-24 (default no). This ticket adds no inbound path: the example edge publishes only on127.0.0.1of the host behind the operator's TLS edge, the api nodes publish no port, and the host service still reaches the server only outbound through the same edge (TestComposeMultiNodeOverlay); the overlay works only with the profiledirect, becausetailscale servehas no hash rule, and with the profiletailscaleit adds nothing (TestComposeMultiNodeOverlay, casetailscale). On an answer "yes" to OQ-24, M2-43 and M2-44 change and the overlay follows them. - Audit without content: M2-46 owns audit and telemetry without content (TM-T06), M2-44 the relay that records only length, time and session ID. What this ticket enforces: signals carry only IDs (
TestCrossNode_SignalsCarryOnlyIDs,TestSignalCodec); the end of an unpaired session writes exactly oneterminal.terminateentry with codes only, also when two nodes time out (TestTMT15_WithoutHashRouting); the example edge writes no access log and no request line (TestEdgeExample_Config); M2-44's metric has only the attributebothy.terminal.side(TestRelayUnpairedAttributeSet). - Idle timeout and maximum duration (24 "Limits"): M2-45 measures idle on the host, M2-46 the server side and the job
terminal.sweep(TM-T11). What this ticket enforces: an end committed on any node, also in a worker transaction, closes the relay connections on the node that holds them through M2-44's event-log tail, which this ticket proves with two nodes (TestCrossNode_CloseReachesRelayAndControl, subtestworker_end); and the example edge never cuts a session before the server would end it, because its timeout on the stream paths isterminal.idle_timeoutplusterminal.idle_grace(TestEdgeExample_Config). - Service user and session users without administrator rights (T12, HD rules): M2-42 builds
installanddoctorwith HD1 to HD11 (TM-T13, TM-T22, TM-T24), M2-45 runs every PTY in the helper of the session user. Not touched here; the example edge runs as a non-root user, read-only, without capabilities and withno-new-privileges, like the server containers (infra/compose/README.md"Security rules in this file",TestComposeMultiNodeOverlay,TestEdgeExample_Config). - Threats of threat model 6.7 that concern this ticket, each with its test: T-TM-01 (content readable in the server) by a relay that pairs both sides on one node and never moves a byte across nodes, with signals of IDs only (TM-T15 tests,
TestCrossNode_SignalsCarryOnlyIDs; TM-T06 by M2-46); T-TM-02 (replay and token swap at the relay) by a misrouted side that never receives a byte of the other side (TestTMT15_WithoutHashRouting; TM-T03 to TM-T05 by M2-45); T-TM-05 (copied keys, a second control connection) by the probe across nodes and the probation of a new connection (TestCrossNode_DuplicateControlConnection; TM-T23 on one node by M2-43); T-TM-11 (session stays after revocation) by ends that reach every node through the event-log tail, also while the listener of this ticket is lost, and the reconciliation of host transitions after a lost listener (TestCrossNode_CloseReachesRelayAndControl,TestCrossNode_ListenerLossReconciles; TM-T10 by M2-46 and M2-48); and the new row T-TM-15 (misrouting and lost signals across nodes, design note 15) by TM-T15. Not touched here: T-TM-03 and T-TM-09 (M2-40, M2-44), T-TM-04, T-TM-12, T-TM-13 and T-TM-14 (M2-42, M2-45), T-TM-06 (M2-41), T-TM-07 (M6-18), T-TM-08 (M2-40, M2-42, M2-45), T-TM-10 (M4-35).
Scope¶
All paths under services/server/ unless noted. Names of M2-38 to M2-44 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).
Create:
internal/terminal/domain/cluster.go(pure, standard library andinternal/kernelonly):type SignalKind stringwithSignalSessionOpen = "terminal.session_open",SignalHostClose = "terminal.host_close",SignalHostPresent = "terminal.host_present",SignalHostGone = "terminal.host_gone",SignalSessionPaired = "terminal.session_paired";type Signal struct { Kind SignalKind; HostID, SessionID, ConnID ids.ID };EncodeSignal(s Signal) ([]byte, error)andDecodeSignal(payload []byte) (s Signal, ours bool, err error)(design note 3);OlderConnection(a, b ids.ID) bool(design note 7);type PresenceViewwithSeen(host, conn ids.ID, at time.Time),Gone(host, conn ids.ID)andConnected(host ids.ID, now time.Time, window time.Duration) bool(design note 6);ProbationEnd(firstPresent time.Time, probe time.Duration) time.Time(design note 7);type Side stringwithSideClient = "client",SideHost = "host",LoneSideOutcome(side Side, state string) LoneOutcomeandLoneDeadline(identifiedAt time.Time, openTimeout time.Duration) time.Time(design note 8).internal/terminal/app/signals.go: outgoing portSignalsand inbound handler interfaceSignalHandler(design note 4) andLocalSignals, the implementation forcluster.mode = single.internal/terminal/adapters/postgres/queries/cluster.sql(sqlc):NotifyEphemeral(SELECT pg_notify('ephemeral', sqlc.arg(payload)::text)) andHostStatesByIDs(state anddisabled_reasonby ID list, ordered byid) unless M2-39 or M2-44 already have an equal read.internal/terminal/adapters/postgres/signals.go:NotifySignals, the implementation ofSignalsforcluster.mode = multi;internal/terminal/adapters/postgres/ephemeral_listener.go:EphemeralListener(alifecycle.Componentnamedterminal_ephemeral, design note 5).internal/terminal/adapters/wsrelay/cluster.go: the hub'sHandleSignal, the presence view, the probe across nodes, the probation and the reconciliation (design notes 4 to 9).internal/wire/configkeys_cluster.go:clusterConfigKeys()withcluster.mode(enumsingle,multi, default from 02 22,chapter02), only if the base branch does not register the key yet.test/terminal/multinode/:main_test.go(pgtest.Main),harness_test.go(two in-process nodes, design note 13),testedge_test.go(the in-process test edge) and the integration tests of the section "Tests".test/edge/:main_test.go,edge_example_test.go,compose_overlay_test.go(design notes 11 and 12).- Repository root:
infra/compose/compose.multi-node.yamlandinfra/compose/edge/nginx.conf(design notes 11 and 12).
Change:
internal/terminal/app: every use case of M2-39 to M2-44 that acts on a live connection after its commit other than through the event-log tail (open, attach, host state changes, re-pairing, the duplicate verdict) stages its signal in the unit and hands it over after the commit throughSignalsinstead of calling the relay directly (M2-44'sRelayControl.ExpectandControlLinks.OpenSessionafter open and attach move into the handler ofterminal.session_open, design note 3); the check "host connected" of open and attach asks the presence port of design note 6. Ends of sessions (close by the person or an owner, the unpaired end, the end withhost_restartedafteridentify, the storing ofsession.ended, and the ends of M2-46 andterminal.sweep) keep M2-44's path through the event-log tail and stage no signal (design note 3).internal/terminal/adapters/wsrelay: a connection ID (UUIDv7 from anids.Generatoron the node's clock) per accepted control connection; the presence view; the rules of the lone side (design note 8) where M2-44 differs;HandleSignal.internal/wire: chooseLocalSignalsorNotifySignalsbycluster.mode; in rolesapiandallwithmultistartEphemeralListenerafter the HTTP listeners and register its stop step inlifecycle.PhaseEventTail; roleworkergetsNotifySignalswithout listener; registercluster.modeif missing; regenerateinternal/wire/testdata/txn_registrations.goldenonly if it changes.docs/api/asyncapi.yaml:4102terminal_host_unavailableinchannels.hostData.x-close-codes; theclient_actionof4102interminalStreamafter design note 8; thenmake api-lintandmake api-checks.docs/spec/24-terminal.md,docs/spec/16-operations.md,docs/spec/02-architecture.md,docs/security/threat-model.md,docs/adr/0068-terminal-through-a-host-service-with-noise-stream-and-invariant-i6.md("Confirmation"),docs/operations/install-compose.md(new 5.3),docs/operations/configuration.md(section 9, one row) as in design note 15;make adr-index.- Repository root:
infra/compose/README.md(files, overlay, checks),infra/compose/.env.example(BOTHY_NGINX_VERSION). internal/terminal/README.md,services/server/CHANGELOG.mdand the rootCHANGELOG.md(overlay of the Compose bundle) under "Unreleased",docs/DECISIONS.mdsection 10 rowsD-M2-47-1toD-M2-47-11.tools/lint/sql_visibility.yaml: only if it exists (M1-70 merged), the reads of design notes 4 and 9 with the reason "Relay reads the state of its own connections".
This ticket creates no migration and no new top-level make target.
Out of scope¶
- The relay on one node, session tokens,
openTerminalSession,attachTerminalSession,listTerminalSessions,getTerminalSession,closeTerminalSession,terminal_session.changed, the close path of every session end through the event-log tail, the counterbothy.terminal.relay.unpaired(D-W-105) with its attributebothy.terminal.side, the fake host and the single-node cases TM-T07 and TM-T12 (M2-44); the control connection,hello,identify,report, heartbeats, close codes4110to4114, the local probe of TM-T23 (M2-43). - Handshake checks, PTY, profiles and replay rejection on the host (M2-45); revocation subscribers, limits,
rl.terminalandrl.host,terminal.sweep, the metricsbothy.terminal.sessions.active,bothy.terminal.sessions.closedandbothy.terminal.hosts.connected, TM-T06, TM-T10, TM-T11 (M2-46). Their ends reach the connections through M2-44's event-log tail like every end; this ticket builds nothing for them. - The conformance and chaos suite with the real host service in a container, TM-T15 end to end through the example edge, TM-T16 (M2-48).
NOTIFY ephemeralfor gateway,runsandagents(typing.started,run.updated,agent.presence_changed,4017across nodes), fencing signals of agent consumers,host.presence_changedto clients on another node, a shared key store for every deployment target, rate limits across nodes,perf.fanout_cross_node_p99(M7-17). Rate limits stay per node (02 16).- Starting the overlay (
docker compose up), its support ininstall.sh(M7-29), ingress examples for Helm, other proxies (the overlay is one example; the rule stands in 16 4.4). - A terminal reachable only over a private network (OQ-24); content recording (OQ-04).
- Changes to the core contract.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M2-47-terminal-multi-node; one writer per worktree. - Conventional Commits with the module as scope (for example
feat(terminal): ...,docs(compose): ...) 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, removed at the end. - 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-44: their merged names win over the expected names here; list each difference. Where the merged relay of M2-44 differs from design note 8 in a point that 24 9 and 13 decide (the close code of a lone side, the timer of the host side), change it here and list it; any other difference of M2-38 to M2-44 from the specification: stop with
Verdict: BLOCKED <reason>. - Containers only from pinned images: PostgreSQL through
pgtestand the pinned nginx image of the overlay for the edge tests, with Ryuk disabled, ports published only to127.0.0.1with random host ports, labelorg.bothy.test=edge, removed at the end. Pulling these images counts as a download of a pinned tool.docker composeonly with the subcommandconfig, neverup,runorpull. - No real host service, no real device, no third-party service; nothing in this ticket costs money.
- Time only through
kernel/clock(Now) and the timer source of M2-44's relay; no test sleeps for the default value of a window (design note 13). - Session tokens,
jtivalues, keys and stream bytes never appear in logs, signals, audit details, problem bodies or test failure messages (print a hash prefix if needed).
Design notes¶
-
Scope of the ticket (Ruling for docs/DECISIONS.md,
D-M2-47-1). The plan row namessession.openoverNOTIFY ephemeraland the hash rule of the edge. With several nodes the specification needs the same path in three more places, and no other ticket builds them (M7-17 covers gateway,runsandagentsonly): the probe of an older control connection "throughNOTIFY ephemeralto the node of the older connection" (24 3.4); the closing of a host's connections at a transition or re-pairing (24 3.1, 3.3); and the check "host connected" behind409 host_unavailable(24 6.1 point 2, 13). They belong here, because without themcluster.mode = multiwould keep the control connection of a disabled or re-paired host open on another node, hide a copied key and answer409 host_unavailableby chance of the node. The immediate end of sessions with4100and the instruction to the host service (24 8.3) needs no signal: every end writesterminal_session.changedin its transaction, and the event-log tail of every api node hands it to M2-44's relay observer (M2-44 Design note 11; 02 11.2, 16), so this ticket only proves it with two nodes. The code stays node-neutral (OQ-07); withcluster.mode = singlenone of it runs. -
Mode and roles (Ruling for docs/DECISIONS.md,
D-M2-47-2).cluster.modeis the enumsingleormultiof 02 22 (default from that table, place Server). Register it ininternal/wire/configkeys_cluster.gowithchapter02if the base branch lacks it (keys enter with their first consumer, M1-07); thenmake config-doc-check.single:LocalSignals, no listener, noNOTIFY, no presence view, no probation; the behaviour and the tests of M2-43 and M2-44 stay unchanged.multi, rolesapiandall:NotifySignalsand oneEphemeralListenerper process.multi, roleworker:NotifySignalsfor a unit of the Scope it commits, no listener, because it holds no connection; the ends it commits (terminal.sweep, expiries) need no signal (design note 3).
-
Signals and payload (Ruling for docs/DECISIONS.md,
D-M2-47-3). Channelephemeral(03 6.6). The payload is compact JSON with exactly the fields of its kind:kind,host_id, and per kindsession_idorconn_id, each a UUID string. No other field, nevernull. Kinds, who sends them and what the receivers do:terminal.session_open(host_id,session_id): staged by every unit after which M2-44 sendssession.open(the open; the attach too, if M2-44 sends one there). Receivers: every api node loads the session through the app and, for a session of that host that is notclosed, registers the expectation locally through M2-44'sRelayControl.Expect(M2-44 Design note 7, with the expecteddevice_idof the row and the node's receipt time of the signal as the grant time of its deadline), which closes an older device side of the session held on that node with4103and an older unpaired host side with1000; so the node the edge hashes the session to holds the expectation, whichever node served the REST call (M2-44 Design note 9 closes a host data connection without an expectation with1000). The deadline of such an expectation decides by the session state read from the database (design note 8), not by a localstartedmark, becausesession.startedarrives only on the node of the control connection; an expectation without sides therefore ends only a session still inopening, through the compare-and-swap of design note 10. Ruling for docs/DECISIONS.md: on a host whose lastreportsaysrequire_local_confirm = true(hosts.reported_require_local_confirm), an expectation without sides that receivedterminal.session_pairedfor the session after its grant time only removes itself at its deadline; without that signal it ends the opening at its deadline like every other expectation (open_failedwithopen_timeout, compare-and-swap of design note 10). Reason: a pairing on another node meets the opening deadline there, and the host service then bounds start and consent itself and ends a refused or unanswered opening withsession.ended(M2-44 Design note 7, M2-45 Design note 15 d); the server does not know the host'shost_service.local_confirm_timeout, so only the fact of the pairing may spare the opening, and an opening that no node ever paired still ends afterterminal.open_timeout(24 6.1 step 6). A node that holds a side keeps M2-44's rule. Only a node that holds an eligible control connection of the host (one that sent its firstreport, design note 6) additionally sendssession.open, only for a session inopeningof anactivehost, with M2-44's builder ofHostSessionOpen(session_id,data_path,profile,expires_at); it drops everything else. A connection in probation waits with thesession.open, not with the expectation (design note 7). Withcluster.mode = single,LocalSignals.Committedhands the signal to the same handler on the one node, which does both, so M2-44's order (RelayControl.Expect, thenControlLinks.OpenSession) stays.terminal.host_close(host_id): staged by every transition out ofactive(setHostState), by a re-pairing (issueHostPairingCodeon a paired host) and by the duplicate verdict of design note 7. Receivers: every node closes the host's control and data connections with the code M2-43's single-node path uses for the host's state read after the signal (expected:4110after a re-pairing,4112fordisabled_reason = duplicate_connection,4111fordisabledby an owner and forretired).terminal.host_present(host_id,conn_id) andterminal.host_gone(host_id,conn_id): sent outside any unit by the node of a control connection (design notes 6 and 7).terminal.session_paired(host_id,session_id): sent outside any unit by the node whose relay pairs both sides of a session inopeningon a host withrequire_local_confirm = true; every other node marks its side-less expectation of that session as paired (only IDs, R-SY-25).
The end of a session has no kind. Every end, from any module, process or node (close by the person or an owner, the unpaired end, host transitions, re-pairing,
host_restarted, a storedsession.ended, the ends of M2-46 andterminal.sweep), writesterminal_session.changedin its transaction; the event-log tail of every api process reads the committed rows ofevent_log(woken byNOTIFY event_log, which is on in every mode, 03 6.6) and hands each closed session to M2-44's relay observer, which closes the device stream and the host data connection held there and callsControlLinks.CloseSessionon the node of the control connection (M2-44 Design note 11, 02 11.2). Reason: that path already reaches every api process in both modes, also for an end committed in a worker; a second path throughNOTIFY ephemeralwould act twice and would not reach the api process from a worker withcluster.mode = single.Decoding is strict: unknown fields, missing or extra IDs for the kind, a non-UUID value or an unknown kind starting with
terminal.drop the payload with one log record (design note 14); a kind outside the prefixterminal.is ignored without a log, because M7-17 adds its own kinds to the same channel. The longest encoded signal stays below the payload limit of PostgreSQLNOTIFY; the limit is a protocol constant in code with its source, not a configuration key. The kinds are internal signals of the module, not event types of 05: no entry indocs/spec/actions.yaml, no message inasyncapi.yaml, nothing in the event log. Ruling: the receiver always reads state from the database, so signals that arrive late, twice or out of order change nothing wrong. -
Port
Signalsand the handler (Ruling for docs/DECISIONS.md,D-M2-47-4). Ininternal/terminal/app:type Signals interface { Stage(ctx context.Context, s domain.Signal) error; Committed(ctx context.Context, s domain.Signal); Send(ctx context.Context, s domain.Signal) error }.Stageruns inside the unit of work in phasework;Committedruns afterDoreturned nil;Sendruns outside any unit.type SignalHandler interface { HandleSignal(ctx context.Context, s domain.Signal) }, implemented by thewsrelayhub. It never blocks the caller: frames go to the writer of the connection, and every database read uses a context withdatabase.statement_timeout.LocalSignals(single):Stagedoes nothing,Committedcalls the local handler (forterminal.session_openit registers the expectation and sendssession.open, design note 3),Senddoes nothing.NotifySignals(multi):StageexecutesNotifyEphemeralon the unit's transaction, so PostgreSQL delivers the notification only after the commit and never after a rollback;Committeddoes nothing, because every node, the sender included, receives the notification through its listener;SendexecutesNotifyEphemeralthrough the pool in its own statement.- Every use case of the Scope ("Change") calls
Stagein its unit andCommittedafter it; no use case calls the relay directly after a commit any more. One handler serves both modes, so the single-node behaviour is the multi-node behaviour with a local transport. Reason: a local shortcut on the sending node would act twice (locally and again through its own listener) or need a second code path.
-
Listener (Ruling for docs/DECISIONS.md,
D-M2-47-5).EphemeralListeneropens its own connection withpg.DialDedicated(ctx, "bothy-terminal-ephemeral"), executesLISTEN ephemeral, reads notifications in order on one goroutine, decodes them (design note 3) and hands each one toSignalHandler. On the loss of the connection it logsterminal ephemeral listener lostonce, tries again at once and then everydatabase.listen_reconnect_backoff_cap(the largest interval 02 22 allows for aLISTENreconnect; no new key), runs the reconciliation of design note 9 afterLISTENsucceeded again and logsterminal ephemeral listener restored. The listener is a soft dependency like the tail'sLISTEN(02 14.4): no readiness check,/readyzunchanged. Stop stepterminal_ephemeralinlifecycle.PhaseEventTail(02 15.3 api step 4, "Stop the log tail andLISTEN"); an unexpected end of its goroutine (a bug, not a lost connection) goes tolifecycle.Runner.Fail. One terminal listener per process; M7-17 may merge it with its own listener of the same channel, which the namespaced kinds allow; say so in theterminalREADME. -
Presence across nodes (Ruling for docs/DECISIONS.md,
D-M2-47-6). "Host connected" (24 6.1 point 2, 13) needs to know whether any node holds an eligible control connection; 03 has no column for it, so the view comes from signals.- The node of a control connection sends
terminal.host_presentafter the connection's firstreportand again after eachheartbeatit receives from the host service, andterminal.host_gonewhen the connection closes. - Each node keeps a
domain.PresenceViewof remote entries in memory. A remote entry counts while no more thanterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limithas passed since its lastterminal.host_present(the silence after which the holder closes the control connection itself, M2-43);terminal.host_goneremoves only its ownconn_id. - With
multi, "host connected" foropenTerminalSessionandattachTerminalSessionis an eligible local control connection or a counting remote entry; otherwise409 host_unavailable, as on one node. - A remote entry of a node that died without
terminal.host_gonecounts at most for the window above; an open it lets pass endsopen_failedwithclose_detail = open_timeoutafterterminal.open_timeout. After a start or a listener reconnect the view is complete within oneterminal.host_heartbeat_interval; until then an open may get409 host_unavailablefor a host connected elsewhere, which the next attempt corrects.
- The node of a control connection sends
-
Second control connection across nodes (24 3.4; Ruling for docs/DECISIONS.md,
D-M2-47-7). Each node gives every accepted control connection aconn_idfrom anids.Generatoron its clock (UUIDv7, ordered by the accept time of its node,kernel/ids). a. A node ignoresterminal.host_presentof its own connections; a second connection of a host on the same node stays M2-43's local case. b. A node that holds a control connectiondof the same host withOlderConnection(d, c)for an announcedcstarts one probe ofdwith M2-43's probe (WebSocket ping,terminal.host_duplicate_probe_timeout); at most one probe per connection at a time. No answer: closedwith4009as M2-43 does and sendterminal.host_gone. Answer: run M2-43's duplicate verdict in one unit (hostdisabledwithdisabled_reason = duplicate_connection, sessions endhost_disabled, audithost.connectwith codeduplicate_connection,host.updatedto all owners), which stagesterminal.host_close(the sessions' ends reach the relays through the event-log tail, design note 3); every node then closes the control connections of the host with4112. c. A node that holds a newer connectiondsendsterminal.host_presentfordat once, so that the node of the oldercprobes it after rule b. The periodicterminal.host_presentof design note 6 repeats this after a lost notification. d. Probation: withmultia control connection receives nosession.openbeforeProbationEnd(twiceterminal.host_duplicate_probe_timeoutafter its firstterminal.host_present: the other node's probe window plus the same again for notification and commit). Aterminal.session_openfor its host during probation waits and is checked again at the end against the database (session stillopening, host stillactive, connection still open); the session's ownterminal.open_timeoutkeeps running. Reason: otherwise a host service with copied keys on another node could getsession.openand finish the handshake before the probe decides (T-TM-05). e. The order of two connections on two nodes rests on the clocks of the nodes; servers run with synchronized clocks, and only overlapping connections of one host are compared. Write this into 24 3.4 and the README. -
The lone side (24 9 point 4, 6.1 point 6, 13; Ruling for docs/DECISIONS.md,
D-M2-47-8). The same rules on one node and on several; this ticket makes them exact, and the counts go to M2-44's counterbothy.terminal.relay.unpaired(D-W-105). a. A device stream connection whoseidentifywas accepted and that has no host data connection of its session on this node withinterminal.open_timeout: close it with4102, countbothy.terminal.relay.unpairedwithbothy.terminal.side = client. b. A host data connection whoseidentifywas accepted, for a session inopening, with no device stream on this node withinterminal.open_timeout: close it with4102(new onhostData, design note 15), count it withhost. c. A session still inopeningthen endsopen_failedwithclose_detail = open_timeoutthrough M2-44's end function (auditterminal.terminate,terminal_session.changed, which the event-log tail brings to every node). When both sides time out on two nodes, the second end is a compare-and-swap that changes nothing: one audit entry, one event. A device that attaches to adetachedsession and finds no host data connection on its node gets4102, and the session staysdetached(13: "attach later"). d. A host data connection of adetachedsession waits for an attaching device until the session ends (terminal.detach_grace); it is not unpaired. A connection that was paired once never counts as unpaired. e. The timer starts when the side'sidentifyis accepted and fires atLoneDeadline, the acceptance time plusterminal.open_timeout, inclusive. Frames of a lone side never leave the node: not into the database, the event log, a signal or another node. No readiness check is involved;/readyzstays200. -
Reconciliation after a listener reconnect. After
LISTENsucceeded again (never before, so nothing committed in between is missed), the node reads withHostStatesByIDsthe hosts of all its control and data connections, then closes what ended while it was deaf: hosts no longeractivewith the codes of design note 3; it sendsterminal.host_presentfor each of its eligible control connections at once. Sessions that ended meanwhile need nothing here: the event-log tail has its ownLISTENand poll and reads on fromlocal_head(02 11.2), so M2-44's observer closes their connections whether this listener is up or not. Part ofD-M2-47-5. -
Locks, order and idempotency. Signals take no row lock; a staged
NotifyEphemeraladds the commit-timeNOTIFYlock of 02 11.7 only withmulti(03 6.6 accepts that cost). Every end of a session goes through M2-44's compare-and-swap; the loser writes nothing. Closing an already closed connection, a secondsession.closefor an ended session and aterminal.host_presentfor an unknown host are no-ops. Notifications of one sender arrive in commit order, those of several senders in any order; the handler's database reads make the order irrelevant. -
Example edge (
infra/compose/edge/nginx.conf; Ruling for docs/DECISIONS.md,D-M2-47-9). nginx (BSD-2-Clause) in the imagenginxinc/nginx-unprivileged, which runs without root; pin the newest stable release line available at build time as the full tag<version>-alpineinBOTHY_NGINX_VERSIONand record version, digest and license in the report. The file is a completenginx.confwith:pidand all temp paths under/tmp(read-only root file system),listen 8080,access_log offanderror_log /dev/stderr crit, because access and error lines carry request lines and client addresses (16 4.4 point 3, SEC-LOG-3, SEC-LOG-5);client_max_body_size 0, because the server enforces its own named limits.map $uri $bothy_terminal_sessionwith the single regular expression^/api/v1/(?:terminal|hosts)/sessions/(?<sid>[^/]+)/stream$to the captured session ID, andmap $http_upgrade $connection_upgrade(upgrade, empty toclose).upstream bothy_terminal_streamwithhash $bothy_terminal_session consistent;and the serversapi:8080andapi-2:8080;upstream bothy_apiwith the same servers and the default balancing, for REST,/api/v1/gatewayand/api/v1/hosts/connect(24 9 point 1: the control connection may land anywhere).- One
locationfor the stream regular expression tobothy_terminal_streamwithproxy_buffering offandproxy_request_buffering off, andlocation /tobothy_api; both withproxy_http_version 1.1,Upgrade $http_upgrade,Connection $connection_upgrade,Host $http_hostandX-Forwarded-For $proxy_add_x_forwarded_for(the outer edge'sX-Forwarded-Protopasses unchanged). - Timeouts (16 4.4 point 1, refined in design note 15): on the stream location
proxy_read_timeoutandproxy_send_timeoutequal to the defaults ofterminal.idle_timeoutplusterminal.idle_grace, the longest silence of a session the server keeps (24 6.3); onlocation /equal to the defaults ofterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limit, which is also abovesync.heartbeat_intervalof the gateway. A comment above each value names its keys;TestEdgeExample_Configkeeps the numbers equal to the defaults indocs/operations/configuration.md, the only file besides the configuration that holds them.
Reason for nginx: consistent hashing over a value taken from the path is a single documented directive there; the real image in the test proves the routing.
-
Compose overlay (
infra/compose/compose.multi-node.yaml; Ruling for docs/DECISIONS.md,D-M2-47-10). Used asdocker compose -f compose.yaml -f compose.multi-node.yamlwith the profiledirectonly.api:ports: !reset []andBOTHY_CLUSTER__MODE: multi.api-2:extendsofapifromcompose.yaml(same image, command, hardening, volumes with the sharedsigning-keyskey store of ADR 0066, healthcheck,stop_grace_period),ports: !reset [],BOTHY_CLUSTER__MODE: multi, anddepends_onwritten out (asapi), becauseextendsneed not carry it.worker:BOTHY_CLUSTER__MODE: multi.edge: imagenginxinc/nginx-unprivileged:${BOTHY_NGINX_VERSION:-<tag>}, profiledirect,read_only: true,cap_drop: [ALL],security_opt: ["no-new-privileges:true"],tmpfs: [/tmp], thelocallog driver with the options ofcompose.yaml, the file./edge/nginx.confread-only, the port127.0.0.1:${BOTHY_HTTP_PUBLISH_PORT:-8090}:8080, only the networkdefault,depends_onboth api nodes withservice_healthy.- Header comment: what the overlay is for, that the operator's TLS edge stays in front, that
BOTHY_TRUSTED_PROXIESthen names the subnet of the network<project>_default(fieldSubnetofdocker network inspect), because the server sees the edge's address and the edge appends the gateway's (16 4.3), and that the profiletailscaleruns one api node. - Add
BOTHY_NGINX_VERSIONto.env.exampleand to section 9 ofdocs/operations/configuration.md;TestComposeMultiNodeOverlaykeeps both equal to the default in the overlay.
-
Test harness for two nodes (
test/terminal/multinode). Onepgtestdatabase; nodes A and B assembled in-process throughinternal/wirein roleapiwithcluster.mode = multi, each with its own public and internal listener on127.0.0.1:0, the same key store directory frommktemp -d(secrets.signing_key_dir, mode0700), the sameclock.Fakefor everyNowread; an instance C in roleworkerfor the worker case. The test edge is an in-processhttputil.ReverseProxyon127.0.0.1:0that passes WebSocket upgrades, with the modeshash(both stream paths by rendezvous hashing over the{session_id}segment, all other paths to a node the test chooses) andsplit(device streams to A, host data to B, all other paths to a chosen node); the test can switch modes between steps. World: Robin is the owner; Alex is the assignee of the hostlab-1(active, profileshell) with a grantterminaland the session useralex-shell, which the fake host reports; the fake host of M2-44 connects its control connection and its data connections through the test edge with the edge's base URL; the device is the client of M2-44's relay tests. If the merged wiring has one global meter provider, the counter assertions use the process total by side. Timers (the open timeout of the relay, the probe, the end of a probation, the listener's retry) run on the timer source of M2-44's relay: if it followsclock.Fake, the tests advance the fake clock; otherwise the test configuration sets short values ofterminal.open_timeout,terminal.host_duplicate_probe_timeoutanddatabase.listen_reconnect_backoff_cap, and the tests wait with a bound of a small multiple of them. Exact boundaries are proven by the pure functions of the unit tests, never by waiting. Advance the fake clock between two accepts whose order a test needs. -
Telemetry and logs (Ruling for docs/DECISIONS.md,
D-M2-47-11). This ticket registers no instrument and no attribute: M2-44 registers and recordsbothy.terminal.relay.unpaired(D-W-105; signals.md 4.3a, unit{connection}) together with its attributebothy.terminal.side(client,host; signals.md 5.3, collector allowlist,otel/attr), and M2-46 the other threebothy.terminal.*instruments; this ticket asserts the counter with two nodes. Log records (constant English messages, attributes only from signals.md 5.3):terminal relay side unpaired(warn;bothy.terminal.side,bothy.close.code),terminal signal dropped(warn;bothy.reasonmalformed),terminal ephemeral listener lost(warn;error.type),terminal ephemeral listener restored(info),terminal duplicate control connection(warn;bothy.close.code). No session, host, person or device ID and no address in any of them. -
Specification and documentation text (English, like the files, in the same change):
- 24 9 "Multiple nodes": the kinds of design note 3 and their receivers, and that the end of a session reaches every node through the log tail without a signal; the presence view and its window (design note 6); the rules of the lone side (design note 8), stating that an
openingsession endsopen_failedwithclose_detail = open_timeoutand adetachedone staysdetached; the listener, its reconnect and the reconciliation (design notes 5 and 9); what is lost while a node'sLISTENconnection is down. - 24 3.4: the order of two connections across nodes, rules b and c of design note 7, the probation and the clock assumption. 24 6.1 point 6: reference to the phase rule of 9. 24 13:
4102with the channels "stream, data connection" and the phase rule. 24 16: rowR-TM-31"Signals between api nodes throughNOTIFY ephemeralonly with kind and IDs, also for state transitions, probe and presence; the end of a session reaches every node through the log tail; one path for one and for several nodes" with its reason. - 16 4.4 "Edge and proxy": point 1 states the idle timeouts of design note 11 (stream paths at least
terminal.idle_timeoutplusterminal.idle_grace, control connection at leastterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limit), point 2 names the example (infra/compose/compose.multi-node.yaml,infra/compose/edge/nginx.conf), a new point 4 names the overlay's conditions (profiledirect,BOTHY_TRUSTED_PROXIESwith the subnet, shared key store). - 02 11.6: after its table a paragraph "Terminal signals" (no rows in the table, because they are no event types of 05): the kinds of design note 3, one node local after the commit, several nodes
NOTIFY ephemeralin the transaction with host, session or connection ID; that the end of a session goes through the log tail (11.2), not through these signals; and that the moduleterminalholds its ownLISTEN ephemeralconnection until M7-17 shares one per process. 02 14.4: a row "LISTENconnection of the terminal" (soft; reconnect, then reconciliation). 02 16 "Multiple nodes in detail": a bullet "Terminal: 24, section 9". docs/api/asyncapi.yaml:hostData.x-close-codesgains4102terminal_host_unavailablewithclient_action"no counterpart on this node withinterminal.open_timeout; do not reopen this data connection, wait for the nextsession.open"; theclient_actionof4102onterminalStreamsays that an opening session endedopen_failed(open again) and a detached one staysdetached(attach again later).- Threat model: status head "as of milestone M2" if it does not say so yet; 5.16 two rows ("Relay | D | Edge without hash routing splits the stream paths of a session across two nodes | lone side closes after
terminal.open_timeoutwith4102, metric, no broker, example edge (16 4.4) | T-TM-15" and "Relay | E | End, revocation or second control connection on another node goes unnoticed | end and revocation through the log tail of every node, state transition as a signal in the transaction, reconciliation after reconnect, probe across nodes (24 3.4, 9) | T-TM-05, T-TM-11"); in 6.7 the test columns of T-TM-05 and T-TM-11 name the tests of this ticket, and a new row T-TM-15 "Misrouting and lost signals across several nodes" (L low, I medium; Mitigations as above; Tests TM-T15 and the cross-node tests; Residual risk low: while a node'sLISTEN ephemeralconnection is down, its control connections of a host disabled or re-paired elsewhere stay open until the reconciliation after the reconnect (the sessions still end through the log tail), and opens through its control connections fail visibly withopen_failed). - ADR 0068 "Confirmation": a bullet "Two api nodes with and without hash routing (TM-T15)"; the decision stays unchanged;
make adr-index. docs/operations/install-compose.mdnew 5.3 "Several api nodes (overlay)";infra/compose/README.md(files table, overlay, profiledirectonly, checks of this ticket);internal/terminal/README.md(mode, signals, listener, presence, probation, lone side, limits, the edge requirement).- 03, 04 and 05 stay unchanged: 03 6.6 already allows host and terminal session IDs on the channel. If the build finds that the core contract contradicts a rule here, stop with
Verdict: BLOCKED.
- 24 9 "Multiple nodes": the kinds of design note 3 and their receivers, and that the end of a session reaches every node through the log tail without a signal; the presence view and its window (design note 6); the rules of the lone side (design note 8), stating that an
-
Rulings for
docs/DECISIONS.mdsection 10, one row each with date, decision, reason and place:D-M2-47-1scope (note 1);D-M2-47-2mode and roles (note 2);D-M2-47-3kinds, payload and strict decoding, and the expectation ofterminal.session_openon every node (note 3);D-M2-47-4the port with one handler for both modes (note 4);D-M2-47-5listener, reconnect, reconciliation, soft dependency (notes 5 and 9);D-M2-47-6presence view and its window (note 6);D-M2-47-7probe order and probation (note 7);D-M2-47-8rules of the lone side and4102onhostData(note 8);D-M2-47-9the example edge and its timeouts (note 11);D-M2-47-10the overlay (note 12);D-M2-47-11log records, with the counter and its attribute left to M2-44 (note 14).
Tests (write them first)¶
Unit:
internal/terminal/domain:TestSignalCodec(each kind encodes and decodes to the same value; a payload with an unknown field, a missing ID, an extra ID for the kind, a non-UUID,nullor an unknownterminal.kind fails;typing.startedand other foreign kinds giveours = falsewithout error);FuzzDecodeSignal(any input gives an error, a foreign kind or a signal that encodes back to an equivalent payload; never a panic);TestSignalPayloadBound(the longest signal stays below the protocol constant for theNOTIFYpayload);TestOlderConnection(UUIDv7 IDs of two instants in both orders, equal IDs are not older);TestPresenceView(an entry counts at exactlyterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limitafter its lastSeenand not one tick later;Goneof oneconn_idkeeps the other; an unknown host is not connected);TestProbationEnd(twice the probe timeout after the first announcement);TestLoneSideOutcome(device inopening: close4102, endopen_failedwithopen_timeout, sideclient; host data inopening:4102, end, sidehost; device indetached:4102, no end, sideclient; host data indetached: no timer);TestLoneSideDeadline(one millisecond before the acceptance time plusterminal.open_timeoutthe side is not yet unpaired, at that instant it is).internal/terminal/app:TestLocalSignals(StageandSenddo nothing,Committedcalls the handler once);TestUseCasesStageSignalswith a recording fakeSignals: open stagesterminal.session_open;setHostStatetodisabledandretiredstageterminal.host_close; a re-pairing stagesterminal.host_close; close by the person and by an owner, storingsession.endedand the ends ofsetHostStatestage no signal (they travel through the event-log tail); a failing unit callsCommittednever.internal/terminal/adapters/wsrelay:TestHandleSignal_SessionOpenExpectsOnEveryNodewith a fakeControlLinksand fakeTimers: on a node without a control connection of the host,terminal.session_openregisters the expectation throughRelayControl.Expectand sends nosession.open; a host data connection of the session then pairs there instead of getting1000; an older device side of the session on that node closes with4103and an older unpaired host side with1000; a node with an eligible control connection registers the expectation and sends exactly onesession.open; a closed session or a session of another host registers nothing; the deadline of an expectation without sides ends a session still inopeningand nothing for a session that isactivein the database; on a host withrequire_local_confirm = truea side-less expectation that receivedterminal.session_pairedafter its grant only removes itself at its deadline (the session paired and waiting for consent on another node staysopening), while without that signal it ends the opening at its deadline; the pairing node sendsterminal.session_pairedexactly once per pairing of such a session.internal/terminal/adapters/otelmetrics(M2-44's counter):TestRelayUnpairedAttributeSet(the only attribute isbothy.terminal.sidewith exactlyclientandhost).internal/wire:TestClusterModeKey(enumsingleandmulti, default as 02 22, another value fails the start with a message naming the key);TestTerminalSignalsWiring(single:LocalSignals, no componentterminal_ephemeral;multiinapiandall:NotifySignals, the component and its stop step inlifecycle.PhaseEventTail;multiinworker:NotifySignals, no listener).
Integration (PostgreSQL 18 via pgtest, the harness of design note 13):
TestTMT15_TwoNodesWithHashRouting(TM-T15, first part): edgehash, control connection on B. Open sessions through REST on A until one has its hash node A and one has B (at most a fixed number of opens, closing each after its checks). For each: the fake host gets exactly onesession.openon B, valid againsthost.session.open, with the session'ssession_idanddata_path; device stream and host data connection both arrive on the hash node; handshake message 1, message 2 and later binary frames pass byte for byte in both directions; after the fake host'ssession.startedthe row isactive; the session token issued on A verifies with the JWKS that B sent inhello; no4102;bothy.terminal.relay.unpairedunchanged;/readyzof A and B answers200. Then the device disconnects,attachTerminalSessionruns through the other node, the new device arrives on the same hash node and pairs with the waiting host data connection, and a still open older device connection of the session gets4103there.TestTMT15_WithoutHashRouting(TM-T15, second part; PLAN row): edgesplit, open through A, device stream on A, host data on B. Beforeterminal.open_timeouthas passed since eachidentify, both are open; once it has passed, the device stream is closed with4102on A and the host data connection with4102on B;bothy.terminal.relay.unpairedrose by one withclienton A and by one withhoston B; the row isclosedwithopen_failedandclose_detail = open_timeout; exactly oneterminal.terminateaudit entry and oneterminal_session.changedfor the end; no byte of the device reached the fake host and none of the fake host reached the device;/readyzof A and B answers200before, during and after; two log recordsterminal relay side unpairedwith side and code and without any ID or token.TestTMT15_WithoutHashRouting_Attach: a session runs withhash; the edge switches tosplit; the device disconnects and attaches; the new device lands on the wrong node and gets4102afterterminal.open_timeout, the counter rises withclient, the session staysdetachedand the host data connection on the hash node stays open; withhashagain the next attach pairs.TestCrossNode_SessionOpenOnlyAfterCommit: control on B; a test wrapper aroundNotifySignalsstagesterminal.session_openand then fails the unit; the open answers an error, no row exists, the fake host gets nosession.openand a test connection with its ownLISTEN ephemeralreceives nothing.TestCrossNode_SignalsCarryOnlyIDs: a test connection withLISTEN ephemeralrecords every payload during the TM-T15 tests, the close tests and the host tests; each decodes strictly into its kind; no payload contains the session token, itsjti, the profile name, the host name, the session user name, a person ID or a device ID (canary search).TestCrossNode_ForgedSignalOpensNothing: the test sendspg_notify('ephemeral', ...)withterminal.session_openfor a random session ID, for a closed session and for a session of another host, and malformed payloads; the fake host gets nosession.open; each malformed payload gives oneterminal signal dropped; a payload of kindtyping.startedgives nothing.TestCrossNode_SingleModeSendsNoNotify: one node withcluster.mode = single; open, attach and close work as M2-44's tests expect; the test listener receives nothing andpg_stat_activityshows no backend with application namebothy-terminal-ephemeral.TestCrossNode_CloseReachesRelayAndControl, control on B, relay on A, subtests:person_close(closeTerminalSessionthrough B: device stream and host data closed with4100andclient_closedon A, the fake host getssession.closewithclient_closedon B);owner_close(Robin through A:closed_by_ownerthe same way);host_reported_end(the fake host sendssession.endedwithprocess_exitedto B: A closes both with4100);worker_end(an end committed in a unit of instance C in roleworker, through the end function the ends of M2-46 andterminal.sweepuse: A closes both, B sendssession.close). Each subtest compares codes, reasons and frames with the same scenario on one node.TestCrossNode_HostTransitionsCloseEverywhere: control on B, one session relayed on A;setHostStatetodisabledthrough A, then a second host toretired, then a re-pairing withissueHostPairingCode: the control and data connections close with the codes of M2-43's single-node path for that state (expected4111,4111,4110) and the device streams with4100and the sessions'close_reason, on whichever node holds them; parity with a run on one node.TestCrossNode_DuplicateControlConnection(T-TM-05), subtests:older_answers(fake host H1 on A answers pings; H2 with the same keys connects through B: withinterminal.host_duplicate_probe_timeoutboth are closed with4112, the host isdisabledwithduplicate_connection, its sessions endhost_disabled, one audithost.connectwith codeduplicate_connection,host.updatedto the owners);older_silent(H1 stops answering pings: afterterminal.host_duplicate_probe_timeoutH1 is closed with4009on A, H2 stays on B, A sendsterminal.host_gone);probation_defers_open(an open through A right after H2 connected:session.openreaches H2 only at the end of its probation; with H1 still answering, H2 never gets one);announcement_lost(A's listener is down while H2 connects; after A's reconnect and the next heartbeat of H1 the probe runs and the host endsdisabled).TestCrossNode_HostUnavailableParity: no control connection anywhere:openTerminalSessionthrough A and through B both give409 host_unavailable; control on B: an open through A gives201; the fake host closes its control connection: an open through A gives409 host_unavailable; a harness switch drops node B's connections and listener without its stop steps, like a crash, so noterminal.host_goneis sent, and the fake host does not reconnect: an open through A gives201untilterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limitafter the lastterminal.host_present, then409 host_unavailable; a session opened in that window endsopen_failedwithopen_timeout.TestCrossNode_ListenerLossReconciles(T-TM-11): control on A, a session relayed on A; the test ends A's listener backend (application namebothy-terminal-ephemeral) withpg_terminate_backendand disables the host through B withsetHostState; A's device stream and host data connection still close with4100andhost_disabledthrough the event-log tail, while A's control connection stays open and/readyzof A answers200; after the reconnect A closes the control connection with4111; oneterminal ephemeral listener lostand oneterminal ephemeral listener restored.TestCrossNode_StepUpConsumedOnce(ID-T28 across nodes): Alex confirms one fresh step-up; two concurrentopenTerminalSessionwith it, one through A and one through B, give exactly one201and one401 step_up_required, and one row.
Edge and overlay (Docker, test/edge):
TestEdgeExample_HashRoutesBothStreamPaths: on a test network, two responder containers of the pinned nginx image with a test config that answers every request with200and a header naming the node, with the aliasesapiandapi-2; the edge container withinfra/compose/edge/nginx.confmounted read-only, port8080published to127.0.0.1on a random port. For many UUIDv7 session IDs,/api/v1/terminal/sessions/{id}/streamand/api/v1/hosts/sessions/{id}/streamreach the same node, and both nodes occur;/api/v1/hosts/connectand/api/v1/gatewayare not pinned to one node.TestEdgeExample_Config: in the edge containernginx -tpasses andnginx -Tshowsproxy_http_version 1.1, the upgrade headers,proxy_buffering offon the stream location,hash $bothy_terminal_session consistentonly inbothy_terminal_stream,access_log off,error_logatcrit,client_max_body_size 0,X-Forwarded-For $proxy_add_x_forwarded_for,listen 8080andpidunder/tmp; the stream timeouts equalterminal.idle_timeoutplusterminal.idle_graceand the other timeouts equalterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limit, both read from the defaults indocs/operations/configuration.md, and the latter is abovesync.heartbeat_interval; the container runs read-only with all capabilities dropped andid -uis not0.TestComposeMultiNodeOverlay:docker compose --project-directory infra/compose -f infra/compose/compose.yaml -f infra/compose/compose.multi-node.yaml --env-file infra/compose/.env.example --profile direct config --format json.apiandapi-2have the same image, command, volumes (withsigning-keys),read_only,cap_drop,security_opt,tmpfs, healthcheck,stop_grace_period,depends_onand networks, bothBOTHY_CLUSTER__MODE=multiand no ports;workerhasBOTHY_CLUSTER__MODE=multi;edgeuses the tag ofBOTHY_NGINX_VERSIONin.env.example, which equals the default in the overlay, publishes only127.0.0.1:<BOTHY_HTTP_PUBLISH_PORT>:8080, is hardened as in design note 12, is not oninternal, mounts the config read-only and waits for both api nodes healthy; every published port of every service binds127.0.0.1. Casetailscale(profiletailscaleonly): neitheredgenorapi-2exists.
Contract: frames sent on control connections validate against host.session.open and host.session.close of asyncapi.yaml; responses of the operations used validate against openapi.yaml (G8) where the merged tree has the response validator.
Red team focus of the milestone ("misrouting across several nodes", T-TM-05, T-TM-11) is covered by TestTMT15_WithoutHashRouting, TestCrossNode_DuplicateControlConnection, TestCrossNode_CloseReachesRelayAndControl, TestCrossNode_ListenerLossReconciles and TestCrossNode_ForgedSignalOpensNothing; name them in the red team request.
Run FuzzDecodeSignal once locally with go test -run=^$ -fuzz=FuzzDecodeSignal -fuzztime=<duration of your choice> and report duration and result; commit any crashing input as a seed after the fix.
Commands that must pass, from the repository root:
cd services/server && go test -race -count=1 ./internal/terminal/... ./internal/wire/... ./internal/platform/... ./internal/kernel/... ./test/terminal/... ./test/edge/...
make codegen-check
make arch
make register-check
make api-lint
make api-checks
make api-tables
make config-reference
make config-doc-check
make adr-index
make lint
make docs
make check
After committing, git diff --exit-code docs/operations/configuration.md docs/adr/README.md shows nothing after make config-reference and make adr-index.
Acceptance criteria¶
- TM-T15 with hash routing: two api nodes with
cluster.mode = multi;session.openreaches the node of the control connection overNOTIFY ephemeralonly after the commit; both stream paths of a session meet on one node; every node registers the expectation ofterminal.session_open, so the session runs whether the control node and the relay node are the same or not; attach works across nodes and replaces an older device side with4103on the hash node. - TM-T15 without hash routing: each lone side closes with
4102afterterminal.open_timeout,bothy.terminal.relay.unpairedrises withclientandhost,/readyzstays ready on both nodes, the session endsopen_failedexactly once, and no byte crosses nodes. - With
cluster.mode = singlenoNOTIFYis sent and no listener runs; M2-43's and M2-44's behaviour is unchanged. - Signals carry only a kind and IDs; rolled-back, forged and malformed signals open nothing.
- An end of a session or a host transition decided on any node, also in a worker, closes the connections on every node with the codes of one node.
- A second control connection on another node is probed:
4112anddisabledwithduplicate_connectionwhen the older answers,4009for a silent older one; a new connection gets nosession.openduring its probation. -
409 host_unavailableanswers the same on every node; a dead node's entry expires afterterminal.host_heartbeat_intervaltimesterminal.host_heartbeat_miss_limit. - A lost listener reconnects, reconciles and never affects
/readyz; one fresh step-up opens exactly one session across nodes. - The example edge routes both stream paths of a session to one node with the real nginx image, its timeouts and logs follow 16 4.4, and the overlay renders two equal api nodes behind it with ports only on
127.0.0.1. - 24, 16, 02,
asyncapi.yaml, threat model, ADR 0068,install-compose.md,configuration.mdsection 9, the Compose README, theterminalREADME, DECISIONS and both CHANGELOGs are updated. -
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.
- README of
terminalupdated (cluster mode, signal kinds, listener, presence view, probation, lone side, edge requirement, what M7-17 adds);infra/compose/README.mdupdated. - Rulings of design note 16 recorded in
docs/DECISIONS.mdsection 10 (one row each with reason); the changes to 24, 16, 02,asyncapi.yaml, the threat model and ADR 0068 made in the same change. services/server/CHANGELOG.mdand the rootCHANGELOG.mdentries under 'Unreleased'.- An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- What is not proven (for example the timing margins of the probation on loaded nodes, other edges than the example, the overlay started with real images, which M2-48 and M7-17 cover).
- Follow-ups that could not be done in this scope, each with its reason, the version, digest and license of the nginx image, every difference between the names in this brief and the merged code of M1 and M2-38 to M2-44 (D-W-100), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.