Compass-native T5 — native-client mode end to end (SEA-1686)
Status: Draft
Refines the frozen parent record docs/designs/product/compass-native-app/design.md §T5 + §A4. Design only; no implementation.
Problem / Intent
Section titled “Problem / Intent”The same compass-app binary that runs embedded mode (T4, done) must also run
native-client mode: connect to an already-established remote
compass-server instead of supervising a local stack. Today client mode is a
parked sentinel — go/cmd/compass-app/embedded.go:101-102:
case appconfig.ModeClient: return "", errClientNotImplementedT5 wires it end to end: mode-select → connect screen (bearer token ONLY) →
keychain store + bearer injection over the shell IPC → remote TLS dial with
optional private-CA anchor → apiVersion compat check → WhoAmI resolves the
caller account → board renders live. Refines parent §T5 (design.md:493-518)
against frozen rows DL-107 (shell-side CA trust + bearer injection), DL-109
(mode-select + keychain-first creds, never config/argv), DL-111 (WhoAmI
supplies caller identity).
Global Constraints
Section titled “Global Constraints”-
Framework = Wails v3 (DL-110). The shell binds services via
application.NewServiceand emits frames through the Wails event manager (go/cmd/compass-app/bridge_service.go:30-36— “Its signature matches*application.EventManager.Emitexactly”). -
Go go1.26.5; proto-shimmed
go— build/vet only viadirenv exec <workspace-root> go …, never barego. -
Scripts-over-bash; tests are Go/TS test code.
-
No cleartext:
server_urlis already https-only —go/internal/appconfig/appconfig.go:129-132:if u.Scheme != "https" {return fmt.Errorf("appconfig: server_url %q must use https (got scheme %q): cleartext connections are not allowed",The server side independently refuses a cleartext door and pins TLS 1.3 (
go/server/network_door.go:99-103,:115MinVersion: tls.VersionTLS13). -
Secret hygiene (DL-109): the bearer lives ONLY in the OS keychain (0600 file fallback) and in the shell-injected
Authorizationheader. NEVER in app.toml (enforced:appconfig.go:137-140rejects URL-embedded creds andConfig“carries neither the bearer token (OS keychain, DL-109) nor the caller account id (WhoAmI RPC, DL-111)”,appconfig.go:48-50), never in argv, never in the UI-sideConnection, never in the process table, never logged (mirrornetwork_door.go:258-259“Log the path, never the token”). -
Cross-lane contracts are consumed, not redefined: the server’s bearer interceptors, WhoAmI, and apiVersion are compass-server’s surface (grounded below). Any needed server-side change is an Open Question, not a task here.
-
UI transport-boundary discipline (§A1): no Wails/shell import above the
ShellIpc/ConnectionProviderseams (apps/ui/src/live/provider.ts:12-14— “NO shell/Wails dependency reaching this module or anything above the transport boundary”).
Approach
Section titled “Approach”What already exists (consumed, not re-designed)
Section titled “What already exists (consumed, not re-designed)”Client-mode config parsing is DONE. go/internal/appconfig/appconfig.go
parses mode="client" (modeStrClient :32), requires a validated https
server_url (parseClient :106-119, validateServerURL :124-143), carries an
optional ca_cert trust-anchor path (Config.CACert :57-60 — “optional path
to a private trust anchor (PEM) … Empty means use the system roots”), rejects
unknown keys (:85-91), and applies the --mode override with re-validation
(applyOverride :200-218). T5 consumes appconfig.Config{Mode, ServerURL, CACert} as-is.
The mode flag plumbing is DONE. go/cmd/compass-app/main.go:57-59:
modeFlag := flag.String("mode", "", "Operating mode override (embedded|client). Defaults to $COMPASS_APP_MODE, "+ "then app.toml, then embedded.")and launchByMode dispatches on the resolved mode
(embedded.go:97-105) — the ModeClient branch is the sentinel T5 replaces.
The bridge pump is transport-agnostic by design. go/internal/bridge/pump.go:77-81:
// Today the only constructor is [NewUnixTarget] (embedded/Dogfood mode: h2c over// a Unix domain socket). The type is deliberately transport-agnostic so a// TLS/network-door target (native-client mode) is a clean later drop-in: a new// constructor supplies a client with a TLS-dialing transport and an https base// URL, and the pump forwarding logic is unchanged.T5 adds that constructor; Pump.Do (:117-127) is untouched.
The shell IPC bridge service is DONE (Wails v3). bridge_service.go binds
compass_rpc/compass_rpc_cancel and streams frames as Wails runtime events
(bridge_service.go:9-11 — “streams each ordered response frame back as a
Wails runtime event named “compass_rpc:“+requestId”). It already exposes an
AccountID bound getter whose doc anticipates client mode
(bridge_service.go:81-83 — “It returns the empty string when no identity was
resolved (client mode, …)”).
The UI seams are DONE — with one stale-binding caveat. The
ConnectionProvider.resolve() seam (apps/ui/src/live/provider.ts:28-30) and
envConnectionProvider (:37-43) are the boot seam; ShellIpc
(apps/ui/src/daemon-transport.ts:32) + createDaemonFetch (:66) are the
frame seam, deliberately framework-agnostic (:13-15 — “any shell can supply
its own ShellIpc binding”). Caveat: the only in-tree binding is still the
stale Tauri one — daemon-transport.ts:188 import { Channel, invoke } from "@tauri-apps/api/core"; and tauriShellIpc (:190-200), with
@tauri-apps/api": "^2" still in apps/ui/package.json:17 — while the Go
shell is Wails v3 emitting "compass_rpc:"+requestId events. A Wails-events
ShellIpc binding does not exist in the UI tree yet (grep for wails under
apps/ui/src matches only the provider.ts comment). T5 designs strictly
against the ShellIpc interface; the Wails JS binding is its own small task
(T5.4) and a coordination point with the compass-ui lane (OQ-5).
The server contracts (compass-server’s; cited, consumed).
-
Bearer interceptors on the network door —
go/server/network_door.go:263-267:interceptors := connect.WithInterceptors(auth.BearerInterceptor(st),auth.BearerStreamInterceptor(st),auth.NewAdminGate(adminID),) -
The token the operator reads —
issueAndWriteAdminToken(network_door.go:333-339) mints and writes it 0600 asadmin-tokenunder the state dir (adminTokenFile:36; atomic 0600 write,writeTokenFile:341-347). -
GetServerInforeturnsVersion+ApiVersion(go/server/service.go:377-386), with the contract constantconst apiVersion = "compass.v1"(service.go:36-37). -
WhoAmIreflects the bearer’s subject and fails closed —go/server/service.go:398-402:caller, ok := auth.CallerFrom(ctx)if !ok {return nil, connect.NewError(connect.CodeUnauthenticated, errNoCaller)}return connect.NewResponse(&compassv1.WhoAmIResponse{AccountId: string(caller)}), nil -
UI probe:
probeServerreturns{version, apiVersion}—apps/ui/src/live/client.ts:65-68(the parent’sclient.ts:48-51citation has drifted; current lines are 65-68).resolveCaller(:76-84) already rejects an empty WhoAmI id. -
The parked caller-id gap is already retired:
apps/ui/src/live/connection.ts:3-6— “The caller’s own account id is NOT part of this config: it is learned from the server via the WhoAmI RPC right after the transport is up”; theconnection.ts:28-35lines the parent cites are now theCompassEnvinterface (:30-33), no caller-id field anywhere. Nothing to remove; T5 just must not reintroduce one.
The T5 shape: shell owns secrets and dial; UI owns the screen
Section titled “The T5 shape: shell owns secrets and dial; UI owns the screen”The load-bearing split: everything secret or transport-shaped lives in the
Go shell; the UI renders states and forwards one pasted string. This is the
direct composition of DL-107 (shell-side CA trust + bearer injection), DL-109
(keychain-first, UI Connection carries no bearer), and DL-111 (identity is
server-resolved) with the existing seams.
Client-mode flow:
- Launch (Go) —
run()resolvescfg.Mode == ModeClient. Unlike embedded mode, whose whole bring-up runs before the window opens under a bounded bring-up context (main.go:84-86), client mode opens the window IMMEDIATELY: bring-up may require the user to paste a token, so the window IS the bring-up surface and there is no pre-window probe that could wedge launch on a slow or unreachable remote. The shell builds the remote TLSbridge.Targetfromcfg.ServerURL+cfg.CACertand constructs the bridge service wired with the tokenstore and the connect prober. - Auto-connect (UI-driven, ONE probe path) — boot calls the
ConnectIPC method with an empty token (see step 4, “use the stored one”), and the UI renders aconnectingstate while the call is in flight. This is the ONLY auto-connect probe: the shell runs no separate pre-window probe, so there is exactly one probe chain per launch (closes the duplicate-probe gap). If a stored token exists and the probe succeeds, boot proceeds straight to the board — token survives restart via the keychain (gate requirement). - Connect screen (UI) — if there is no stored token, or the stored-token probe returns a failure kind, the UI renders the connect screen: the server URL displayed read-only (it comes from app.toml, not user entry), ONE input — the bearer token — and one visual state per failure kind. No caller-id field (DL-111). Submit is disabled on empty input: the empty-token sentinel is a boot-internal call, never a user submit.
- Connect probe (Go, one bound IPC method) — the token (pasted, or empty
meaning “use the stored one”) goes over a single
ConnectIPC call. Go-side, using a connect-go client over the same TLS-anchoredhttp.Clientthe bridge target uses, the shell:GetServerInfo→ exact-matchapiVersion == "compass.v1"→WhoAmI→ classify any failure into a sealed error-kind enum (bad-url/bad-cert/bad-token/version-mismatch/other). On success it writes the keychain, arms the pump’s bearer injector, and returns{accountId, serverVersion, apiVersion}(accountIdused for the “connected as …” confirmation, not written to the shell’s set-onceaccountIDfield — see step 6 / OQ-7). On failure it returns the kind + a safe message and stores nothing. - Token dropped by the UI — the connect screen hands the token to
Connectand discards it; the nativeConnectionProviderresolves aConnectionwithtoken: undefined(assertable). Note this supersedes the parent’s “handed an empty token” phrasing (compass-native-app/design.md:152-153): the client factory rejects an EMPTY STRING loudly as a misconfiguration (apps/ui/src/live/connection.ts:17-19), so the correct no-bearer value isundefined, not""— an executor following the parent’s prose must not ship the rejected empty string. The gRPC-Web transport is built without a bearer (packages/compass-client/src/index.ts:63-66createCompassWebTransport(baseUrl, token?, opts?)— token simply not passed); the shell injectsAuthorization: Bearer …into every forwarded call at the pump target and strips any inboundauthorizationheader so the UI cannot inject one even by bug. - Board renders — the UI boots through the existing chain
(
index.tsx:32-47bootConnection→main→bootCaller), with ONLY the native provider substituted forenvConnectionProvider. The caller id is resolved UNIFORMLY by the existingbootCaller(root, () => resolveCaller(clients.compass))path (index.tsx:64) — the same WhoAmI round-trip both modes already run, now tunneled over the IPC fetch with the shell-injected bearer. This keeps the mode difference confined to the boot provider choice (§A1’s one sanctioned divergence, no second mode-conditional above the transport seam) and lets a token revoked betweenConnectand boot land onbootCaller’s failure screen rather than a stale cached id. The Go-sideAccountIDbinding stays empty in client mode (its existing documented behavior,bridge_service.go:81-83). OQ-7 parks the alternative (seedbootCallerfrom a Go-sideshellAccountID()to save one round-trip) for Matt’s ruling.
Why Go-side classification, not UI-side. The frame seam’s failure channel
is a bare string (pump.go:56-58 ErrorFrame{Message string};
daemon-transport.ts:23 { kind: "error"; message: string }). Distinguishing
bad-URL / bad-cert / bad-token / version-mismatch by parsing error prose in the
UI would be brittle and would push TLS/x509 vocabulary above the transport
boundary. Go sees the typed causes natively (*net.OpError/DNS errors,
*tls.CertificateVerificationError, connect.CodeOf(err) == connect.CodeUnauthenticated, and a successful probe with a mismatched
ApiVersion), so the one Connect bound method returns a sealed kind and the
frame contract stays untouched.
Failure-state legibility (the gate’s four states).
| State | Go-side signal | UI copy theme |
|---|---|---|
| bad URL | dial error: DNS/refused/timeout (*net.OpError, *net.DNSError, deadline) | “Can’t reach the host” + check server_url |
| bad cert | *tls.CertificateVerificationError (or hostname mismatch) | “Can’t verify the server’s certificate” + ca_cert hint |
| bad token | probe/WhoAmI returns connect.CodeUnauthenticated | “The server rejected this token” |
| version mismatch | probe OK, ApiVersion != "compass.v1" | “App speaks compass.v1; server speaks X” |
Each is a distinct enum value with its own test row; other is the explicit
residual (never a silent fallthrough).
Secret-hygiene assertions (gate requirements, each a test):
- UI: the resolved client-mode
Connectionhastoken === undefined(provider unit test). - Go:
Connectnever logs the token; the keychain fallback file is 0600 (mirroringwriteTokenFile’s atomic 0600 pattern,network_door.go:341-347). - e2e script: after connect,
app.tomlcontains no token substring and/proc/<pid>/cmdlineof every compass process contains no token substring.
Alternatives considered
Section titled “Alternatives considered”- UI-side probe + error-string parsing (UI calls
probeServer/WhoAmIover the IPC fetch and pattern-matchesErrorFrame.message): rejected — brittle string matching, TLS vocabulary above the transport boundary, and the token would transit the UI twice. The Go-sideConnectmethod gives typed causes for free. - Extending
ResponseFrame.errorwith a machine-readablecodefield so the UI can classify transport failures generally: rejected for T5 — it changes the frozen §A2 frame contract for a need the singleConnectmethod covers; revisit only if post-connect mid-session error legibility demands it (Open Question OQ-6). - Connect screen as a router route (
/connectinAppRoutes): rejected in favor of a boot gate — the screen must render before the store/QueryClient exist (boot cannot proceed without a working transport,index.tsx:49-57), exactly whererenderBootError-style boot surfaces already live (OQ-2). - Storing the token in app.toml or passing via argv/env: forbidden by
DL-109 and already actively rejected by
validateServerURL(appconfig.go:137-140).
Dependency order: T5.1 → T5.2 → T5.3 → (T5.4 ∥ T5.5) → T5.6 → T5.7. T5.1/T5.2/
T5.3 are Go-shell slices; T5.4/T5.5 are UI slices; T5.6 wires launch; T5.7 is
the gate. The T5.4 ∥ T5.5 parallelism is against a STUBBED seam: T5.5 is
developed against a stubbed shellConnect/nativeConnectionProvider and
integrates T5.4’s real outputs at its gate (T5.5 consumes them — see its
Interfaces block), so the two UI slices are concurrently developable but
T5.5’s gate depends on T5.4’s outputs landing.
T5.1 — Remote TLS bridge target
Section titled “T5.1 — Remote TLS bridge target”New constructor in go/internal/bridge beside NewUnixTarget
(pump.go:87-105), per the package’s own drop-in note (pump.go:77-81).
- Do:
NewTLSTarget(serverURL string, caPEM []byte) (*Target, error)builds aTargetwhosehttp.Clientuses an HTTP/2-over-TLS transport: system roots whencaPEMis empty, else ax509.CertPoolseeded with the PEM (the appconfigca_certanchor,appconfig.go:57-60). Base URL =serverURL(already validated https-absolute byvalidateServerURL,appconfig.go:124-143).Pump.Dois untouched. Additionally a header-injection hook onTarget(or a thinPump-level option) that setsAuthorization: Bearer <token>on every forwarded request and DROPS any caller-suppliedauthorizationheader — the DL-107 shell-injection point. The token is held in the target via a setter armed by T5.3’sConnect, guarded for concurrent reads. - Interfaces:
- consumes
appconfig.Config.ServerURL/.CACert(read by caller), crypto/tls, net/http. - produces
func NewTLSTarget(serverURL string, caPEM []byte) (*Target, error)andfunc (t *Target) SetBearer(token string)(empty = no injection).
- consumes
- Test cycle: Go unit tests against
httptest.NewUnstartedServerwith a self-signed cert: (a) dial succeeds with the anchor PEM, failstls.CertificateVerificationErrorwithout it; (b) every forwarded request carries exactly oneAuthorizationheader with the armed bearer; (c) a caller-suppliedauthorizationheader is stripped; (d) empty bearer injects nothing.
T5.2 — Keychain store (keychain-first, 0600-file fallback)
Section titled “T5.2 — Keychain store (keychain-first, 0600-file fallback)”New package go/internal/tokenstore.
-
Do: OS-keychain read/write/delete of the remote bearer, keyed by service
"compass-app"+ the server URL (so two remotes never collide). Wails v3 ships no keychain API (nothing undergithub.com/wailsapp/wails/v3ingo/go.mod:29provides one — OQ-1), so usezalando/go-keyring(Secret Service/D-Bus on Linux, Keychain on macOS). Fallback when the keyring is unavailable: an 0600 file$XDG_STATE_HOME/compass/remote-token(else$HOME/.compass/remote-token), written atomically exactly like the server’s token file (temp file born 0600 + rename,writeTokenFile,go/server/network_door.go:341-347). Never log the token (network_door.go:258-259pattern). -
The fallback file is URL-bound, not last-writer-wins. The keyring path is already keyed by server URL; the file fallback MUST carry the same binding or it becomes a cross-remote bearer-replay hole: a bare token file
- a later app.toml edit to
server_url = <remote B>would make auto-connect (T5.6) read remote A’s bearer and SEND it to B in theAuthorizationheader — bearer disclosure to an arbitrary server, the exact class the server treats as critical (network_door.go:258-259— a leaked bearer lets anyone impersonate the admin). So the fallback file stores the pair{serverURL, token}(single JSON object, 0600);Read(serverURL)returnsErrNotFoundwhen the stored URL does not match the requested one, andWritereplaces the whole pair. Single-remote MVP is preserved (one file, one pair); the cross-remote replay is closed.
- a later app.toml edit to
-
Interfaces:
-
consumes
github.com/zalando/go-keyring(new dep), os, path/filepath. -
produces:
type Store interface {Read(serverURL string) (token string, err error) // ErrNotFound when absent OR stored URL != serverURLWrite(serverURL, token string) errorDelete(serverURL string) error}func New(stateDir string) Store // keyring-first, file-fallbackvar ErrNotFound = errors.New(...)
-
-
Test cycle: Go unit tests on the file fallback (temp dir): round-trip, 0600 mode asserted,
ErrNotFoundon absent,ErrNotFoundon a URL-mismatch read (the F1 replay guard), atomic overwrite. Keyring path covered by a build-tagged integration test (D-Bus present) — CI runs the file-fallback suite unconditionally.
T5.3 — Connect bound method + failure classification (shell)
Section titled “T5.3 — Connect bound method + failure classification (shell)”Extends bridgeService (go/cmd/compass-app/bridge_service.go).
- Do: a
Connect(ctx, req)bound IPC method: given a pasted token (or""meaning “use the stored one” — a boot-internal auto-connect call, never a user submit, T5.5), run the probe chain over a connect-goCompassServiceClientbuilt on the SAMEhttp.Client/CA anchor as the T5.1 target:GetServerInfo→ exact-matchApiVersionagainst the app’s pinned apiVersion literal →WhoAmI(subject id, DL-111; empty id rejected as the UI’sresolveCallerdoes,apps/ui/src/live/client.ts:78-84). The server’sapiVersionconstant is UNEXPORTED (const apiVersion = "compass.v1",go/server/service.go:36-37) so the app cannot import it: it pins its OWNconst clientAPIVersion = "compass.v1"literal, cross-checked by a test that compares it against a liveGetServerInforesponse from the in-repo server (drift then reddens CI here, not silently at a customer). On success:tokenstore.Write,target.SetBearer(token), and return the summary (including the resolvedaccountId) — it does NOT write the service’saccountIDfield: that field is documented set-once beforeapp.Runand read without a lock (bridge_service.go:48-54), andConnectis a webview-goroutine bound method (bridge_service.go:39-42) so writing it at runtime is a data race; the caller id is instead resolved uniformly by the UI’sbootCaller/resolveCallerpath over the IPC fetch (Approach step 6), and theAccountIDbinding stays empty in client mode as documented (bridge_service.go:81-83). On failure: classify into the sealed kind enum and return it; store/arm nothing. ADisconnect-shaped forget path is OUT of T5 scope (no gate requirement). - Interfaces:
-
consumes T5.1
NewTLSTarget/SetBearer, T5.2tokenstore.Store,compassv1connect.CompassServiceClient,connect.CodeOf. -
produces (Go, bound to the webview; camelCase JSON tags like
rpcRequest,bridge_service.go:95-104):type connectRequest struct{ Token string `json:"token"` }type connectResult struct {OK bool `json:"ok"`Kind string `json:"kind"` // "" | "bad-url" | "bad-cert" | "bad-token" | "version-mismatch" | "other"Message string `json:"message"` // safe prose, never the tokenAccountID string `json:"accountId"` // for the "connected as …" confirmation; NOT written to the service's set-once accountID field (see Do / OQ-7)ServerVersion string `json:"serverVersion"`APIVersion string `json:"apiVersion"`}func (s *bridgeService) Connect(ctx context.Context, req connectRequest) connectResult -
classification:
*net.DNSError/*net.OpError/deadline →bad-url;*tls.CertificateVerificationError(viaerrors.Ason the dial error) →bad-cert;connect.CodeUnauthenticated→bad-token; probe OK withApiVersion != clientAPIVersion→version-mismatch; elseother.
-
- Test cycle: table-driven Go tests against
httptestTLS servers, one row per kind: unreachable host, self-signed-untrusted cert, a stub CompassService returning Unauthenticated, a stub returningApiVersion:"compass.v2", and the success path asserting keychain write +SetBearerarmed + account id recorded. Assert the token string never appears inMessageor the log output.
T5.4 — Wails ShellIpc binding + native ConnectionProvider (UI)
Section titled “T5.4 — Wails ShellIpc binding + native ConnectionProvider (UI)”Cross-lane: needs compass-ui coordination (decided, OQ-5).
apps/ui/src/daemon-transport.ts, apps/ui/src/components/MarkdownText.tsx,
apps/ui/src/components/MarkdownText.test.tsx, and apps/ui/package.json are
the compass-ui lane’s zone; this task edits all four, so it MUST be coordinated
with (and ack’d by) the compass-ui lane before
it starts. Parent §A2 anticipates the ShellIpc swap
(compass-native-app/design.md:127-130 — “swaps only the two framework calls
(invoke, Channel, today Tauri-shaped in the UI) for the Wails runtime
behind a thin ShellIpc shim”), so it is contract-legitimate; the gate is
ownership/scheduling, not the frozen contract.
Replaces the stale Tauri binding (apps/ui/src/daemon-transport.ts:185-207,
@tauri-apps/api/core) with the Wails v3 one matching the Go shell’s actual
contract: bound-method calls + "compass_rpc:"+requestId runtime events
(bridge_service.go:9-12).
-
Do: a
wailsShellIpc: ShellIpcbinding —rpcsubscribes to the"compass_rpc:"+requestIdevent before invoking the boundCompassRPC, delivering eachResponseFrametoonFrameand unsubscribing on terminal frame;cancelinvokesCompassRPCCancel. Plus anativeConnectionProvider(): ConnectionProviderthat resolves{ baseUrl, token: undefined, fetchImpl: createDaemonFetch(wailsShellIpc) }— the DL-109 assertion point: the UI-sideConnectionNEVER carries a bearer in client mode. Also a thin wrappershellConnect(token)→Connect. (NoshellAccountIDwrapper: the caller id is resolved uniformly by the existingresolveCallerpath over the IPC fetch — Approach step 6 / OQ-7 — so the shell binding surface stays minimal.) All Wails imports live in this one module (provider.ts:12-14 discipline). Remove the@tauri-apps/apidependency andtauriShellIpc/daemonFetchexport. -
Both Tauri deps leave in this task (decided, OQ-5). T5.4 removes the two independent Tauri runtime imports so the native shell carries no Tauri dependency: (1) the
ShellIpcbinding —@tauri-apps/api/core(daemon-transport.ts:188), swapped for the Wails events binding above; and (2) the link opener —apps/ui/src/components/MarkdownText.tsx:1import { openUrl } from "@tauri-apps/plugin-opener";(+package.json:18"@tauri-apps/plugin-opener": "^2"), which cannot open links under the Wails webview (external links in rendered markdown are broken in both native modes today, pre-existing from T4). Swap it for the Wails runtime’s browser-open call (@wailsio/runtimeBrowser.OpenURL), keeping the browser-dev-build path (a plain anchor /window.open) intact behind the same seam. Inpackage.json: remove BOTH@tauri-apps/*entries AND ADD@wailsio/runtime(pinned) todependencies— the runtime this task’s binding (Call.ByName/Events.On/Browser.OpenURL) imports, not currently a UI dep. (Flagged as a new JS dep at review, mirroring the go-keyring Ledger-impact call-out.) The T5.7 gate’s “board renders” is extended to assert a rendered-markdown external link actually opens, so a silently-broken opener cannot pass. -
Interfaces:
-
consumes
ShellIpc/createDaemonFetch(daemon-transport.ts:32,66),ConnectionProvider/ResolvedConnection(provider.ts:22-30), the Wails v3 JS runtime (@wailsio/runtime:Call.ByName,Events.On— exact API OQ-5). -
produces (TS):
export function wailsShellIpc(): ShellIpc;export function nativeConnectionProvider(baseUrl: string): ConnectionProvider;export function shellConnect(token: string): Promise<ConnectResult>;export type ConnectResult = { ok: boolean; kind: "" | "bad-url" | "bad-cert" | "bad-token" | "version-mismatch" | "other"; message: string; accountId: string; serverVersion: string; apiVersion: string };
-
-
Test cycle: unit tests with a fake Wails runtime (the same fake-the-seam style as
FakeShellIpc,daemon-transport.test.ts:42): frame ordering, unsubscribe-on-terminal, cancel forwarding; provider test assertingresolve()yieldstoken === undefinedand a definedfetchImpl. Plus: rewriteMarkdownText.test.tsx’s link-safety suite, which today couples to the removed dep (import * as realOpener from "@tauri-apps/plugin-opener"at line 3;mock.module("@tauri-apps/plugin-opener", …)at lines 429/434/472/499) — remock the new WailsBrowser.OpenURLseam instead, PRESERVING everyjavascript:/file:/data:scheme-neutralization assertion (a dangerous href must never reach the opener) and the safe-https:-still-opens assertion. Removing the Tauri opener without this rewrite breaks the suite’s module mocks; the suite must not be deleted or skipped to green CI (that would drop live-href injection coverage).
T5.5 — Connect screen as a boot gate (UI)
Section titled “T5.5 — Connect screen as a boot gate (UI)”- Do: a boot-gate screen (not a router route — OQ-2) driven by the native
boot path. Boot calls
shellConnect("")ONCE (the single auto-connect probe, Approach step 2) and branches on the result: while it is in flight the UI shows aconnectingstate; onokit proceeds into the board; on any failure kind it renders the connect screen — read-only server URL, one token input, a connect button drivingshellConnect(token), and one visual state per failure kind (distinct copy per the Approach table;othershows the safe message). Submit is DISABLED on empty input, so the empty-token “use-the-stored-one” sentinel is only ever the boot-internal call, never a user action.shellConnect("")is also the armed-ness channel (no separateConnected()getter needed): a successful result means the shell armed a connection, a failure means render the gate. On success, proceed into the existing boot chain (index.tsx:32-47): resolve vianativeConnectionProvider, and resolve the caller id UNIFORMLY through the existingbootCaller(root, () => resolveCaller(clients.compass))path (index.tsx:64) — no mode-conditional, noshellAccountIDseeding (OQ-7). The token input’s value is cleared after the call; nothing stores it UI-side. - Interfaces:
- consumes T5.4
shellConnect/nativeConnectionProvider,renderBootError-style painting (apps/ui/src/boot.ts:48-53), the boot chain (index.tsx:58-69). - produces
bootNativeClient(root: HTMLElement): Promise<ResolvedConnection | undefined>— the client-mode sibling ofbootConnection, returning undefined only when the user cannot proceed (it keeps the screen up and retries in place, so in practice it resolves on success).
- consumes T5.4
- Test cycle: component/unit tests with a stubbed
shellConnect: the in-flightconnectingstate renders; each failure kind renders its distinct state; success resolves the provider; submit is disabled on empty input; the token variable is not retained after submit (spy on the stub, assert the input is cleared and no module-scope binding holds it).
T5.6 — Client-mode launch wiring (shell)
Section titled “T5.6 — Client-mode launch wiring (shell)”-
Do: replace
errClientNotImplemented(embedded.go:56-59,101-102). Todayrun()(main.go:48-151) resolves the stack binary and builds the quit controller UNCONDITIONALLY before dispatch:resolveStackBinhard-fails launch on error (main.go:89-92— “stackBin, err := resolveStackBin(...);if err != nil { return err }”) and the quit controller/“Quit and stop stack” menu is built atmain.go:122-140. Both move UNDER the embedded branch: a client-only install has nocompass-stackbinary and no stack to stop, so neither may gate client launch. In the client branch: buildNewTLSTarget(cfg.ServerURL, caPEM)(caPEM read fromcfg.CACertwhen set, with a legible read/parse error) instead ofNewUnixTarget(socket)(main.go:105), construct the bridge service wired with the tokenstore + connect prober, and open the window IMMEDIATELY. There is NO pre-window auto-connect probe (that reconciles the earlier draft’s contradiction): the single auto-connect probe is the UI’s boot-timeshellConnect("")(Approach step 2, T5.5), so a slow/unreachable remote never wedges launch. The launch mode is handed to the UI as a shell-injected startup global (window.__COMPASS_MODE__, set at window creation — OQ-8) that the UI reads synchronously at entry to pick env-provider vs native-client path, so boot needs no IPC round-trip to learn its mode. -
Interfaces:
- consumes T5.1/T5.2/T5.3,
appconfig.Load(appconfig.go:158),launchByMode(embedded.go:97-105). - produces the reshaped launch.
launchByModetoday isfunc(ctx, mode, pipeline, params) (string, error)returning the account id (embedded.go:97-105). Retire the single-return shape in favour of two explicit mode paths inrun()(no sum-type gymnastics): anrunEmbedded(ctx, params) (accountID string, quit *quitController, err error)and arunClient(cfg, deps) (*bridgeService, err error)that wires the TLS target + tokenstore + prober and leavesaccountIDempty (client identity is UI-resolved, OQ-7).run()switches oncfg.Modeand only the embedded arm touchesresolveStackBin/the quit controller. Plus: injectwindow.__COMPASS_MODE__("embedded" | "client") into the webview at window creation (theapplication.WebviewWindowOptionsinrun(),main.go:142-147), the boot-dispatch signal the UI reads with no IPC (OQ-8). No boundMode()IPC getter: the injected global is the single source of truth for the mode.
- consumes T5.1/T5.2/T5.3,
-
Test cycle: replace
embedded_test.go’slaunchByModerows with the two split paths:runClientno longer errors, invokes no pipeline/stack-bin effect, produces a TLS target, and attempts auto-connect iff the store has a token for the configured URL (fake store + fake prober);runEmbeddedretains its existing behavior.
T5.7 — e2e gate (parent §T5 gate, design.md:513-518)
Section titled “T5.7 — e2e gate (parent §T5 gate, design.md:513-518)”- Do: a scripted e2e against a locally-started
compass-server --listenwith its self-signed anchor and mintedadmin-token(network_door.go:327-339): app in client mode (app.toml:mode="client",server_url,ca_cert) → connect screen → paste token → board renders; wrong token →bad-tokenstate; server stubbed to a different apiVersion →version-mismatchstate; relaunch → auto-connect from keychain (no screen); assert no token substring inapp.tomlnor in/proc/*/cmdline; UIConnection.token === undefined(covered by the T5.4 unit assertion, referenced in the checklist). Manual QA on the dev box + a headless CI variant covering the Go-side chain (T5.3’s tests are the CI proxy for the webview-dependent steps, mirroring T4’s split, parent design.md:490-491). - Interfaces: consumes everything above +
compass-server --listen. Produces the T5 gate evidence. - Test cycle: the script itself, kept under the repo’s scripts convention.
- T5.1
bridge.NewTLSTarget(CA anchor +SetBearerheader injection/strip); TLS + injection unit tests. - T5.2
go/internal/tokenstorekeyring-first store with atomic 0600 file fallback; round-trip + mode +ErrNotFoundtests. - T5.3
bridgeService.Connectprobe chain (GetServerInfo → apiVersion exact-match → WhoAmI) with the sealed failure-kind enum; one test row per kind + success side-effects + no-token-in-logs assertion. - T5.4 (coordinate with compass-ui — edits their zone) Wails
ShellIpcbinding +nativeConnectionProvider+shellConnect; BOTH Tauri deps removed (@tauri-apps/apiShellIpc swap +@tauri-apps/plugin-openerlink-opener → Wails browser-open); fake-runtime frame/cancel tests +token === undefinedprovider assertion. - T5.5 Connect-screen boot gate (
bootNativeClient): boot-drivenshellConnect("")probe,connecting+ four failure states, read-only URL, token-only input with submit-disabled-on-empty; stubbed-shell component tests incl. token-not-retained. - T5.6 Client-mode launch wiring: TLS target instead of UDS,
resolveStackBin/quit-controller moved embedded-only, boot-driven auto-connect (no pre-window probe),window.__COMPASS_MODE__injected at window creation (noMode()IPC getter); splitrunEmbedded/runClientdispatch tests. - T5.7 e2e gate script vs
compass-server --listen(token accepted / wrong token / version mismatch / restart-survives-keychain / no-secret-in-config-or-cmdline); manual QA + headless CI variant.
Open Questions
Section titled “Open Questions”OQ-1/OQ-5/OQ-7/OQ-8 resolved by Matt’s ruling before freeze (the genuine forks — a new third-party dep, a cross-lane zone edit, and two A-vs-B boot/identity choices with viable alternatives; OQ-8 was surfaced by the design review); OQ-2/OQ-3/OQ-4/OQ-6 by the adopted recommendation, each confirmed sound by the design red-team. Kept here as the decision record.
- OQ-1 — Keychain API under Wails v3. RESOLVED (Matt):
zalando/go-keyring. Wails v3 (beta,github.com/wailsapp/wails/v3 v3.0.0-beta.0,go/go.mod:29) ships no keychain service. The existingsecretspec-godep (go.mod:21, v0.15.0) does NOT fit: its Go SDK is resolve-only (New()→WithProvider/WithProfile→Load()/Report()), with noSet/Writeprimitive — writing a value into a provider is a CLI action, not an SDK one. This is grounded in-repo by the seal-side wrapper of that same SDK:go/internal/secrets/resolver.go:80-81documentsWithCLIas pinning “the secretspec CLI binary used for the write path”, andresolver.go’s resolve path uses onlyb.WithProvider/b.WithProfile/b.Load()(resolver.go:162-164), never aSet. And it is a SERVER-side manifest-driven resolve-a-declared-set surface (DL-026,internal/secrets), the wrong layer for a client-side single-token write. So T5 takes a direct keyring dep:zalando/go-keyring(pure-Go, no CGO), which wraps the same OS backends (Linux Secret Service via D-Bus, macOS Keychain) with theSet/Get/Deletewrite API the resolve-only SDK lacks. The URL-bound 0600 file fallback (OQ-4) covers keyring-less hosts per DL-109. (affects T5.2) - OQ-2 — Connect screen: new router route vs boot gate.
Recommendation: boot gate (a
bootConnection-sibling surface painted before store/QueryClient construction): the transport does not exist yet, so no route belowmain()can render (index.tsx:49-57— boot cannot proceed without the connection), and the boot-error surfaces already live there (boot.ts:48-53). A route would drag half-initialized store state into every screen. (affects T5.5) - OQ-3 — apiVersion policy beyond MVP. MVP is exact-match against
"compass.v1"(go/server/service.go:36-37). Recommendation: keep exact-match for T5 and defer any semver-floor/range policy until acompass.v2actually exists — the constant is a contract name, not a semver, so ordering semantics are undefined today. Revisit at the first contract rev. (affects T5.3) - OQ-4 — 0600 fallback path. Recommendation:
$XDG_STATE_HOME/compass/remote-token(else$HOME/.compass/remote-token) — the same state-dir resolution family the embedded stack already uses (main.go:64-66:$COMPASS_STATE_DIR, then$XDG_STATE_HOME/compass, then$HOME/.compass), and the same directory hygiene as the server’sadmin-token(0600 under the state dir,network_door.go:327-331). The file stores the{serverURL, token}pair (single JSON object) and is URL-bound, NOT last-writer-wins:Read(serverURL)returnsErrNotFoundon a URL mismatch so a re-pointedserver_urlnever replays remote A’s bearer to remote B (the F1 guard). Multiple concurrent remotes are still deferred: one file, one pair, matching the single-remote MVP. (affects T5.2) - OQ-5 — Wails JS runtime API + the cross-lane Tauri removal (compass-ui).
RESOLVED (Matt): T5.4 removes BOTH Tauri deps; coordinate with compass-ui.
Two coupled parts on
apps/ui(compass-ui’s zone). (a) JS surface: the in-tree binding is still Tauri (daemon-transport.ts:188) while the Go shell emits Wails runtime events (bridge_service.go:9-12) — use@wailsio/runtimeEvents.On/Call.ByNamedirectly (no generated bindings, keeps the seam hand-rolled). (b) The second Tauri dep,@tauri-apps/plugin-openerinMarkdownText.tsx:1(+package.json:18), which cannot open links under the Wails webview — swapped in the SAME task (T5.4) for the Wails browser-open call, so the native shell carries no Tauri dependency after T5.4. Both edits are in compass-ui’s zone (daemon-transport.ts,MarkdownText.tsx,package.json), so T5.4 must be coordinated with and ack’d by the compass-ui lane before it starts. (affects T5.4) - OQ-6 — Mid-session failure legibility. After connect, a revoked token
or server restart surfaces as
ErrorFrameprose (pump.go:56-58) — the connect-time kinds don’t apply. Recommendation: out of T5 scope (the gate only requires connect-time legibility); if needed later, extend the frame contract with acodefield as its own §A2-touching record. (affects nothing in T5; parked) - OQ-7 — Caller-id resolution at boot: uniform
resolveCallervs Go-side seeding. RESOLVED (Matt): uniformresolveCallerover the IPC fetch. After a successfulConnect, run the existingbootCaller(root, () => resolveCaller(clients.compass))path (index.tsx:64) in BOTH modes, tunneled over the IPC fetch with the shell-injected bearer. Costs one extra WhoAmI round-trip at boot; in return there is no second mode-conditional above the transport seam (§A1’s one sanctioned divergence is the provider choice; the parent forbids another above the fetch/IPC seam,compass-native-app/design.md:335-337), a token revoked betweenConnectand boot lands onbootCaller’s failure screen rather than a stale cached id, and the racy runtimeaccountIDwrite (F2) is eliminated along with theshellAccountIDbinding. The rejected alternative (seedbootCallerfrom a Go-sideshellAccountID()) saved the round-trip at the cost of that mode-branch, staleness window, and concurrency hazard. (affects T5.3 accountID handling, T5.4 binding surface, T5.5 boot path) - OQ-8 — Entry-point mode dispatch: how boot picks env-provider vs
native-client BEFORE it can call a Go getter. RESOLVED (Matt): a
shell-injected startup global.
index.tsx:32today unconditionally bootsenvConnectionProvider(provider.ts:36-44), and any Go-side getter is an IPC call only available inside the Wails shell — so the entry point needs a mode signal it can read synchronously, with NO IPC, before either boot path starts. The shell injects the mode as a startup global (window.__COMPASS_MODE__ = "embedded" | "client") at window creation (theapplication.WebviewWindowOptionsinrun(),main.go:142-147); the UI reads it synchronously at entry:"client"→bootNativeClient;"embedded"→ the embedded native provider; absent (browser dev build, no shell) → the unchangedbootConnection(envConnectionProvider)path. The mode difference stays confined to which provider boot installs (provider.ts:1-14). The rejected alternatives were a synchronous@wailsio/runtime-presence sniff (implicit — infers mode from runtime presence rather than an explicit signal) and a Vite build flag (splits the bundle by target and adds a build-config surface the shell must set correctly); the injected global is the most explicit shell→UI contract and needs no runtime inference. Consequence for T5.6: this REPLACES the previously-planned boundMode()IPC getter, whose only purpose was boot dispatch — the record carries the injected global, not the getter, so there is one source of truth for the mode. (affects the T5.6 launch wiring and the T5.4/T5.5 UI boot entry)
Ledger-impact
Section titled “Ledger-impact”None — with one PR-flagged dependency. This record is a pure refinement of
the parent’s frozen rows: DL-107 (the bearer-injection + CA-trust point lands
in the T5.1 target exactly as the row states), DL-109 (keychain-first store +
connect screen + no-config/argv, T5.2/T5.5), DL-110 (Wails v3 binding,
T5.4), DL-111 (WhoAmI subject, T5.3). No new decision class is introduced:
the failure-kind enum, the boot-gate placement, and the tokenstore package
shape are implementation structure under those rows, not new contracts.
Dependency to flag in the impl PR: OQ-1 resolved to zalando/go-keyring, a new
third-party Go-module dep — it IMPLEMENTS DL-109’s existing “keychain-first”
ruling (it is not a new decision and does not amend the ledger), but a new dep
warrants an explicit call-out at review. docs/designs/product/DECISIONS.md
stays untouched (single-writer rule; no new row owed).