Compass native: server URL entry from the UI
Linear: RIG-3102 (design); RIG-1746 (parent)
Refines: ui/compass-native-client-mode/design.md (the connect probe and its
failure kinds) and ui/compass-native-embedded-revival/design.md §A1 (the
app.toml contract). Supersedes DL-320 via DL-406 (Matt, 2026-10-05); see
§Ledger-impact.
Problem / Intent
Section titled “Problem / Intent”The native client learns its server only from
$XDG_CONFIG_HOME/compass/app.toml (appconfig.Load), so a beta user must
hand-write TOML before the app can reach a server. This record lets a
first-run user enter the server URL and an optional private CA in the app,
probe it with the bearer they already paste, and keep the result across
restarts, under the validation the file already has. app.toml stays the one
hand-editable source of truth; the bearer stays keychain-first with the 0600-file
fallback (DL-109).
Global Constraints
Section titled “Global Constraints”- Toolchain and gate. Go module
go/(go 1.26.0) and Bun forapps/ui, both from the devenv shell. Go:go test ./...ingo/; files gated(linux && gtk4) || darwinalso need-tags gtk4. TS:bun testinapps/ui.moon run :ciis the whole gate. - One validator.
appconfig.NormalizeServerURLis the only server-URL check, used by the file parse and byConnect. The UI only disables submit on an empty field. - Bearer storage stays DL-109’s: keychain-first, 0600-file fallback. Never in app.toml, the CA copy, argv, env, or a log. In the UI it lives in the input for one call, as today.
- app.toml is the one source of truth. No second store. Every write is
re-read through
appconfig.Parsebefore it is linked into place. The app writes only on a first-run choice, and the write fails if app.toml already exists, so a hand-written file is never rewritten. - One server per process. The server is chosen at most once per process, on first run, before any successful connect. Embedded never starts inside a process that opened without it (compass-native-app §A4: “switching modes is an edit-and-relaunch operation … never a runtime toggle”).
- Untrusted server text renders as text.
Message,ServerVersion, andAPIVersionkeep thetextContentrule in theconnectResultdoc (go/cmd/compass-app/bridge_service.go). - Build tags.
bridge_service.goand its tests areunix. Only Wails-importing code is(linux && gtk4) || darwin; new logic sits behind injected funcs so it is testable underunix. - Repo hygiene. No shims or dead branches; comments say why; no issue IDs in source (AGENTS.md § Hygiene). Public repo: no private-repo references.
Approach
Section titled “Approach”A1 — First run is a chooser
Section titled “A1 — First run is a chooser”Today an absent app.toml resolves to embedded (Load in
go/internal/appconfig/appconfig.go: “Absent file → embedded zero-config
onboarding default; not an error”), and the bring-up runs before any window
exists. A user who wants a server gets a local stack they did not ask for or,
without podman, exit 1 with no window (run in go/cmd/compass-app/main.go
returns the launch error before application.New).
Ruled (OQ-1 (a)): when Load reports no file and no override
(appconfig.ErrNoConfig), the window opens at once to two choices:
- Run Compass on this computer (primary). The shell runs the embedded
preflight in the window behind a pending line; on a Mac the first check can
take minutes because it may create the podman machine. On pass it writes
mode = "embedded"and tells the user to quit and reopen Compass (OQ-6). The next launch takes today’s embedded path unchanged. On failure the window shows the error and both choices again, and nothing is written. - Connect to a server. The connect form with an empty URL, an optional CA file, and the token (A3, A4).
History for the OQ-1 ruling: embedded-revival OQ-2 weighed exactly this
(“absent → a chooser screen (‘run locally / connect to a stack’)”) and
recommended a cheaper first-run confirm instead. The confirm never shipped:
DL-320 has no confirm clause and go/cmd/compass-app has none. So the chooser
replaces a confirm-free default. It also reverses the compass-native-app §A4
charter (“the single-user charter forbids a question screen on first run”),
which expected the reversal in this shape: “A first-run wizard … is a UI
nicety layered on this same config later, not an alternative mechanism”.
Unchanged: precedence is flag > env > file; --mode embedded and
--mode client behave as today; a present but invalid app.toml still fails
launch; graduation from embedded to client stays a config edit. Deleting
app.toml brings the chooser back but does not stop a lingering embedded stack
(DL-108 linger-by-default); “Quit and stop stack” first.
A2 — Persist by writing app.toml
Section titled “A2 — Persist by writing app.toml”appconfig.SaveClient writes mode = "client", the normalized server_url,
and ca_cert only when the user picked a CA file; SaveEmbedded writes
mode = "embedded". Each encodes with toml.NewEncoder after a comment
header saying the app wrote the file, then re-Parses the bytes. A picked CA
is written first to a new server-ca-<16 hex>.pem beside app.toml, created
with O_EXCL, so no existing trust anchor is ever replaced; ca_cert names
that copy (OQ-4), so a moved download cannot break a later launch. The TOML
goes to a same-directory temp file, is fsynced, and is linked to app.toml
with os.Link. The link fails if app.toml exists, so a file written
meanwhile by another process or by hand is never replaced; that failure
returns ErrConfigExists. The link is the commit point: after it the save has
succeeded, and a following directory fsync failure is logged, not returned.
Every exit removes the temp file, and every returned failure also removes the
new CA copy, so a returned error always means no app.toml was written. The
next launch reads the file through the unchanged parse path.
A3 — The URL reaches the running shell (OQ-2)
Section titled “A3 — The URL reaches the running shell (OQ-2)”runClient builds one TLS target that the pump and the service share
(runClient in go/cmd/compass-app/client.go: “The ONE-target invariant: the
pump and the service MUST share this single *bridge.Target instance”). In
setup there is no target yet, so Connect gains an optional server choice:
- Normalize the URL (failure kind
invalid-url). - Take the CA bytes the user picked in the native dialog; none means system
trust. Build a candidate with
bridge.NewTLSTarget(kindinvalid-caon its “CA PEM contained no usable certificate” error). - Run the existing probe (GetServerInfo → API version → WhoAmI) against the candidate.
- On success:
SaveClient, then store the token under the normalized URL, then install the candidate and a new pump on it as one atomic value. Saving first means the token is written only by the process that created app.toml, so a lost first-run race never touches another process’s token. A token-store failure after the save asks the user to reopen and enter the token again, which the configured connect screen already supports.
The webview never names a file. PickCACert reads the picked file in the
shell and returns an opaque ref, which is all Connect accepts.
Every window boots its own UI (index.tsx runs bootForMode per window),
and a restored window set can open several chooser windows. One first-run gate
covers both choices across all windows: one attempt at a time, and once a save
lands the choice is final for the process. Outside setup mode a server choice
is refused. After setup, plain token connects use the installed connection.
Normalization lives in appconfig, so the file gets it too. It trims space
and one trailing / and rejects any path, query, or fragment. The probe’s
client trims (NewCompassServiceClient in
go/gen/compass/v1/compassv1connect/compass.connect.go:
baseURL = strings.TrimRight(baseURL, "/")), but the pump concatenates
(Pump.Do in go/internal/bridge/pump.go: p.target.baseURL+call.Path), and
call.Path already carries the whole URL path (createDaemonFetch in
apps/ui/src/daemon-transport.ts: const path = url.pathname + url.search).
So https://h:8443/ sends //compass.v1… and https://h/p sends
/p/p/compass.v1… on every bridged RPC after a passing probe. Rejecting a
path tightens the file contract: such a file now fails launch with a clear
message, where before it launched and failed every call. A token stored under
a trailing-slash URL is not found after the upgrade, because the tokenstore
key is the URL; the user pastes it once.
The validator returns *appconfig.URLError. Error() keeps today’s text for
the file path; Reason is the sentence the UI shows.
A4 — UI
Section titled “A4 — UI”ShellMode gains "setup", and bootForMode routes it to a new bootSetup
screen with the two choices. “Connect to a server” opens the boot-native.ts
form in its setup entry:
- an editable URL field;
- “Choose CA certificate…” (native dialog through
PickCACert), and a CA row reading “System trust” or the picked file’s name, with “Use system trust” to clear it; - the token field, and copy for
invalid-urlandinvalid-ca.
On success the provider is built from the URL that connected. A configured
client keeps today’s read-only “Server:
Alternatives considered
Section titled “Alternatives considered”The settings-store and CA alternatives are under OQ-3 and OQ-4.
- Relaunch after a choice (OQ-2, OQ-6). Rejected. For client it probes
twice and reopens the window. For both it adds a detached child that escapes
the
spawnSynctools/compass-app-devruns the app under, and a direct exec of the bundle binary is a GUI relaunch path the app has never exercised. - Validate the URL in TypeScript too. Rejected: two validators drift, and the bound call is local IPC.
- A
--server-urlflag or env var. Rejected: still a hand step, and it does not persist.
T-1, T-2a, and T-4 do not depend on each other. T-2b needs T-1 and T-2a; T-3 needs T-2b; T-5 lands last. T-4 codes against the method names and JSON shapes fixed below.
T-1 — appconfig: no-config signal, one validator, the writer
Section titled “T-1 — appconfig: no-config signal, one validator, the writer”-
Do:
LoadreturnsErrNoConfigwhen the file is absent and the override is empty. With an override and no file it resolves as today (embedded→ embedded;clientfails inparseClient).Modekeeps its two values.- Replace
validateServerURLwithNormalizeServerURL: trim space,url.Parse, require https, a host, no userinfo, a path of""or/, no query, no fragment; return"https://" + u.Host.parseClientstores the result. - Each failure is a
*URLError. The four existing checks keep their exactError()text. The new one readsappconfig: server_url %q must not include a path, query, or fragment (e.g. https://host:8443). - Export
configPathasConfigPath. SaveClientandSaveEmbeddedper A2: directory 0700, files 0600, through a package-local create-exclusive helper (certgen.atomicWriteand tokenstore’sfileStore.writeAtomicrename over the target, so they are the wrong primitive here). An existingapp.tomlreturnsErrConfigExists.SaveClientrefuses acfg.Modeother thanModeClient. Any failure, including a failed re-Parse, leaves noapp.toml, no new CA copy, and no temp file.- Update
doc.go(“precedence is override > file > embedded-default”, “the zero-config onboarding default”).
-
Interfaces:
var ErrNoConfig = errors.New("appconfig: no app.toml and no mode override")var ErrConfigExists = errors.New("appconfig: app.toml already exists")type URLError struct {URL string // the raw inputReason string // UI sentence, e.g. "The server URL must use https."}func (e *URLError) Error() stringfunc Load(configHome, home, override string) (Config, error) // unchanged signaturefunc NormalizeServerURL(raw string) (string, error) // error is *URLErrorfunc ConfigPath(configHome, home string) (string, error)func SaveClient(path string, cfg Config, caPEM []byte) (Config, error) // returns what it wrotefunc SaveEmbedded(path string) error -
Test cycle (red first,
appconfig_test.go):NormalizeServerURL:" https://h:8443/ "→"https://h:8443".http://h,h:8443,/x,https://u:p@h,https://h/p,https://h/p/,https://h?x=1, andhttps://h#feach return a*URLError; the first four keep today’sError()text.- A file with
server_url = "https://h:8443/"loads as"https://h:8443"; one with"https://h/p"fails. - No file, no override →
errors.Is(err, ErrNoConfig). No file with"embedded"→ embedded. SaveClientwithcaPEM→Loadreturns client mode, the URL, andCACertnaming aserver-ca-*.pemin the dir that holds the bytes. WithoutcaPEM, noca_certand no CA file.SaveEmbedded→Loadreturns embedded.SaveClientwithhttp://hfails.- Onto an existing
app.tomlandserver-ca-*.pem, both savers returnErrConfigExistsand leave both byte-identical. - Every failure above leaves no temp file and no new CA file in the dir. An
injected directory-fsync failure after the link returns success, and
Loadreads the saved file.
T-2a — Shell: one connection value (behaviour-preserving)
Section titled “T-2a — Shell: one connection value (behaviour-preserving)”-
Do:
- Replace
bridgeService.pumpand.targetwithconn atomic.Pointer[connection].runloads it once per call; with none it emits oneframeKindErrorframe, “Not connected to a server”, and finishes. - Factor
Connect’s arm → GetServerInfo → API version → WhoAmI intoprobe, which arms the target, disarms on failure, leaves it armed on success, and stores nothing. PlainConnectkeeps today’s token lookup, store, messages, and fail-closed path when there is no target or tokenstore. shellStatereads the connection and a separatesetupPhasewithout a lock, so a New Window click never waits onconnectMu. An installed connection wins; with none it returns the phase.setupPhaseis never a connection, soCompassRPCstays on the no-connection error in"reopen".- Move the six
newBridgeServicecall sites to&connection{…}:runClient,launch’s embedded arm,bridge_service_test.go,bridge_service_connect_test.go(two sites), andmultiwindow_e2e_test.go.
- Replace
-
Interfaces:
type connection struct {mode string // appconfig mode string: "embedded" | "client"serverURL string // client onlytarget *bridge.Target // client only; nil in embeddedpump *bridge.Pump}func newBridgeService(conn *connection, events eventEmitter, tokens tokenstore.Store) *bridgeService// bridgeService gains phase atomic.Pointer[string]: "setup" until a// first-run choice ends, then "reopen". Unused on a configured service.func (s *bridgeService) setPhase(phase string)// shellState returns the startup globals for a new window: the installed// connection's (mode, serverURL), else (phase, "").func (s *bridgeService) shellState() (mode, serverURL string)// ShellState is the bound form, so a window can re-check after it// subscribes to setup:decided.func (s *bridgeService) ShellState() shellStateResult // {mode, serverUrl}// probe runs the connect probe against target with token. It does not// store the token.func (s *bridgeService) probe(ctx context.Context, target *bridge.Target, token string) connectResult -
Test cycle: the existing
bridge_service_test.goandbridge_service_connect_test.goassertions pass unchanged (they are the regression net). New: a service with no connection emits exactly one error frame forCompassRPC, including aftersetPhase("reopen"), andshellStatereturns("reopen", "")then.
T-2b — Shell: Connect with a server choice
Section titled “T-2b — Shell: Connect with a server choice”-
Do:
firstRunGate:beginfails with “Another window is setting up Compass.” while an attempt runs, and with “Compass is already set up. Quit and reopen it to change this.” after a decision.end(false)releases it.decide(g, svc, clientInstalled bool)is the one terminal step for every path that ends first run: it makes the gate final, then, unlessclientInstalled, callssvc.setPhase("reopen"), then emitssetup:decided. State is set before the event, so a window that readsShellStateafter it subscribes cannot miss the decision.caPicks: the shell-side store of picked CA bytes, keyed by a 128-bit random hex ref.getleaves the entry, so a failed probe can be retried with the same pick;cleardrops every entry after a save.newSetupBridgeServicebuilds the setup-mode service: no connection, a tokenstore, and the setup wiring.newBridgeServiceservices have no setup wiring.ConnectwithServer != nil:- no setup wiring → kind
other, “The server is set in app.toml.”, nothing built; gate.begin(); its error → kindother. Every later failure callsend(false);NormalizeServerURL→invalid-url, messageURLError.Reason;- non-empty
CARef: unknown →invalid-ca, “Choose the certificate again.”;NewTLSTargeterror →invalid-ca, “The file is not a PEM certificate.”; - an empty token reads the stored token for the normalized URL, as plain
Connectdoes; probethe candidate; failure → its kind;SaveClient(configPath, Config{Mode: ModeClient, ServerURL: url}, caPEM), then store the token. The token is written only after this process ownsapp.toml, so a lost race never touches another process’s token.ErrConfigExistsdisarms the candidate, callsdecide(g, svc, false), and returns kindotherwith the “already set up” message. Any otherSaveClientfailure disarms, callsend(false), and returns kindother(“Connected, but the settings could not be saved:”). A token-store failure after the save disarms the candidate, calls picks.clear()anddecide(g, svc, false), installs nothing, and returns kindother(“Saved, but could not store the token. Quit and reopen Compass, then enter it again.”);- install
&connection{mode: "client", serverURL: url, target: candidate, pump: bridge.NewPump(candidate)}, thenpicks.clear()anddecide(g, svc, true). Other open setup windows readShellStateon the event and boot as the configured client.
- no setup wiring → kind
- Every successful
connectResultcarriesserverUrl.
-
Interfaces:
type firstRunGate struct {mu sync.Mutexbusy, decided bool}func (g *firstRunGate) begin() errorfunc (g *firstRunGate) end(saved bool)// decide makes the gate final, sets "reopen" unless clientInstalled, then// emits setup:decided.func decide(g *firstRunGate, svc *bridgeService, clientInstalled bool)type caPicks struct {mu sync.MutexbyRef map[string][]byte}func (p *caPicks) add(pem []byte) stringfunc (p *caPicks) get(ref string) ([]byte, bool)func (p *caPicks) clear()type setupWiring struct {configPath string // appconfig.ConfigPath resultgate *firstRunGatepicks *caPickssaveClient func(path string, cfg appconfig.Config, caPEM []byte) (appconfig.Config, error) // appconfig.SaveClient; a seam for tests}func newSetupBridgeService(events eventEmitter, tokens tokenstore.Store, setup *setupWiring) *bridgeServicetype serverChoice struct {URL string `json:"url"`CARef string `json:"caRef"` // "" = system trust; else a PickCACert ref}type connectRequest struct {Token string `json:"token"`Server *serverChoice `json:"server,omitempty"`}// connectResult gains: ServerURL string `json:"serverUrl"`const (connectKindInvalidURL = "invalid-url"connectKindInvalidCA = "invalid-ca") -
Test cycle (
bridge_service_connect_test.go, the httptest TLS server, its CA added throughpicks.add, at.TempDir()config path):- Success →
ok,serverUrlnormalized;appconfig.Loadreturns client mode, that URL, andCACertnaming the copy; the token is stored under the URL; a followingCompassRPCreaches the server. - No
app.tomland no install after:http://…→invalid-url; an unknown ref and a non-PEM pick →invalid-ca; a wrong token →bad-token; no CA →bad-cert. - A wrong token with a CA ref, then the right token with the same ref →
ok. - After a success, a different URL →
otherandapp.tomlbyte-identical; a plain tokenConnect→ok. - A server choice on a
newBridgeServiceclient service →other, nothing written. - Gate: a second
beginwhile busy fails;end(false)reopens;decideis final. - An injected
saveClienterror: kindother, no connection installed, no token written, the gate open after; a following valid attempt →ok. - An injected token-store failure after a good save: kind
other, no connection installed, the candidate disarmed,picksempty,shellState→("reopen", ""), and onesetup:decidedevent. - An
app.toml(and token) created after the chooser opened → the “already set up” message, both byte-identical,shellState→("reopen", ""), and onesetup:decidedevent. - A successful client setup emits one
setup:decided, andShellStatethen returns("client", url). - Under
-race:CompassRPCconcurrent with a successful setupConnect.
- Success →
T-3 — Shell: setup launch, live window globals, embedded choice, CA dialog
Section titled “T-3 — Shell: setup launch, live window globals, embedded choice, CA dialog”-
Do:
- In
run,errors.Is(err, appconfig.ErrNoConfig)takes the setup arm:ConfigPath,tokenstore.New(stateDir),newSetupBridgeService,setupService, anddialogServicesharing one gate and onecaPicks. It resolves no compass-stack binary and builds no quit controller. Any otherLoaderror aborts as today.setupServiceanddialogServiceare bound only in setup.dialogService.appis assigned afterapplication.New, assvc.events = app.Eventis. newAppWindowcomputesshellStartupJS(svc.shellState())per window; the launch-timestartupJSlocal goes.shellStartupJSis unchanged:"setup"and"reopen"inject no URL global.setupService.ChooseEmbedded:gate.begin()→ preflight → on failureend(false)and return its message →SaveEmbedded. On success,decide(gate, svc, false)and returnokwith the embedded reopen text. OnErrConfigExists, the samedecide(gate, svc, false)and the neutral “already set up” message, which names no mode. Any other error →end(false). No relaunch and no quit.main.gowires preflight asrealPreflight(image)underbringUpTimeout, which is already sized for darwin machine creation.dialogService.PickCACert:app.Dialog.OpenFile().SetTitle("Choose CA certificate").AddFilter("Certificates", "*.pem;*.crt").PromptForSingleSelection(). Cancel returns the zeropickedCA. Otherwise it reads the file, callspicks.add, and returns the ref and base name. A read error is returned.- Fix the
--modehelp (“then app.toml, then embedded” → “then app.toml, else the first-run chooser”), theresolveModedoc, andclient.go’s stale package doc (“retired in RIG-2554”).
- In
-
Interfaces:
// setup_service.go, //go:build unixtype setupResult struct {OK bool `json:"ok"`Message string `json:"message"` // rendered via textContent}type setupService struct {gate *firstRunGatesvc *bridgeService // for decidepreflight func(ctx context.Context) errorsave func() error // appconfig.SaveEmbedded(path)}func (s *setupService) ChooseEmbedded(ctx context.Context) setupResult// dialog_service.go, //go:build (linux && gtk4) || darwintype pickedCA struct {Ref string `json:"ref"` // "" on cancelName string `json:"name"` // base name, for display}type dialogService struct {app *application.Apppicks *caPicks}func (d *dialogService) PickCACert(ctx context.Context) (pickedCA, error)func newAppWindow(app *application.App, svc *bridgeService, name, title string) -
Test cycle:
setup_service_test.gowith fakes:- preflight fails → no save, and the gate is open after;
- preflight passes → one save,
ok,shellState→("reopen", ""), and onesetup:decidedevent; - save fails → the message, and the gate is open after;
- save returns
ErrConfigExists→ the “already set up” message (no mode named),shellState→("reopen", ""), onesetup:decidedevent, and the gate final; - gate held by an in-flight connect → refused without running preflight;
- after a save → refused.
In
client_test.go,shellStartupJS("setup", "")andshellStartupJS("reopen", "")yield the mode global and no URL global. The dialog is covered by T-5’s smoke run.
T-4 — UI: setup screen and first-run connect form
Section titled “T-4 — UI: setup screen and first-run connect form”-
Do:
shell-globals.ts:"setup"and"reopen"joinShellMode.daemon-transport.ts, still the only@wailsio/runtimeimporter:ConnectResultgainsserverUrland the two kinds;shellConnectsends{token}or{token, server};- add
pickCACert(main.dialogService.PickCACert),chooseEmbedded(main.setupService.ChooseEmbedded),shellState(main.bridgeService.ShellState), andquitApp(Application.Quit).
- New
boot-setup.ts, with the two choices:- Embedded shows “Checking this computer… The first check on a Mac can take several minutes.” Success shows “Compass is set up to run on this computer. Quit and reopen it to start.” with a Quit button. Any other result shows its message; when the gate is final that message is the neutral “already set up” text, otherwise both choices return.
- The window subscribes to
setup:decided, then callsshellStateonce, and again on each event."client"hands off todeps.bootNativeClient(root, "configured"), which reuses the installed connection;"reopen"shows the neutral reopen screen (“Compass is already set up. Quit and reopen it to change this.” and Quit). Reading after subscribing means a decision made before the subscription is still seen. - The listener stays active across this window’s failed or refused
attempts. It is dropped only when this window reaches a terminal result
of its own (the embedded success text, or a successful connect), so its
own event cannot cause a second transition. “In flight” means one shell
call (
chooseEmbeddedor oneshellConnect), not the open form: an event during that call is handled when the call returns non-terminal, and an event while the form is idle is handled at once. Handling it aborts the setup connect form through thesignalthatbootSetuppasses tobootNativeClient(which then resolvesundefined), and applies the new state. - Connect hands off to
deps.bootNativeClient(root, "setup", signal). The default deps wrapbootNativeClient(root, undefined, entry, signal).
boot-native.tssetup entry: no auto-probe; the form from A4; submit disabled while the URL is empty; it always sendsserver;invalid-urlandinvalid-cashowmessageviatextContent; onokthe provider usesresult.serverUrl. The configured entry is unchanged.boot-mode.ts:BootModeDepsgainsbootSetup;bootForModeroutes"setup"to it and"reopen"to the neutral reopen screen.
-
Interfaces:
export type ShellMode = "embedded" | "client" | "setup" | "reopen";export type ServerChoice = { url: string; caRef: string };export type PickedCA = { ref: string; name: string };export type SetupResult = { ok: boolean; message: string };export function shellConnect(token: string, server?: ServerChoice): Promise<ConnectResult>;export function pickCACert(): Promise<PickedCA>;export function chooseEmbedded(): Promise<SetupResult>;export function quitApp(): Promise<void>;export type NativeBootDeps = {shellConnect: (token: string, server?: ServerChoice) => Promise<ConnectResult>;pickCACert: () => Promise<PickedCA>;nativeConnectionProvider: (baseUrl: string) => ConnectionProvider;};export async function bootNativeClient(root: HTMLElement,deps?: NativeBootDeps,entry?: "configured" | "setup", // default "configured"signal?: AbortSignal, // abort clears root and resolves undefined): Promise<ResolvedConnection | undefined>;export type SetupBootDeps = {chooseEmbedded: () => Promise<SetupResult>;shellState: () => Promise<{ mode: ShellMode; serverUrl: string }>;onSetupDecided: (fn: () => void) => () => void; // returns unsubscribequitApp: () => Promise<void>;bootNativeClient: (root: HTMLElement, entry: "setup" | "configured", signal?: AbortSignal) => Promise<ResolvedConnection | undefined>;};export async function bootSetup(root: HTMLElement,deps?: SetupBootDeps,): Promise<ResolvedConnection | undefined>; -
Test cycle (Bun, injected fakes):
boot-native.test.ts:- the setup entry makes no probe call and sends
{url, caRef: ""}; - a picked CA sends its ref and the row shows its name, and “Use system trust” clears it;
- an
invalid-urlmessage with<b>renders literally; - the provider gets
serverUrl; - the existing configured-entry tests stay green.
- the setup entry makes no probe call and sends
- New
boot-setup.test.ts:- an embedded failure re-renders both choices with the message;
- success shows the embedded reopen text, and Quit calls
quitApp; - a
setup:decidedevent withshellState→"reopen"shows the neutral reopen screen; with"client"it callsbootNativeClientwith"configured"; - a decision made before the subscription (the first
shellStatealready returns"reopen") shows the reopen screen with no event; - the window that chose embedded keeps its embedded success text when its
own
setup:decidedarrives, and the window that connected boots once; - a window whose attempt failed or was refused keeps listening: when another window then decides, it transitions exactly once;
- a sibling decision while the connect form is open and idle aborts the
pending
bootNativeClientand transitions with no local connect; - connect hands off with
"setup"and a signal.
boot-mode:"setup"routes tobootSetup;"reopen"renders the neutral reopen screen.
T-5 — Docs, examples, smoke run
Section titled “T-5 — Docs, examples, smoke run”-
Do: update every place that says an absent file means embedded, or that the URL is only hand-edited:
app-bundle/SMOKE.md§3, its checklist, and the client section;docs/onboarding.md;docs/specs/runtime/runner-tiers.md, which quotes DL-320’s “absent → embedded”;tools/compass-app-dev/app.toml.exampleand its README (“REQUIRED”, “there is no env var or CLI flag”, the stale “Embedded mode was retired” comment). Add thatserver_urlmust be the origin only, and that the app writes the file on first run;go/e2e/client_mode_test.gowrites its app.toml throughappconfig.SaveClientinstead of a literal (its NOTE asks for exactly this).
SMOKE.md also notes two things: a token stored under a trailing-slash URL must be pasted once more, and deleting app.toml does not stop a lingering embedded stack.
-
Interfaces: none.
-
Test cycle: a smoke run on a Linux GTK4 build with an empty config home:
- connect with the stack’s CA, then quit;
- reopen: it auto-connects, and app.toml holds the origin URL and a
ca_certnaming aserver-ca-*.pemcopy; - delete app.toml and choose embedded: the reopen message appears, and reopening runs the embedded bring-up;
http://xandhttps://h/peach show their message and write nothing;- open a second chooser window: after the first connects, the second’s embedded choice is refused.
- T-1 — appconfig:
ErrNoConfig,NormalizeServerURL+URLError,ConfigPath,SaveClient/SaveEmbedded,doc.go - T-2a — shell:
connectionvalue,probefactored out,shellState; no behaviour change - T-2b — shell:
Connectserver choice,firstRunGate,caPicks, new failure kinds - T-3 — shell: setup launch arm, per-window startup globals,
ChooseEmbedded,PickCACert, doc fixes - T-4 — UI:
"setup"mode,bootSetup, first-run connect form - T-5 — SMOKE.md, onboarding, runner-tiers spec, dev example + README, e2e writer; smoke run
Decisions (ruled)
Section titled “Decisions (ruled)”Matt ruled on 2026-10-05 (RIG-4480): OQ-1 (a) and OQ-6 (d), and OQ-2, OQ-3 and OQ-4 adopt the recommended option (a). OQ-5 (an editable URL on a configured client) is deferred: it is outside RIG-3102’s acceptance, it would rewrite hand-written files, and it needs a keep / system / file CA choice.
OQ-1 — Where the URL entry appears (conflicts with DL-320) — load-bearing
Section titled “OQ-1 — Where the URL entry appears (conflicts with DL-320) — load-bearing”DL-320: “absent → embedded” and “Graduation embedded→client is a config edit … not an in-app flow”. The acceptance case, “a first-run user with no app.toml can enter a URL in the UI”, cannot pass while absent means embedded.
- (a) A first-run chooser on absent app.toml (A1). Embedded stays the primary choice and graduation stays a config edit. It supersedes DL-320, reverses the compass-native-app §A4 charter, and reopens the chooser embedded-revival OQ-2 recommended against.
- (b) Keep absent → embedded and prompt only when
mode = "client"lacksserver_url. No ledger change, but the user still hand-writes TOML to reach the prompt, so it fails acceptance. - (c) An in-app “connect to a server” switch inside running embedded. It contradicts DL-320’s graduation clause, and a client-only user must pass the embedded preflight first.
Ruled: (a) (Matt, 2026-10-05), the only option that meets acceptance without making client users pass through embedded.
OQ-2 — How the TLS target gets a URL that arrives after startup
Section titled “OQ-2 — How the TLS target gets a URL that arrives after startup”- (a) Build a candidate in
Connectand install it after a good probe, first run only (A3). One probe, no window churn, no in-flight streams to drain. - (b) Save, then relaunch. The probe runs twice (or the URL is saved unverified), and it brings the relaunch costs under Alternatives.
- (c) A hot swap at any time. It must drain streams and rebuild the UI’s provider; that is a separate design.
Ruled: (a).
OQ-3 — Where the choice persists
Section titled “OQ-3 — Where the choice persists”- (a) Write back to app.toml (A2). One documented source of truth. The app writes only on first run, so no hand-written file is rewritten.
- (b) A UI-owned store in the state dir. Two sources and a precedence rule; a stale entry silently wins.
- (c) Both, with the store used when app.toml is absent. The same precedence problem, and deleting app.toml no longer resets.
Ruled: (a).
OQ-4 — The private CA from the UI
Section titled “OQ-4 — The private CA from the UI”- (a) Pick a PEM file and copy it to
server-ca.pembeside app.toml. - (b) Point
ca_certat the picked path. No copy, but it breaks when the file moves. - (c) No CA in the UI. System trust only; fails for every self-minted stack.
- (d) Trust on first use with a fingerprint check. A trust-model design of its own.
Ruled: (a); (d) deferred.
OQ-6 — How the embedded choice starts the stack — load-bearing
Section titled “OQ-6 — How the embedded choice starts the stack — load-bearing”- (a) In-window preflight, write
mode = "embedded", relaunch. It adds the relaunch costs under Alternatives. After a click that closes the window, the child runs the cold three-image pull with no window, so it looks like a crash. - (b) Write and relaunch with no preflight. A failure becomes a silent
exit 1, and the chooser cannot return because app.toml now exists. - (c) In-window preflight, then bring the stack up in the same process and
install a Unix-socket connection (T-2a’s value). No relaunch, and the
cold pull runs behind a visible “Starting Compass…” line. It costs a
run-time “Quit and stop stack” menu item, moving
accountIDinto the connection value, a hand-off frombootSetuptobootConnection, and a retry path over a half-started lingering stack. It also bends Constraint 5. - (d) In-window preflight, write
mode = "embedded", ask the user to quit and reopen (A1). No relaunch, no detached child, nospawnSynccaveat, and no new process code. The next launch takes today’s embedded path untouched. Cost: one extra click, and the reopened app’s cold pull is today’s pre-window wait.
Ruled: (d) (Matt, 2026-10-05). It meets the need with the least new code and no new
failure modes. (c) is the better long-term experience, but it belongs with the
provisioning-state UI that the bringUpTimeout doc in main.go says is “not
built yet”, which would fix the same wait for every embedded launch, not only
the first. Under every option, recovering from a present but invalid app.toml
in the app stays out of scope.
Ledger-impact
Section titled “Ledger-impact”This PR adds DL-406 to DECISIONS.md (claimed with
bun tools/dl-claim --ref RIG-3102); its Record cell links §A1. It flips
DL-320 to Superseded by DL-406 (Matt, 2026-10-05), with its Decision cell
unchanged. DL-109 stays Active and unedited; the partial override lives in
DL-406’s text, as DL-319 does for DL-259.