Compass distribution & install surface
Status: Draft Linear: RIG-2477 (client app per-OS matrix + install channels), RIG-2608 (headless self-host stack distribution). Scope expansion of RIG-1746 ruled by Matt 2026-08-24.
Composes with, never duplicates:
../compass-release-bundling.md(PR #460, the binary/image Release lane — per-buildbuild-<sha12>prereleases + the reservedv*semver lane on compass GHA, per-arch binary assets, GHCR image by digest, nix-outputs manifest). This record CONSUMES that lane as its publishing rail and decides what #460 deliberately deferred: its OQ-1 ruling (“the desktop lane (compass-app, compass-stack) is DEFERRED … decided in the native-packaging lane”, filed as RIG-2477) is resolved HERE. (That pointer names the native-packaging lane as the deciding record; the client-only pivot DL-238 re-homed app distribution into THIS record, so the deferral chain is repaired via DL-238 — the decision lands here, not incompass-native-packaging/design.md.) Where this record extends a #460 decision (the release asset-set boundary, Fork 2(i)), it says so explicitly.
Problem / Intent
Section titled “Problem / Intent”Compass has a release lane (#460) but no install surface: the thin client app
(compass-app, post client-only pivot DL-235/DL-238) has no per-OS artifacts
and no install channel beyond a Linux tarball built locally, and the headless
self-host stack (compass-stack + compass-server + compass-runner +
postgres) has no distribution home at all — the client-only record’s OQ-3
recorded exactly this orphan (“compass-stack/compass-postgres have no
release-artifact home today”,
../../product/compass-native-client-only/design.md:447-462). This record
designs both surfaces: the client’s full OS matrix (Linux AND macOS together)
with the install channels users actually reach for (homebrew, nix flakes, a
tarball), and the self-hoster’s host-level KVM-stack bring-up with postgres
provisioned as a dedicated container image out of the box.
Decisions encoded (Matt’s rulings — not open questions)
Section titled “Decisions encoded (Matt’s rulings — not open questions)”Two rulings from Matt (2026-08-24) are the frozen premises of this record; nothing below re-litigates them:
- Client app (RIG-2477): full per-OS matrix + install channels. The client ships on Linux AND macOS together (not Linux-first), and beyond a tarball it ships through OS install channels — nix flakes and homebrew (especially for macOS). This is an app-distribution surface, not a bare artifact attach.
- Self-host stack (RIG-2608): host-level KVM stack + postgres-as-container.
The headless stack STAYS a host-level bring-up on a KVM-capable machine —
it cannot ship as a plain compose/Swarm config droppable on any host,
because the microVM runtime hard-requires
/dev/kvm(../compass-elastic-session-runtime/microvm-runner.md:230-236, quoted in Global Constraint 1). Postgres moves OUT of the stack’s host-process tree: the simple path runs a dedicated postgres container image out of the box with zero thought; a user can always point the stack at their own Postgres instead. This SUPERSEDES DL-217’s bundle answer (alreadySuperseded by DL-238) AND the client-only record’s interim OQ-3 recommendation of “postgres tooling = host prerequisite on a dedicated machine” (compass-native-client-only/design.md:456-460).
Global Constraints
Section titled “Global Constraints”- The KVM floor is consumed, not designed. The stack host MUST expose
/dev/kvmto the Runner uid; the runtime hard-fails without it —../compass-elastic-session-runtime/microvm-runner.md:230-233: “KVM-absent ⇒ hard-fail (D3): with no container fallback,/dev/kvmabsence (or any preflight failure) aborts Runner startup with an error naming the missing capability”. Consequence: every stack install channel targets Linux x86_64 with KVM; there is no macOS or no-KVM stack story, ever, in this record. - The publishing rail is #460’s Release lane. All downloadable artifacts
attach to the
build-<sha12>prerelease /v*semver Releases onRigelBuild/compassGHA (../compass-release-bundling.mdForks 1/3); this record adds assets and channels, never a second Release mechanism. Its posture rules inherit: no PR-event trigger on any workflow holdingcontents: write; immutable identities first, moving pointers last;v*tag creation enforced by ruleset + ancestry guard. - Module path + toolchain. One Go module
github.com/RigelBuild/compass/go(go/go.mod:13), Go pinned viatools/toolchain/versions/go.nix; builds use the pinned toolchain, never asetup-godrift (#460 Global Constraint 6). - One version stamp across every stack binary in a bundle/release. The
attach check hard-fails on mismatch —
go/cmd/compass-stack/main.go:35-38: “It feeds Deps.ExpectedVersion, so the attach mismatch check compares a live server’s version against this build’s”. Every channel MUST install stack binaries from the SAME release tag. - macOS does not cross-compile from an ubuntu runner. The app on macOS
links the system WebKit framework —
devenv.nix:229-231: “on macOS the app links the system WebKit framework, so the closure is Linux’s alone” — so the macOS lane runs on a macOS arm64 runner (GHAmacos-14/macos-15) with cgo against system frameworks. - macOS artifacts are signed + notarized before any public channel carries
them. Gatekeeper quarantines an unsigned downloaded
.app/dmg; homebrew-cask installs of unsigned apps are a broken first-run. Signing = Developer ID Application certificate +codesign --options runtime; notarization =xcrun notarytool submit+ staple. Secrets (cert p12 + App Store Connect API key) live as GHA repo secrets on the release workflow only (never a PR-triggered workflow, per Constraint 2). - The postgres major is pinned and matches the repo’s parity pin. The
dev shell pins bare
postgresql“for strict parity” (devenv.nix:99-113, postgresql 18.x at the current lock); the postgres container image pins the same major so the private cluster’s on-disk format never skews between a dev-box stack and an installed stack. - Rootless podman is the stack host’s container runtime for the postgres
container. The stack host already speaks podman (the agent image is
podman pulled, DL-112) and the repo’s container tooling is podman throughout; the postgres container reuses it — no docker, no compose file as the product interface. - Thin-client bundle content is frozen by DL-238:
compass-app+dist.desktop+ LICENSE on Linux (app-bundle/build.sh:2-5: “the thin CLIENT bundle: the gtk3 shell (compass-app) + the UI dist + the desktop file + LICENSE. No sidecar binaries, no postgres tooling”); the macOS bundle is the.appequivalent (binary + dist inside the app bundle). This record distributes that content; it does not reopen its shape.
- Scripts with real logic are TypeScript, not bash
(
rule://scripts-ts-over-bash); nix + build orchestration glue may stay bash per thebuild.sh:7-10precedent.
Approach
Section titled “Approach”Two surfaces, one publishing rail.
S1 — Client app: per-OS matrix
Section titled “S1 — Client app: per-OS matrix”Linux (x86_64). The artifact exists: the thin client tarball
compass-app-<version>-linux-amd64.tar.gz, store-rpathed against the
devenv.lock-pinned GTK closure (DL-214 stays Active for it;
app-bundle/build.sh:54-60 builds it with the pinned cc + PKG_CONFIG_PATH).
S1 attaches it to the #460 Releases: the release workflow gains an
app-bundle build step (or invokes app-bundle/build.sh) and uploads the
tarball + its checksum. This extends #460 Fork 2(i)’s asset boundary —
that record excluded compass-app from v1 “to keep the asset set meaning
something” and deferred it to exactly this record (its OQ-1 ruling). The
honest limit DL-214 named stays: a box with no nix store cannot run the
tarball’s rpathed binary — which is precisely why the channels below (nix
flake first) matter more than the raw asset on Linux.
macOS (arm64). The new target. Three facts shape it:
- The shell today is gated
//go:build unix && gtk3(go/cmd/compass-app/main.go:1) and the non-gtk3 entrypoint is an exit-1 stub (main_nogtk3.go:24-28); GTK3 is “the only Linux stack available in this repo’s frozen toolchain” (main_nogtk3.go:5-7). A darwin build therefore needs a build-tag restructure: the Wails v3 shell compiled on darwin without the gtk3 tag (Wails selects the system WebKit backend on darwin), i.e. the gate becomes(linux && gtk3) || darwinon the real entrypoint, with the stub covering the remaining untagged-linux module build. The client-only pivot already removed every Linux-only runtime dependency from the app (the runner was the Linux-only piece,compass-native-client-only/design.md:216-220: “the Linux-only-ness was the runner’s … and the runner no longer ships in the app”), so this is a build-surface change, not a feature port. - No cross-compile (Global Constraint 5): a
macos-14(arm64) GHA job in the release workflow builds the.appbundle —Compass.app/Contents/MacOS/ compass-app+Contents/Resources/dist/(the same beside-the-executable dist resolution the shell already does,main.go:313-321per the packaging record) +Info.plist— then signs, notarizes, staples, and wraps it in a.dmg(the homebrew-cask-native format). - Signing is a hard gate for the public channels (Global Constraint 6); the lane lands in two steps — unsigned ad-hoc build proving the runner + bundle (T3), then signing/notarization (T4) — so the mac build isn’t blocked on Apple-account provisioning.
Arch matrix: linux-amd64 + darwin-arm64 only, matching #460’s darwin
choice for the CLI (“shipping darwin daemon builds would assert a support
surface nothing consumes” — same logic here for darwin-amd64; OQ-4).
S2 — Client app: install channels
Section titled “S2 — Client app: install channels”Three channels in priority order; each consumes the SAME release assets (never rebuilds from divergent sources):
- Homebrew (macOS-first, the ruling’s emphasis). A
RigelBuild/homebrew-taprepo carrying:- a cask
compassinstalling the notarized.dmg(macOS app), and - a formula
compass-cliinstalling the per-arch CLI binaries #460 already attaches (compass_<tag>_darwin-arm64/linux-amd64) — sobrew install rigelbuild/tap/compass-cliworks on macOS AND Linux with zero new build machinery. The semver (v*) release workflow bumps the tap automatically (a small step templating version + sha256 into the cask/formula and pushing to the tap repo with a tap-scoped fine-grained PAT — the one new standing write credential to an external repo this record adds, scoped toRigelBuild/homebrew-tapcontents only; the Apple signing secrets Constraint 6 / T4 add are read-only material consumed on the release workflow, not push credentials). Per-build prereleases do NOT bump the tap: brew users track semver.
- a cask
- Nix flake. The compass repo gains a
flake.nix(it has none today — the repo is devenv-based,devenv.yaml:1-9) exposing packages:compass(CLI),compass-app(Linux gtk3 client, reusingtools/toolchain/gtk-closure.nix— the same 13-package set the bundle links against), and the stack set (S3).nix profile install github:RigelBuild/compass#compass-app(or a NixOS/home-manager module consuming the flake) is the first-class Linux channel — it sidesteps DL-214’s no-nix-store limit entirely, because the flake install IS the nix store. The flake pins nixpkgs to the devenv.lock revision (thegtk-e2e-env.nix:9-13precedent: “Pins nixpkgs to the SAME devenv.lock revision the dev shell and gate-tools.nix resolve”) so flake-built and bundle-built binaries link one closure. - Tarball / dmg direct download. The raw release assets (S1) remain the channel of last resort and the substrate the other two wrap.
Native Linux distro packages (deb/rpm/AUR) are explicitly deferred (OQ-1): flake + brew-on-Linux + tarball cover the Linux install matrix without taking on per-distro packaging debt.
S3 — Self-host stack: host-level KVM bring-up
Section titled “S3 — Self-host stack: host-level KVM bring-up”The stack stays exactly what the client-only record kept it as: “the
standalone headless single-user bring-up CLI”
(compass-native-client-only/design.md:112-119), running host-level because
the Runner opens /dev/kvm and drives cloud-hypervisor/virtiofsd/passt as
ordinary user processes (devenv.nix:183-199: “All three are ordinary user
binaries that open(2) /dev/kvm but need no capability or device node of their
own; the host-level /dev/kvm enablement is a separate, out-of-repo concern”).
A Docker-Swarm/compose packaging of the stack is rejected structurally: it
would either demand privileged /dev/kvm device passthrough into a container
(a worse posture than host processes) or silently lose the microVM boundary —
and D3 forbids degrading (Global Constraint 1).
Distribution, three legs:
- Release binaries.
compass-stackjoins the #460 binary asset set (linux-amd64), extending Fork 2(i)’s boundary the same way S1 does for the app: the v1 exclusion ofcompass-stackwas the OQ-1 deferral this record resolves.compass-server/compass-runnerare already attached.compass-postgres(the Go wrapper) is NOT attached as a host binary — it moves into the postgres container image (S4), which is its only shipped home; the host-level LookPath spawn remains only for the dev-box/devenv path. - Nix flake (the recommended channel). The same
flake.nix(S2 leg 2) exposescompass-stack,compass-server,compass-runner, and acompass-stack-envconvenience (the microVM userspace trio — cloud-hypervisor, virtiofsd, passt — at the devenv.lock pin, which the Runner preflight version-floors:devenv.nix:196-199: “the shell provides one pinned version from devenv.lock; the runtime preflight (V2a+) will enforce the floor”). A NixOS module (services.compass-stack) wrappingcompass-stack upin a systemd unit is the polished endgame for this leg (OQ-3 sizes it). - Bring-up UX.
compass-stack upstays the one entry point; this record adds (a) a host preflight surfacing the KVM/podman prerequisites at install-time rather than first-up(its own minimal checks — no Runner preflight function exists to reuse today; T9 carries the dependency honesty), and (b) an operator doc (docs/self-host.md) covering the two supported shapes — dedicated KVM machine, or one-box localhost-TLS (sanctioned by the client-only record’s OQ-6 ruling).
S4 — Postgres-as-container (the DL-217 supersession)
Section titled “S4 — Postgres-as-container (the DL-217 supersession)”Decision (Matt’s ruling 2): the simple path provisions postgres as a dedicated container image the stack runs out of the box; a user-supplied DSN opts out entirely.
Mechanics, grounded in the current seams:
- The image.
ghcr.io/rigelbuild/compass-postgres: pinned-major postgres (Global Constraint 7) + thecompass-postgreswrapper as entrypoint. The wrapper is already the whole private-cluster brain — initdb-if-needed, socket-only listen, trust-auth local socket, createdb, signal-forwarding parent (go/cmd/compass-postgres/main.go:3-22: “this wrapper’s whole job is to bring up a Postgres that answers on exactly that socket, then hand its lifetime to the supervisor”) — and it locates its tools by LookPath (main.go:187,282,317), so inside the image it finds the image’s postgres with zero code change. Published by apublish-postgres-imagelane cloned from thepublish-agent-image.ymlpattern (immutablegit-<sha12>tag + digest verify +:vX.Y.Zon the semver lane). - The supervisor seam. The stack starts postgres through
stack.ProcessSupervisortoday (go/internal/stack/stack.go:193-196:Component: ComponentPostgres, Args: ["--state-dir", …, "--database", …]), resolved by bare name on PATH (adapters/process.go:31-44). S4 adds a container-backed postgres adapter: when configured with a postgres image ref (default: the pinnedcompass-postgresimage), the supervisor runspodman runwith the state dir and the DSN’s socket directory bind-mounted, instead of LookPathing a host binary. The DSN shape is unchanged —host=<socket-dir> port=<port> dbname=compass sslmode=disable(compass-postgres/main.go:9-11) — because the unix socket directory is bind-mounted host↔container, socompass-serveropens the identical DSN and the cluster stays loopback-free and network-invisible (the “socket-only (no TCP), trust auth on the local socket” posture,main.go:16-18, survives containerization intact). Readiness reuses the existing probe unchanged:waitPostgrespolls the full DSN (stack.go:306-311: “polls DBProber.ProbeDB until postgres accepts connections on the full DSN”) and the bind-mounted socket is byte-identical to it. - Teardown is NOT process-shaped — the container needs its own identity
(pgid format v2). The naive claim “the adapter satisfies
stack.Processso teardown needs no change” is FALSE for the fresh-down(linger) path, and this record designs the fix rather than papering over it. Today’s teardown identity is a process-group record:recordChildpersistspgidEntry{Component, Pgid, StartTime}with “pgid == pid, plus the leader start-time token read at spawn” (go/internal/stack/stack.go:258-269;pgidfile.go:30-34), and a freshcompass-stack downreads<StateDir>/stack.pgidsand tears each entry down by group signal —syscall.Kill(-pgid, sysSig)(adapters/groupsignal.go:54). But apodman runclient’sPid()does not describe the container: under rootless podman the containerized postgres runs beneathconmon, outside the podman-client’s process group, so group-signalling the recorded pgid would orphan the container — postgres survives,DownDetached’s socket-quiescence confirm (“postgres stops accepting on the DSN socket”,downdetached.go:188-191) reports a genuine survivor, anddownfails while leaking the container. Resolution (driver’s ruling, OQ-7 asks Matt to bless the contract change): the containerized postgres stays a supervised stack component — preserving the one-command up/down lifecycle that is the reason postgres is a supervised child at all — and the pgid record format grows a first-class container teardown identity:- Format v2.
pgidFileVersionbumps"1"→"2"(pgidfile.go:20-22, the frozen DL-183 contract). Entries become a discriminated union, tagged by a kind field on the entry line:- process entry — today’s
{Component, Pgid, StartTime}, torn down by group signal exactly as now (proc <component> <pgid> <starttime>); - container entry —
{Component, ContainerName}, torn down bypodman stop -t <budget> <name>withpodman rm -f <name>as the SIGKILL-tier escalation (ctr <component> <name>).readPgidFiledispatches on the tag; the hard-error-on-malformed discipline is unchanged (“signaling off a half-understood record is exactly the blast radius the design forbids”,pgidfile.go:100-103).
- process entry — today’s
- Cross-version rule. A v1-only binary never half-parses a v2 record —
but by the entry-line grammar, not a header-version check: shipped v1
readPgidFilestoresheader[0]asVersionand never compares it topgidFileVersion(pgidfile.go:124), so the refusal comes fromparsePgidLinehard-erroring on a v2 entry line (aprocline is 4 fields where v1 demands exactly 3,pgidfile.go:144; actrline’s leading token is not a known component,pgidfile.go:149) under the same hard-error-on-malformed discipline (“signaling off a half-understood record is exactly the blast radius the design forbids”). The T8arefuse-unknown-versionclause is the forward guard — it protects a v2 reader from a future v3, not v1 from v2. A v2 binary reads v1 records as all-process entries (v2 is a strict superset). In practice the window is nil: Global Constraint 4’s one-version-stamp invariant means every channel installs a matched build set, so the up that wrote the record and the down that reads it are the same build — but the rule is stated so a mixed-build accident degrades to a legible refusal, not a blind signal. DownDetacheddispatch.liveTargets/drainTargetsdispatch per entry kind: process entries keep the identity-checked group-signal path; the container entry’s SIGTERM tier ispodman stop(bounded by the existingpostgresDrainBudget,downdetached.go:31) and its SIGKILL tier ispodman rm -f. The socket-quiescence confirm survives intact: the socket dir is bind-mounted from the host, so the DSN socket goes quiet when the container’s wrapper stops — the confirm channel needs no change, only the signal delivery does.- Stable container name. The name is derived from the state dir alone
(e.g.
compass-postgres-<short-hash(StateDir)>), so a freshdownwith no in-memory handle reconstructs it from config — and it is also persisted in the v2 container entry, which is the authoritative copy the teardown uses (derivation is the collision-avoidance scheme for concurrent state dirs, the record is the teardown identity).
- Format v2.
- The container run contract (interface, not implementation). Two seams
break the zero-thought default if left unstated, so they are pinned here:
- uid mapping. postgres refuses to run as uid 0, and the bind-mounted
<StateDir>/postgresdata dir is created 0700 host-user-owned by the wrapper’s initdb path; under rootless podman the container root maps to the host user, so the run MUST use--userns=keep-id(host uid ↔ same uid in-container) with the image running the wrapper as that non-root user — otherwise the data dir is unreadable across the uid map. - stop timeout — two distinct knobs, not one. postgres never force-kills
itself: the wrapper’s drain grace is 30s —
compass-postgres/main.go:256-259: “shutdownGrace bounds how long we wait after forwarding SIGTERM … We never force-kill; escalation is the supervisor’s job” (const shutdownGrace = 30 * time.Second) — whilepodman stop’s default SIGKILLs after 10s. So (1) thepodman runpins--stop-timeout≥ the 30s wrapper grace, the safe default for any stop that passes no explicit-t(podman never hard-kills before the wrapper would). (2) The detached-downteardown path instead passes an explicitpodman stop -t <postgresDrainBudget>= 10s (downdetached.go:31): this is deliberate behavior parity with today’s process model, where the detached path already caps the postgres drain atpostgresDrainBudgetand group-SIGKILLs at that bound rather than waiting the wrapper’s full 30s. The two knobs serve different callers; they are not equal and must not be conflated. - mounts.
<StateDir>/postgres(data dir) and the DSN’s socket dir are bind-mounted read-write; nothing else from the host is mounted.
- uid mapping. postgres refuses to run as uid 0, and the bind-mounted
- Bring-your-own postgres.
Config.DatabaseDSNis already caller-provided (go/internal/stack/config.go:29-31); a new--database-external(or DSN-shape detection: ahost=pointing outside the state dir) makes the supervisor skip the postgres component entirely and justwaitPostgresthe given DSN. Zero-thought default = container; escape hatch = your DSN. - Dev-box path unchanged. devenv keeps providing host postgres tooling
for the e2e suites (
devenv.nix:99-113); the container is the installed stack’s default, selected by config, not a repo-wide replacement of the process adapter.
Alternatives considered
Section titled “Alternatives considered”- Compose/Swarm-packaged stack — rejected in S3:
/dev/kvm+ microVM userspace as host processes is the designed boundary (microvm-runner.md:230-236hard-fail;devenv.nix:191-193); a containerized Runner needs privileged device passthrough and still can’t degrade (D3). - Postgres as a host prerequisite (the client-only OQ-3 interim
recommendation) — superseded by Matt’s ruling: “install postgres 18
yourself” fails the zero-thought bar; the container path is one
podman pullthe stack performs itself, on a host that already runs podman (DL-112). - Vanilla
docker.io/postgresimage + wrapper on the host — rejected: it splits the private-cluster brain (wrapper) from the server binary (postgres) across a host/container seam, reintroducing the host binary the ruling removes; the dedicated image keeps wrapper+postgres one artifact with one version. - TCP loopback instead of bind-mounted socket for the container cluster —
rejected: it would change the DSN contract, expose a port, and forfeit the
socket-only/trust-auth posture (
compass-postgres/main.go:16-18); the bind-mount keeps every consumer byte-identical. - systemd/quadlet-managed postgres container outside the child tree (the
stack only probes the DSN, never owns the container) — rejected: it would
sidestep the pgid-format-v2 change (the container would carry no stack
teardown identity at all), but at the cost of adding unit provisioning
(a quadlet file,
systemctl --userwiring, lingering) to the zero-thought install story and breaking the stack’s up-starts-all / down-tears-all lifecycle —compass-stack upwould bring up a stack whose database it neither started nor can stop. The supervised container + v2 teardown identity keeps the one-command lifecycle; the format change it costs is exactly what OQ-7 asks Matt to bless. - homebrew-core / distro-official packages — rejected for v1: core/distro inclusion has review latency and policy floors (notarization, popularity) that a young project fails; an org tap ships today and migrates later without user-visible change beyond the tap prefix.
- goreleaser for the matrix — re-rejected (same grounds as #460 Fork 2(i)): tag-driven model fights the per-build lane; the repo convention is dependency-free glue.
- Electron-style auto-update in the app — out of scope; deferred with
macOS follow-ups per
compass-native-app/design.md:275-277(“Deferred to follow-up issues: … signed installers with auto-update”). Channels here are pull-based (brew upgrade / nix profile upgrade / re-download).
Dependency order: T1 → T2 → T3 → T4 → T5; T6 → T7 → T8; T8a → T8
(T8a is the pgid-format-v2 teardown identity T8’s fresh-down needs, and
gates on OQ-7); T9 after T6+T8
(T9’s preflight ships its own checks — see T9 — so it does not block on the
runtime lane); T10 last. S1/S2 (client) and S3/S4 (stack) are independent
lanes until T9. Cross-record landing order: T1-T5 hard-depend on PR #460
landing first — T1 edits .github/workflows/release.yml, which is #460-T1’s
deliverable and does not exist until that record’s lane merges.
T1 — Linux client bundle joins the Release asset set
Section titled “T1 — Linux client bundle joins the Release asset set”- Do: extend
.github/workflows/release.yml(the #460 T1 workflow): a step runningapp-bundle/build.sh(nix + gtk3 link, per its own header) and uploading it renamed to the #460 asset grammarcompass-app_<tag>_linux-amd64.tar.gz(the produced asset, see Interfaces) to the same Release; fold its checksum intoSHA256SUMS. Trigger paths gainapp-bundle/**,apps/ui/**,tools/toolchain/gtk-closure.nix,devenv.lock(the bundle’s moon inputs, packaging record §A4). - Interfaces: consumes
app-bundle/build.sh(existing; emits the tarball inapp-bundle/), the release workflow’s tag + upload step. Produces one new asset per Release:compass-app_<tag>_linux-amd64.tar.gz(renamed to the #460 asset grammar<name>_<tag>_<os>-<arch>). - Test cycle: a main push touching
app-bundle/**mints a prerelease whose tarball unpacks,bin/compass-app --versionprints the stamp, and the DL-238 smoke (app-bundle/SMOKE.md) passes from the downloaded asset.
T2 — Darwin build path for compass-app
Section titled “T2 — Darwin build path for compass-app”-
Do: restructure the shell’s build tags so darwin compiles the real entrypoint AND does not double-compile the non-gtk3 stubs. The stub pairs are load-bearing: each gtk3 file that gains darwin forces its
!gtk3stub pair to narrow tolinux && !gtk3— otherwise a darwin build (which isunixwith no gtk3 tag) compiles BOTH the retagged real file and itsunix && !gtk3stub, a duplicate-symbol compile break (e.g.windowFromContextis defined inbridge_service_window_gtk3.go:28ANDbridge_service_window_nogtk3.go:19). Full per-file table (current tags verified against everygo/cmd/compass-app/*.go:1header):File Before After main.gounix && gtk3(linux && gtk3) || darwinclient.gounix && gtk3(linux && gtk3) || darwinwindow_set.gounix && gtk3(linux && gtk3) || darwinbridge_service_window_gtk3.gounix && gtk3(linux && gtk3) || darwinmain_nogtk3.go(stub pair ofmain.go)unix && !gtk3linux && !gtk3bridge_service_window_nogtk3.go(stub pair ofbridge_service_window_gtk3.go)unix && !gtk3linux && !gtk3bridge_service.gounixunix(unchanged — compiles on darwin already)version.gountagged untagged (unchanged) client_test.go,window_set_test.go,window_name_test.go,multiwindow_e2e_test.go,multiwindow_e2e_helpers_test.gounix && gtk3unix && gtk3(unchanged — Linux-only suite, see note)bridge_service_test.go,bridge_service_connect_test.gounixunix(unchanged)Wails v3 selects the system-WebKit backend on darwin (no pkg-config, no gtk closure —
devenv.nix:102-103: “harmless on macOS, where the app links the system WebKit framework and pkg-config goes unused”). Verify the keychain tokenstore path (DL-109) uses the darwin keychain backend. Honesty note: the gtk3-tagged test suite staysunix && gtk3— the darwin binary ships with ZERO of the shell’s gtk3-tagged tests compiled for it; darwin coverage is the manual launch smoke (below) plus whatever the untagged/unixsuites exercise. -
Interfaces: consumes the existing gtk3-tagged shell sources + Wails v3 (
v3.0.0-beta.0,go/go.mod:29per the packaging record). Produces a darwin-arm64compass-appbinary buildable on a mac withgo build -o compass-app ./cmd/compass-app(cgo, system frameworks, no tag). -
Test cycle:
go build ./...(untagged, on Linux) still green — this is what a Linux box CAN verify; a full darwin compile is NOT runnable on Linux (the darwin entrypoint’s cgo needs the macOS SDK), so the real darwin compile gate is T3’smacos-14job (and the cadence question is OQ-8). On a mac: the build launches, loadsdist, and completes the client connect flow against a live stack.
T3 — macOS app bundle + CI lane (unsigned)
Section titled “T3 — macOS app bundle + CI lane (unsigned)”- Do: a
scripts/macos-bundle.ts(TypeScript per Global Constraint 10 — it templates Info.plist, stagesCompass.app/Contents/{MacOS,Resources}, ad-hoc signscodesign -s -, wraps a.dmgviahdiutil); amacos-14job inrelease.ymlbuilding it per Release (paths-gated like T1). Asset:compass-app_<tag>_darwin-arm64.dmg. - Interfaces: consumes T2’s darwin binary +
apps/uidist (built bybunx vite build,apps/ui/moon.yml:19-23per the packaging record). Produces the.app-in-.dmgasset (ad-hoc signed; NOT yet a public channel input — T4 gates that). - Test cycle: the CI-built dmg mounts, the app launches on a mac (quarantine cleared manually — expected pre-notarization), connects to a stack, board renders.
T4 — macOS signing + notarization
Section titled “T4 — macOS signing + notarization”- Do: add Developer ID signing to T3’s lane: import the cert p12 from a
GHA secret into a throwaway keychain,
codesign --options runtime --timestampthe app,xcrun notarytool submit --waitwith an App Store Connect API-key secret,xcrun stapler staple, re-wrap the dmg and sign it. Secrets scoped to the release workflow (no PR trigger exists on it — Global Constraint 2). - Interfaces: consumes T3’s bundle step + three GHA secrets
(
MACOS_CERT_P12,MACOS_CERT_PASSWORD,NOTARY_API_KEY). Produces a Gatekeeper-clean dmg:spctl -a -t open --context context:primary-signaturepasses; first launch shows no quarantine dialog. - Test cycle: download the release dmg on a clean mac (no dev tools),
drag-install, launch — zero Gatekeeper friction.
codesign -dv+stapler validatein CI as the mechanical gate. - Prerequisite: an Apple Developer Program membership + Developer ID certificate (OQ-2 — the one human-action prerequisite in this record).
T5 — Homebrew tap + auto-bump
Section titled “T5 — Homebrew tap + auto-bump”- Do: create
RigelBuild/homebrew-tap:Casks/compass.rb(installs the T4 dmg) +Formula/compass-cli.rb(installs the #460 CLI binaries, per-arch url/sha blocks). Add a semver-lane step torelease.ymlthat renders both files from templates (version + asset sha256s) and pushes to the tap via a fine-grained PAT scoped to that one repo (TAP_PUSH_TOKEN). - Interfaces: consumes the
vX.Y.ZRelease’s asset URLs +SHA256SUMS. Producesbrew install rigelbuild/tap/compass(macOS app) andbrew install rigelbuild/tap/compass-cli(macOS/Linux CLI). - Test cycle:
brew install+brew upgradeagainst a cutv*release on both a mac and a Linux box;brew audit --cask compassclean; a prereleasebuild-*demonstrably does NOT bump the tap.
T6 — Nix flake
Section titled “T6 — Nix flake”- Do: add
flake.nixat the repo root: inputs pinned to the devenv.lock nixpkgs revision (thegtk-e2e-env.nix:9-13single-pin discipline); packagescompass,compass-server,compass-runner,compass-stack(buildGoModule overgo/,-ldflags -X main.version=stamped from the flake’sself.rev),compass-app(Linux: the gtk3 cgo build againstgtk-closure.nix), andcompass-stack-env(cloud-hypervisor + virtiofsd- passt at the pinned rev). Pin-parity gate: a flake has its OWN
flake.lock, so the repo would carry TWO independent nixpkgs locks — the “one closure” claim (flake-built ≡ bundle-built binaries) holds only if they resolve the same rev, and nothing enforces that by construction. So T6 adds a named parity check (theparity.tsdiscipline the repo already runs —devenv.nix:110-111: CI’s gate-tools are “fed byparity.ts --print-nix-attrsoff THIS list”) verifyingflake.lock’s nixpkgs rev ==devenv.lock’s nixpkgs rev, failing CI on skew. A CI check (nix flake check+ the parity check, moon-registered, affected-gated onflake.nix/flake.lock/devenv.lock/go/**—devenv.lockis in the trigger set precisely because a devenv pin bump is the event that causes the drift) keeps it green.
- passt at the pinned rev). Pin-parity gate: a flake has its OWN
- Interfaces: consumes
go/,tools/toolchain/gtk-closure.nix,devenv.lock’s nixpkgs rev. Producesnix profile install github:RigelBuild/compass#<pkg>for every package;nix run .#compass-stack -- statusworks from a bare checkout. - Test cycle:
nix build .#compass-stack .#compass-appfrom a clean clone; the builtcompass-stack --versionmatches the flake rev stamp; the four stack binaries carry ONE stamp (Global Constraint 4); the parity check red on a deliberately skewedflake.lock, green afternix flake lock --override-inputback to the devenv.lock rev.
T7 — compass-postgres container image + publish lane
Section titled “T7 — compass-postgres container image + publish lane”- Do: an image spec (nix2container beside
agent-image/, or a minimal Containerfile — executor weighs against theagent-image/precedent) bundling pinned-major postgres + thecompass-postgreswrapper as entrypoint (--state-dirand--databasefrom env/argv). Apublish-postgres-imageworkflow cloned frompublish-agent-image.yml’s posture: immutablegit-<sha12>tag, digest-coherence verify,:vX.Y.Zminted by the semver release lane only,packages: writeleast-privilege, no PR trigger. - Interfaces: consumes
go/cmd/compass-postgres+ the pinnedpostgresqlmajor. Producesghcr.io/rigelbuild/compass-postgres:git-<sha12>(+:vX.Y.Zon semver cuts), referenced by digest in the Release body beside the agent image (#460 Fork 2(ii) grammar). - Test cycle:
podman runthe image with a tmp state dir + socket-dir bind-mount;psql 'host=<socket-dir> dbname=compass'connects; SIGTERM drains cleanly (wrapper forwards,compass-postgres/main.go:12-14).
T8 — Stack: container-backed postgres component + external-DSN opt-out
Section titled “T8 — Stack: container-backed postgres component + external-DSN opt-out”- Do: in
go/internal/stack: a container-backed postgres start path — config gainsPostgresImage string(default: the pinned compass-postgres ref; empty on the dev path = today’s ProcessSupervisor LookPath spawn) andExternalDatabase bool(skip the postgres component, probeConfig.DatabaseDSNas-is). The container adapter runs the S4 run contract (--userns=keep-id,--stop-timeout ≥the wrapper’s 30sshutdownGrace,compass-postgres/main.go:256-259; bind-mounts of<StateDir>/postgresand the DSN’shost=socket dir) with the stable per-state-dir container name (S4). Readiness is unchanged:waitPostgresprobes the full DSN (stack.go:306-311) over the bind-mounted socket. Teardown for the attached/in-process stop path maps topodman stop; the fresh-down(linger) path is NOT covered by thestack.Processcontract and is T8a’s pgid-format-v2 work — T8 depends on T8a for a correctdown.compass-stack upflags:--postgres-image,--database-external. - Interfaces: consumes T7’s image ref, T8a’s container-entry teardown,
Config.DatabaseDSN(config.go:29-31), rootless podman on the host (Global Constraint 8). Produces: defaultcompass-stack upon a clean KVM host brings up containerized postgres with zero flags;--database-external --database <dsn>runs against user postgres. - Test cycle: unit tests on the config/dispatch logic (fake supervisor);
a podman-tagged integration test mirroring
compass-stack/cross_process_podman_test.go’s harness: up → probe DSN → fresh-processdown→ container gone (podman ps -aempty) → pgid file removed; the external-DSN path green against a throwaway host postgres.
T8a — pgid record format v2: container teardown identity
Section titled “T8a — pgid record format v2: container teardown identity”- Do: implement the S4 teardown design in
go/internal/stack:pgidFileVersion"1"→"2"(pgidfile.go:20-22), the discriminated entry kinds (proc <component> <pgid> <starttime>/ctr <component> <name>),readPgidFilev1-compat (v1 records parse as all-process entries) + refuse-unknown-version,recordChildgains a container-entry variant, andDownDetached’sliveTargets/drainTargetsdispatch per kind — container entries: liveness bypodman container exists <name>, SIGTERM tierpodman stop -t <budget> <name>, SIGKILL tierpodman rm -f <name>, confirm unchanged (DSN socket quiescence,downdetached.go:188-191). - Interfaces: consumes the DL-183 pgid file seams (
pgidfile.go,downdetached.go) and podman on the host. Produces: a v2 record format;writePgidFile/readPgidFileround-tripping both entry kinds; a freshdownthat tears down a containerized postgres by name. Gated on OQ-7 (Matt blesses the DL-183 format extension). - Test cycle: unit tests: v2 round-trip both kinds; a v1 record parses
(compat); an unknown-version record refuses legibly (the
ErrNoTeardownRecordposture,downdetached.go:47-51); the existingpgidfile_test.goprefix/atomicity suites green on v2. Integration: T8’s up → fresh down → container gone.
T9 — Self-host bring-up surface: preflight + docs
Section titled “T9 — Self-host bring-up surface: preflight + docs”- Do:
compass-stack preflight(or fold intoup’s first phase): KVM openable, podman present + rootless-capable, microVM userspace trio found at/above floors, legible per-check pass/fail. Dependency honesty: the Runner has NOVerifyMicroVMSupportfunction today — the name occurs only in a forward-looking comment (go/internal/runtime/microvm.go:126-128: “the default collapses to microVM guarded by a VerifyMicroVMSupport hard gate at startup”) and in the elastic-session design record, not in code. T9 therefore ships its own minimal checks — open/dev/kvm,exec.LookPaththe microVM userspace trio (cloud-hypervisor, virtiofsd, passt),podman info— explicitly marked in-code as to-be-replaced by the runtime lane’s eventual preflight gate when it lands. This keeps T9 off the runtime lane’s critical path (no cross-lane blocks-on).docs/self-host.md: the dedicated-KVM-machine shape and the one-box localhost-TLS shape (client-only OQ-6), each channel’s install one-liner (flake, release tarball), postgres default + BYO-DSN, systemd unit example. - Interfaces: consumes T6 (flake install path), T8 (postgres default). Produces the documented, preflight-guarded install story RIG-2608 asks for.
- Test cycle: follow
docs/self-host.mdverbatim on a clean KVM VM: flake install → preflight green →compass-stack up→ client connects from another machine over TLS → one agent session runs.
T10 — Docs, ledger, tracker
Section titled “T10 — Docs, ledger, tracker”- Do: update
docs/architecture/build-and-ci.mdwith the distribution surfaces; land the §Ledger delta rows + status flips indocs/designs/product/DECISIONS.md(driver lands them in the design PR); re-scope/close RIG-2477 and RIG-2608 against the frozen record; file the per-task impl issues per the freeze→file→dispatch gate. - Interfaces: consumes the frozen record. Produces ledger rows + dispatched impl issues.
- Test cycle:
tools/design-ledger-gategreen on the design PR; every DL id verified free at landing.
- T1 — Linux client bundle attached to Releases (release.yml + build.sh)
- T2 — darwin build path for compass-app (build-tag restructure)
- T3 — macOS
.app/dmg bundle + macos-14 CI lane (ad-hoc signed) - T4 — Developer ID signing + notarization + stapling in the release lane
- T5 — RigelBuild/homebrew-tap (cask + CLI formula) + semver auto-bump
- T6 —
flake.nix: client + stack + stack-env packages,nix flake checkCI - T7 — compass-postgres container image + publish lane (GHCR, digest-verified)
- T8 — stack container-backed postgres component +
--database-external - T8a — pgid record format v2 (container teardown identity, gated on OQ-7)
- T9 —
compass-stackpreflight +docs/self-host.md - T10 — build-and-ci docs, ledger rows, RIG-2477/RIG-2608 follow-through
Ledger delta (intended)
Section titled “Ledger delta (intended)”Ledger-impact: new rows + no status flips (DL-217 is already
Superseded by DL-238; DL-238/DL-214 stay Active — this record extends
them, per the DL-213 partial-supersession-by-citation pattern). Rows land in
docs/designs/product/DECISIONS.md in the design PR, written by the driver —
this record does not touch the ledger file. IDs allocated tentatively from
the observed highest (DL-240; product/DECISIONS.md:160) — verify free at
landing.
| ID | Decision (proposed) | Status | Record |
|---|---|---|---|
| DL-241 | The client app ships a full per-OS matrix from day one — Linux x86_64 (thin-client tarball, DL-238 content) AND macOS arm64 (signed+notarized .app in a dmg, built on a macOS runner, never cross-compiled) — attached to the RIG-1746 release lanes, extending the release-bundling record’s Fork 2(i) asset boundary and resolving its OQ-1 deferral (RIG-2477) |
Active (Matt, 2026-08-24) | this record §S1 |
| DL-242 | Client install channels are homebrew (RigelBuild/homebrew-tap: cask for the macOS app + a cross-OS CLI formula, semver-bumped by the release workflow) and a repo flake.nix (client + stack packages pinned to the devenv.lock nixpkgs), over the raw release assets; distro-native packages (deb/rpm/AUR) deferred |
Active (Matt, 2026-08-24) | this record §S2 |
| DL-243 | The self-host stack stays a host-level bring-up on a KVM-capable Linux machine (compass-stack up; microVM D3 hard-fail consumed, no compose/Swarm packaging); compass-stack joins the release binary matrix, and the flake + preflight + self-host doc are its install surface — resolving the client-only record’s OQ-3 (RIG-2608) |
Active (Matt, 2026-08-24) | this record §S3 |
| DL-244 | Postgres leaves the installed stack’s host-process tree: the zero-config default is a dedicated ghcr.io/rigelbuild/compass-postgres container (pinned postgres major + the compass-postgres wrapper as entrypoint, socket-dir bind-mounted so the DSN contract is unchanged) run by the supervisor via rootless podman; a user-supplied DSN (--database-external) opts out. Supersedes the host-prerequisite interim answer (client-only OQ-3) atop the already-superseded DL-217 |
Active (Matt, 2026-08-24) | this record §S4 |
Open Questions
Section titled “Open Questions”Per the batched-clarifications rule; each carries a recommendation. Matt’s two rulings above are NOT here — they are decided. The load-bearing set is OQ-2 (Apple account), OQ-5 (postgres image mechanism), OQ-7 (pgid-v2 contract change), and OQ-8 (darwin CI cadence).
OQ-1 [non-load-bearing] — native Linux distro packages (deb/rpm/AUR)
Section titled “OQ-1 [non-load-bearing] — native Linux distro packages (deb/rpm/AUR)”Flake + brew-on-Linux + tarball cover the Linux matrix; distro packages add
per-format packaging debt (postinst scripts, repo hosting/signing) for
uncertain reach. Recommendation: defer; revisit on demand signal. The
channels in S2 are additive — a later .deb wraps the same bundle content.
OQ-2 [load-bearing] — Apple Developer account provisioning
Section titled “OQ-2 [load-bearing] — Apple Developer account provisioning”T4 requires an Apple Developer Program membership (org or individual), a Developer ID Application certificate, and an App Store Connect API key — a human/console prerequisite no agent can perform. Recommendation: enroll as the RigelBuild org; T2/T3 proceed unblocked (ad-hoc signed, internal use), T4/T5-cask gate on the secrets landing. Filed as a human-action issue at dispatch.
OQ-3 [non-load-bearing] — NixOS module (services.compass-stack)
Section titled “OQ-3 [non-load-bearing] — NixOS module (services.compass-stack)”The flake’s packages are the load-bearing channel; a NixOS module wrapping
up in systemd (with the KVM group + podman socket wiring declared) is the
polished self-host endgame but not required for T9’s documented systemd-unit
example. Recommendation: defer to a follow-up on the flake once T6/T9
land; the module is additive.
OQ-4 [non-load-bearing] — darwin-amd64 (Intel mac) support
Section titled “OQ-4 [non-load-bearing] — darwin-amd64 (Intel mac) support”PR #460 ships darwin-arm64 only for the CLI; the app matrix here matches.
Intel macs lack nested-virt relevance (client-only anyway) but are a
shrinking install base. Recommendation: arm64-only now; brew cask can
grow an on_intel block later without a design change (GHA macos-13 for
an amd64 leg if demanded).
OQ-5 [load-bearing] — postgres image build mechanism
Section titled “OQ-5 [load-bearing] — postgres image build mechanism”T7 leaves nix2container-vs-Containerfile to the executor, but the fork has a
real tradeoff: nix2container reuses the agent-image/ publish machinery and
the repo’s pinning discipline (agent-image/publish.sh + the vendored
skopeo), while a Containerfile over the official postgres:18 base is
simpler and inherits upstream security updates by rebuild.
Recommendation: nix2container from the pinned nixpkgs postgresql attr —
one pinning regime (Global Constraint 7’s parity argument), the publish lane
already exists as a pattern, and “upstream base updates” are equally a
nixpkgs-pin bump. Non-blocking for every other task.
OQ-6 [non-load-bearing] — prerelease channel exposure
Section titled “OQ-6 [non-load-bearing] — prerelease channel exposure”Should build-<sha12> prereleases feed any channel beyond raw assets (e.g.
a compass-cli@head formula or a flake packages.head)? The flake tracks
main by construction (github:RigelBuild/compass is a moving ref), which
already IS the head channel. Recommendation: no extra head channels;
brew stays semver-only, flake-at-main is the sanctioned bleeding edge.
OQ-7 [load-bearing] — containerized postgres teardown extends the frozen DL-183 pgid format to v2
Section titled “OQ-7 [load-bearing] — containerized postgres teardown extends the frozen DL-183 pgid format to v2”The container has no process-group teardown identity (S4: a rootless
podman run client’s Pid() does not describe the container, which runs
under conmon outside the client’s group), so the fresh-down path needs a
container entry kind in the stack.pgids record — a "1" → "2" bump of
pgidFileVersion (pgidfile.go:20-22), the format DL-183 froze. The
alternative that avoids the format change is the systemd/quadlet-managed
container (§Alternatives): the stack only probes the DSN, but the install
story gains unit provisioning and up/down stop owning the database
lifecycle. Bless the contract change, or prefer the lifecycle change?
Recommendation: bless v2 (the S4/T8a design): the one-command
up/down lifecycle is the reason postgres is a supervised child at all, the
v2 format is a strict superset (v1 records parse as all-process entries),
and Global Constraint 4’s matched-build-set invariant makes the
cross-version window nil in practice.
OQ-8 [load-bearing] — darwin CI cadence (recurring macOS runner spend)
Section titled “OQ-8 [load-bearing] — darwin CI cadence (recurring macOS runner spend)”Nothing keeps the darwin build green between releases: the per-PR gate is
go build ./... untagged on Linux, which never compiles the darwin arm —
a PR can silently break darwin (exactly the T2 duplicate-symbol class of
error) and it surfaces on release day inside the signing-gated semver lane,
the worst place. macOS runners cost ~10x Linux minutes, so this is a
recurring-spend policy call. Options: (a) a main-branch-only or nightly
macos-14 compile+bundle sweep (~1 job/day; catches rot within a day);
(b) a cheap per-PR GOOS=darwin syntax-only typecheck of the non-cgo
subset on Linux as a canary (catches tag/duplicate-symbol errors, NOT
framework linkage); (c) eat release-day rot. Recommendation: (a) —
bounded daily spend, catches the whole failure class including linkage,
and keeps PR CI free of macOS minutes.