diff --git a/.moon/workspace.yml b/.moon/workspace.yml index 0118c395..027e0807 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -34,6 +34,11 @@ projects: # (SEA-1405). Separate from compass-proto because its subject is the plugin # rather than the schema. stamp-gate: 'tools/stamp-gate' + # The design-ledger gate: validates the Compass design-decision ledger + # (docs/designs/product/DECISIONS.md), the per-record `Status:` headers, and + # dangling supersession/Record pointers (SEA-1187). Migrated from the sealed + # repo with the corpus it governs (SEA-1794). + design-ledger-gate: 'tools/design-ledger-gate' # The one-shot engineering-docs sanitization migration (SEA-1766, T4). Encodes # the four-class rewrite policy; no standing run task, deleted after T5. docs-migrate: 'tools/docs-migrate' diff --git a/bun.lock b/bun.lock index 9c940fae..7ed06bd3 100644 --- a/bun.lock +++ b/bun.lock @@ -89,6 +89,16 @@ "typescript": "catalog:", }, }, + "tools/design-ledger-gate": { + "name": "@compass/design-ledger-gate", + "bin": { + "design-ledger-gate": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/docs-migrate": { "name": "@compass/docs-migrate", "devDependencies": { @@ -248,6 +258,8 @@ "@compass/client": ["@compass/client@workspace:packages/compass-client"], + "@compass/design-ledger-gate": ["@compass/design-ledger-gate@workspace:tools/design-ledger-gate"], + "@compass/docs-migrate": ["@compass/docs-migrate@workspace:tools/docs-migrate"], "@compass/eng-docs": ["@compass/eng-docs@workspace:apps/eng-docs"], diff --git a/docs/designs/platform/compass-agent-image-publish.md b/docs/designs/platform/compass-agent-image-publish.md new file mode 100644 index 00000000..2c04f424 --- /dev/null +++ b/docs/designs/platform/compass-agent-image-publish.md @@ -0,0 +1,503 @@ +# Publish the compass-agent image to GHCR + +> **Design record.** This designs the GHCR publish lane for the `compass-agent` +> runtime image; it targets the **`sealedsecurity/compass`** repo — every +> `agent-image/*`, `.github/workflows/*`, `forks/*`, `go/cmd/*`, `ci.yml`, +> `devenv.nix`, `packages/compass-agent/*`, and `docs/architecture/*` citation +> below is a path in that repo at HEAD `b3fc25311`, not this one (line numbers +> drift as the code evolves; resolve them against that commit). It lives in the +> sealed design corpus (`docs/designs/platform/`) because that is where the wave's design +> records freeze; the `docs/designs/product/*` cross-references (e.g. DL-112) +> are paths in this (sealed) corpus. + +Status: Draft +Tracking: SEA-1690 (blocks compass-native SEA-1683/T2, SEA-1685/T4, SEA-1687/T6) + +## Problem / Intent + +The `compass-agent` base image already builds and loads locally +(`dogfood:agent-image` → `containers-storage:compass-agent:latest`), but per +frozen decision DL-112 (`docs/designs/product/compass-native-app/design.md` +§OQ6) the native app does not bundle the agent image: `compass-stack` `podman +pull`s it from GHCR at first run. Nothing publishes the image today. This +record designs the publish lane — the GHCR side of that seam; the pull side is +compass-native's (SEA-1683/T2). + +## Approach + +### Decision: the name/tag contract (confirmed with compass-native) + +Settled peer-to-peer with compass-native (the consumer) — recorded as a +decision, not an assumption: + +- **Ref:** `ghcr.io/sealedsecurity/compass-agent` — locked. +- **Tags, both published per main build:** + - `:git-` (12-hex, `git rev-parse --short=12 HEAD`) — **immutable**. + This is the pin compass-stack bakes into the native app binary and hands the + runner via `--image` / `$COMPASS_AGENT_IMAGE` + (`go/cmd/compass-runner/main.go:44-45` — "The container image every agent + workstream runs. Defaults to $COMPASS_AGENT_IMAGE."; `:111-114` requires it). + The publish step refuses to overwrite an existing `:git-` tag whose + content differs (a config-digest compare, exact commands in T1), and + re-inspects after every copy to assert the pushed digest — so immutability + is enforced and verified, not assumed. + - `:latest` — moving; documented **first-run fallback only**, never the + default consumption path. +- **Forward note (non-load-bearing):** GA (SEA-1687) will likely add a + release-version tag (e.g. `:v1.2.3`) alongside `:git-`. The publish + script below takes its tag list as arguments, so that is a one-line later + add — no scheme change. +- **Visibility:** the package is **public** (decided — see the visibility + Decision below). The lane works either way; public means no pull credential + anywhere. + +### Decision: platform contract — `linux/amd64` single-arch (dogfood milestone) + +A settled fact, not an open question: the consumer's arch is **frozen** in +compass-native's merged record (PR #1073, +`docs/designs/product/compass-native-app/design.md`) to Linux x86_64 for the +dogfood milestone — non-Linux runner support and +macOS packaging are deferred there to a GA follow-up (`:522-524` "reproducible +build of the app bundle for Linux (the dev/dogfood target; macOS packaging +tracked as follow-up per A5)"; `:246-248` "non-Linux runner support … deferred +to follow-up"; `:346-349` runner is Linux-only with an embedded-mode +preflight-and-refuse on an unsupported host; `:486` dogfood gate = "e2e smoke on +the Linux dev box", which is x86_64). So publish and consumer agree on +`linux/amd64`, and the single-arch two-`skopeo copy` mechanism below is correct +**by design, not by omission**. + +- **Forward add (non-load-bearing), tied to GA native-packaging (compass-native + T5/T6):** macOS/`aarch64` support is both a native-packaging item *and* a + multi-arch manifest-list image here. The per-platform entrypoint FOD hash + (`agent-image/entrypoint.nix:96-116`) and the manifest-list push (which the + two-copy path does not produce — needing per-arch builds on matching runners + plus a `manifest create`/`push`) enter scope in *that* record, not this one. + +### Decision: build+push mechanism — build the nix2container spec once, push each tag with the fork's skopeo + +The image is a devenv/nix2container artifact, not a docker build. The devenv +container module builds a nix2container image spec and copies it with a +patched skopeo that understands the `nix:` transport +(`forks/devenv/src/modules/containers.nix:307`): + +```text +${nix2container.skopeo-nix2container}/bin/skopeo --insecure-policy copy "nix:$container" "$dest" +``` + +with `dest="${registry}${cfg.name}:${cfg.version}"` (`containers.nix:295`) — +the tag is pinned to `cfg.version`, which defaults to `"latest"` +(`containers.nix:324-328`) and is not overridable from the CLI: `devenv +container copy` exposes only `--registry` and `--copy-args` +(`forks/devenv/devenv/src/cli.rs:1104-1113`). So the stock copy path can push +`:latest` but can never mint `:git-`. + +**Chosen mechanism:** + +1. Build the spec exactly as dogfood does: `nix run path:../forks/devenv#devenv + -- container build agent`, cwd `agent-image/`. `container build` prints the + image-spec store path to stdout (`forks/devenv/devenv/src/main.rs:910-912` + emits it as `CommandResult::Print`, a bare `print!` after UI cleanup, + `main.rs:830-832`; `container copy` calls the same `container_build` + internally, `forks/devenv/devenv/src/devenv/container.rs:55`), so this is + byte-for-byte the derivation the dogfood task loads into `containers-storage:` + — the reproducibility constraint holds by construction: one derivation, two + copy destinations. Capture hygiene: nix/devenv tracing goes to stderr, but + the script takes the last stdout line and asserts it matches `^/nix/store/` + before use, so a future fork bump that adds a stdout line cannot silently + feed skopeo a non-path. +2. Push each tag straight from the spec with the fork's patched skopeo + (`forks/nix2container/flake.nix:31` exposes `skopeo-nix2container`; + `default.nix:22` builds it from `pkgs.skopeo` with the `nix:` transport + patch — the same pattern nix2container's own `copyToRegistry` helper uses, + `default.nix:78-81`): + + ```text + skopeo --insecure-policy copy nix:$SPEC docker://ghcr.io/sealedsecurity/compass-agent:git- + skopeo --insecure-policy copy nix:$SPEC docker://ghcr.io/sealedsecurity/compass-agent:latest + ``` + + The second copy re-uploads nothing: skopeo skips blobs the registry already + has, so `:latest` costs one manifest write. + +**Alternatives weighed:** + +- *(a) `devenv container copy agent --registry docker://ghcr.io/sealedsecurity/` + then retag* — reuses the highest-level CLI, but can only produce `:latest` + (tag fixed at `cfg.version`), forcing a registry→registry retag copy for the + immutable tag and pushing the moving tag *first* — the wrong order (the pin + should exist before `:latest` moves). Rejected: more moving parts for less + control. +- *(b) wire nix2container's `copyToRegistry` as a nix attr* — the devenv module + does not expose the raw image derivation as a build target (`derivation` is + an `internal = true` option, `containers.nix:523-527`), so this means new + plumbing through the vendored fork for something two skopeo invocations do. + Rejected: fork surface area for no gain. +- *(c) `podman push` from `containers-storage:` after a dogfood-style load* — + round-trips through the local store and re-computes layers on push; loses the + direct spec→registry path and adds a store dependency to CI. Rejected. + +### Decision: CI placement — a separate least-privilege workflow, a principled exception to ONE-JOB + +New workflow `.github/workflows/publish-agent-image.yml`, **not** a step in the +existing `CI` job. + +The ONE-JOB doctrine (`.github/workflows/ci.yml:4-23`) exists to prevent a +second source of truth for *what the gate covers*: "it can only be built by +enumerating projects (or tasks) in YAML, and a project list in a workflow is a +second source of truth for something .moon/workspace.yml already owns." The +publish lane is not the gate and enumerates no moon projects — `agent-image/` +is deliberately not a moon project (`.moon/workspace.yml` has no entry for it; +it is a standalone devenv) — so a separate workflow re-creates none of the +silent-staleness failure the doctrine targets. What it *does* buy: + +- **Least privilege.** A GHCR push needs `packages: write`; the gate job runs + `contents: read` (`ci.yml:76-77`, workflow-level). GitHub permissions *can* + be scoped per-job, so a second job in `ci.yml` with its own `packages: write` + would not widen the gate job's token — but a separate workflow is the simpler + boundary: it gets `contents: read` + `packages: write` and nothing else, and + PR events never reach it at all. +- **Paths-scoping.** `ci.yml`'s `on:` has no per-job path filter; scoping + publish to closure-affecting pushes inside `ci.yml` would need a + `dorny/paths-filter` step, where a separate workflow gets native + `on.push.paths` (next section). +- **Concurrency.** The gate's concurrency group cancels in-progress runs + (`ci.yml:74-76`, `cancel-in-progress: true`) — a superseding push would kill + an in-flight publish mid-tag-pair. The publish needs its own + `cancel-in-progress: false` group, which a separate workflow gives cleanly. +- **Off the hot path, not a required check.** The image closure is the heavy + nix build that motivates CI's 90m timeout (`ci.yml:93-95`); the dogfood task + is opt-in for the same reason (`devenv.nix:340-348`). A separate workflow + keeps PR latency untouched, and a publish flake never reds the required merge + gate. +- **Failure ownership.** `agent-image/` is not a moon project, so the gate + never builds it — the image has *zero* pre-merge coverage, and the publish is + not required. A red publish (a `.prototools`/nixpkgs bun-drift assert, + `agent-image/toolchain.nix:50-56`; an FOD-hash invalidation, + `entrypoint.nix:116`) would otherwise sit unnoticed while a consumer waits on + a tag. Publish failures are owned by compass-runner (this record's owner); + the main-branch Actions failure notification is the surfacing mechanism. A + cheap eval-only pre-merge check (`nix eval` the image drv without realising + it) is deferred hardening, noted in T4. + +Triggers: `push: branches: [main]` with a `paths:` filter (next section), plus +`workflow_dispatch` as the manual/backstop lane. No PR trigger — no secret or +token exposure to fork PRs, no per-PR heavy build. No tag/release trigger yet; +that arrives with the GA release-version tag (forward note above). + +*Alternative weighed:* fold into the `CI` job as a main-only conditional step. +Keeps one workflow file, but widens the gate token (above), couples publish +latency/failures to the required check, and buys nothing — the publish shares +no setup with the gate beyond nix installation. Rejected. + +### Decision: publish only when the image closure changes, with a manual backstop + +`paths:` filter on the workflow's `push` trigger, approximating the image's +nix closure: + +```text +agent-image/** +packages/compass-agent/** +forks/devenv/** +forks/nix2container/** +package.json +bun.lock +.github/workflows/publish-agent-image.yml +``` + +(`agent-image/entrypoint.nix` bundles `packages/compass-agent` and pins the +whole workspace dependency tree to the root `bun.lock` via a fixed-output +derivation whose src reads every workspace-member manifest — +`(builtins.fromJSON (readFile ../package.json)).workspaces.packages` expanded +with `readDir` over `packages/*`/`apps/*`/`tools/*`, `entrypoint.nix:48-65`, +realised at `:140-148`; the build itself runs through `forks/devenv` and its +nix2container input. `.prototools` is deliberately *excluded* — a bun-pin move +there can only fail the `toolchain.nix:50-56` assert, never silently change the +output, so it needs no publish trigger. The workflow file itself is in the +filter so a fix to the publish lane republishes.) + +- The build is the expensive part (the full agent toolchain closure), not the + transfer — GHCR layer dedup makes an identical push cheap, but the nix build + that precedes it is exactly the cost the repo keeps off every hot path. + Publishing on every main push would pay it on every docs/Go/UI commit for an + identical artifact. +- The cost of filtering: a path the filter misses (e.g. a future input outside + these globs) skips a publish, and `:git-` tags exist only for shas where + publish ran. The same is true of concurrency supersession — GitHub keeps at + most one pending run per group, so a burst of pushes A,B,C publishes A and C + and silently skips B's tag even though its paths matched. Both gaps are + acceptable and mean the same thing: **a published tag is the source of + truth, a missing one is not a failure.** compass-stack pins a *published* + sha (it selects the pin at native-app build time from published tags, so + gaps are fine), and `workflow_dispatch` republishes any HEAD on demand. The + filter list lives beside the workflow with a comment tying it to the closure + inputs, so extending the closure prompts extending the filter in review. + +*Alternative weighed:* publish on every main push (simplest, no filter to go +stale). Rejected for CI spend: the heavy nix build per commit is the exact cost +`ci.yml`'s affected gating and the opt-in dogfood task exist to avoid. + +### Decision: push auth — the workflow's own `GITHUB_TOKEN` + +`skopeo login ghcr.io -u ${{ github.actor }} --password-stdin` fed from +`secrets.GITHUB_TOKEN`, with `permissions: packages: write` on the workflow. +Scoped to this repo's GHCR namespace, auto-rotated, nothing to provision or +leak. *Alternative:* a PAT/deploy token — needed only for cross-repo or +cross-org pushes, which this is not; a standing secret to rotate for zero +benefit. Rejected. + +**Auth-file pin (load-bearing across the two `nix run` invocations):** `skopeo +login` and the later `skopeo copy` run as separate `nix run` processes and must +resolve the *same* credentials file. The default location is +`$XDG_RUNTIME_DIR/containers/auth.json`, whose presence on GitHub-hosted +runners is environment-dependent — a mismatch greens the login step and 401s +the copy. The workflow therefore exports an explicit `REGISTRY_AUTH_FILE` +(e.g. `$RUNNER_TEMP/ghcr-auth.json`) that both invocations honor; `publish.sh` +passes `--authfile "$REGISTRY_AUTH_FILE"` to every skopeo call as a belt-and- +braces guard. + +First-publish caveat (operational, one-time): the first `GITHUB_TOKEN` push +creates the `compass-agent` package linked to this repo, **private by +default**, and only if the org's package-creation policy permits +`GITHUB_TOKEN`-created packages (an org setting; if it forbids them, the first +run 403s and an owner must pre-create the package or relax the policy). Whoever +executes the plan must confirm that precondition, set the package **public** +(per the visibility Decision below) in the package settings once, and confirm +the repo-linkage grants the workflow write access thereafter. + +### Decision: package visibility — public (Matt's ruling) + +The GHCR package is **public**. Matt ruled it directly: compass is +open-source, so the image's payload — the toolchain plus the bundled +first-party agent entrypoint (readable, unminified `bun build` JS of +`packages/compass-agent` and its import graph) — is source that is public by +the project's own nature, not a leak. The image carries no runtime secrets +(those are runner-supplied per-exec, `agent-image/devenv.nix:50-53`). Public is +also what makes DL-112's no-credential first-run `podman pull` work with +nothing to provision. + +Consequences folded through this record: the pull side needs no credential (the +DL-109 keychain flow is a private-visibility contingency only, kept in the note +below for the record); the one-time package-visibility setting after first push +is "set public"; and an unauthenticated `skopeo inspect` becomes a meaningful +acceptance signal (a T3 follow-up — it exercises the actual anonymous consumer +path, and is not folded into T3's write-token-scoped automated coverage). A public +package must not be flipped private later without a migration — that would break +every installed consumer's pull. + +### Note: pull-side auth (compass-native's card, recorded for the dependency) + +On the `linux/amd64` dogfood path: if the package is public, `compass-stack`'s +first-run `podman pull` needs no credential. If private, compass-native builds a +keychain-first pull-cred flow into compass-stack (per their DL-109 posture — on +dogfood's Linux host that is a Secret Service keychain with a `0600` fallback; +cheap on their side, by their own assessment). With the package public (decided +above) this pull-cred flow is **not needed for dogfood** — it stays a private- +visibility contingency only, recorded so the dependency is explicit if +visibility ever changes. + +## Global Constraints + +- Conventional Commits; this design PR is `docs(platform): …` with the + `Co-authored-by: Matt Wilkinson ` trailer. +- No AI-product names; no planning metadata in source. SEA-#### appears only + as tracking refs in this record. +- Comments and docs explain non-obvious WHY (compass `AGENTS.md`). +- The image build stays OFF the hot `up`/PR path: publish is main-only + + `workflow_dispatch`, mirroring the opt-in `dogfood:agent-image` posture + (`devenv.nix:345-348` — "NOT wired `after` into up — the image closure is + large"). +- Reproducibility: the published `:git-` and the local dogfood load are + copies of the SAME nix derivation — both flow through + `container_build agent` (`forks/devenv/devenv/src/devenv/container.rs:55`) + and diverge only in skopeo destination. +- `:git-` tags are immutable: the publish script refuses to overwrite a + tag whose remote config digest differs from the local spec's, and a + deliberate re-publish of the same sha is a no-op success only when the + digests match. A registry error that is *not* an unambiguous manifest-unknown + aborts (never falls through to an overwrite). +- ONE-JOB doctrine (`ci.yml:4-23`) is respected: the gate workflow is + untouched; the publish workflow enumerates no moon projects and is not a + required check. + +## Plan + +### T1 — Publish script + +Add `agent-image/publish.sh`: build the spec, then push the tag list. Runnable +locally (with a PAT-backed `skopeo login`) and from CI identically. + +- Build: `nix run path:../forks/devenv#devenv -- container build agent` + executed with cwd `agent-image/` (the same fork-pinned invocation shape as + `dogfood:agent-image`, `devenv.nix:349-354`); capture the printed image-spec + store path. +- Skopeo: `nix run path:../forks/nix2container#skopeo-nix2container --` + (exposed at `forks/nix2container/flake.nix:31`; `pkgs.skopeo`'s + `meta.mainProgram` survives the `overrideAttrs` at `default.nix:22`, so + `nix run` resolves `bin/skopeo`). +- For each requested tag, in order (`git-` before `latest`, so the + immutable pin lands before the moving tag), a `git-*` tag runs the + immutability guard first: + 1. **Read the remote config digest.** `skopeo inspect --raw + docker://…: | jq -r .config.digest`. Plain `skopeo inspect`'s + `.Digest` is the *manifest* digest, which skopeo may re-serialize on copy; + `--raw` + `.config.digest` is the rootfs-pinning identity the fork itself + compares (`GetConfigDigest`, `forks/nix2container/nix/image.go:40-47`) and + survives a copy verbatim. + 2. **Error taxonomy — never fall open.** If the inspect fails, distinguish + *manifest-unknown* (tag genuinely absent: GHCR returns + `manifest unknown` / HTTP 404 — parse stderr for it) from any other + failure (auth 401/403, network 5xx, rate-limit). Manifest-unknown → the + tag is free, proceed to copy. Any other error → **abort non-zero**; a + transient registry hiccup must never be read as "tag absent, safe to + overwrite". + 3. **Compare.** Read the local spec's config digest the same way + (`skopeo inspect --raw nix:$SPEC | jq -r .config.digest`). Equal → + idempotent re-run, skip the copy (no-op success). Differ → fail with a + "tag exists — immutable" error. +- Copy: `skopeo --insecure-policy copy nix:$SPEC + docker://ghcr.io/sealedsecurity/compass-agent:` (`--insecure-policy` + matches the module's own copy invocation, `containers.nix:307`). +- **Post-copy assert.** After each copy, re-inspect the pushed tag's config + digest and assert it equals the local spec's — upgrading the guarantee from + "skopeo exited 0" to "the artifact I built is the artifact at that tag", and + closing the check-then-copy TOCTOU window's *damage* (a racing overwrite is + detected, not silently accepted). +- Auth: `--authfile "$REGISTRY_AUTH_FILE"` on every skopeo call (never an + ambient-only login — see the auth-file pin above). +- **Verify the guard's linchpin first.** The whole guard (local-digest read, + compare, post-copy re-inspect) rests on `skopeo inspect --raw nix:$SPEC` + working as a standalone inspect through the fork's `nix:` transport — the one + claim resting on a build-time-fetched patch (`forks/nix2container/default.nix` + pulls the transport from `github.com/nlewo/container-libs`) rather than an + in-tree file. The first implementation spike must confirm `skopeo inspect + --raw nix:$SPEC | jq -r .config.digest` succeeds against the patched skopeo + before the guard is built on it, so a broken assumption fails at T1, not at + first publish. (Strongly expected to hold — the same `nix:` ImageSource + already backs `skopeo copy`, `containers.nix:307` — but out-of-tree, so + verify.) + +Interfaces: + +- Consumes: cwd `agent-image/`; `forks/devenv` + `forks/nix2container` flakes; + git HEAD for the sha tag; an existing `skopeo login ghcr.io` session. +- Produces: pushed + `docker://ghcr.io/sealedsecurity/compass-agent:git-` and + `docker://ghcr.io/sealedsecurity/compass-agent:latest`; exits non-zero on + immutability violation or copy failure. +- CLI: `./publish.sh [tag …]` (no args = the default two-tag set; the GA + release tag later becomes `./publish.sh git- v latest`). + +### T2 — Publish workflow + +Add `.github/workflows/publish-agent-image.yml`: + +- `on: push: branches: [main]` with the `paths:` filter from the Approach + (globs listed there, with a header comment tying each glob to the closure + input it tracks), plus `workflow_dispatch`. +- **`workflow_dispatch` ref guard.** `workflow_dispatch` runs on any branch, so + the publish job is guarded `if: github.ref == 'refs/heads/main'` — a dispatch + from a feature branch must never mint a `:git-` for unmerged code nor + move `:latest` off main. (Main pushes satisfy the guard trivially.) +- `permissions: contents: read, packages: write` (workflow-level; nothing + else). +- `concurrency: group: publish-agent-image, cancel-in-progress: false` — + publishes serialize rather than cancel, so an in-flight `:latest` move is + never half-superseded (a superseded push skips its tag, per the Approach — + never a torn tag pair). +- Steps: checkout (default depth — the script needs only HEAD); + `cachix/install-nix-action` pinned to the same sha + `extra_nix_config` + block as `ci.yml:227-245` (same substituters, same reviewed-file trust + rationale — never `accept-flake-config`); `skopeo login ghcr.io -u + ${{ github.actor }} --password-stdin <<< "$GITHUB_TOKEN"` via the fork's + skopeo; run `agent-image/publish.sh`. +- `timeout-minutes: 90` (the image closure is the same heavy build that sizes + the CI job's timeout, `ci.yml:93-95`). + +Interfaces: + +- Consumes: `agent-image/publish.sh` (T1); `secrets.GITHUB_TOKEN`; + `github.actor`. +- Produces: the two pushed refs above on every closure-affecting main push; + a manually dispatchable republish of HEAD. +- Not a required check; never triggered by `pull_request`. + +### T3 — Verification + +Two layers, one automated + one documented smoke: + +- In-workflow (end of T2's job), all with `--authfile "$REGISTRY_AUTH_FILE"`: + `skopeo inspect docker://…:git-` must succeed — proves the artifact is + resolvable from GHCR, not merely that the copy exited 0. Assert `.Architecture` + and `.Os` match the build platform (the cheapest tripwire for a platform- + contract regression, see the `linux/amd64` platform Decision above), and + assert `:latest`'s config digest equals `:git-`'s (one inspect each — + proves the two-copy pair + landed coherently). Automated coverage ends here: at "the manifest is + resolvable with the workflow's own write-scoped token"; it does not exercise + the visibility-dependent anonymous pull (public, so anonymous pull is the + contract — an unauthenticated `skopeo inspect` is the truest check, notable + as a T3 follow-up) nor a real pull-and-run. +- Documented smoke (in the workflow header comment and the T4 doc fold): on a + runner host, `podman pull ghcr.io/sealedsecurity/compass-agent:git-` + then start `compass-runner --image + ghcr.io/sealedsecurity/compass-agent:git-` and drive one provision — + exercising the exact consumer seam + (`go/cmd/compass-runner/main.go:44-45,111-114`). This is the acceptance + check compass-native T2 repeats from the pull side. + +Interfaces: + +- Consumes: the pushed refs from T2; a rootless-podman host with the runner + built. +- Produces: a green publish run implies GHCR-resolvability; the documented + smoke procedure lives in the workflow header + `build-and-ci.md`. + +### T4 — Doc fold + +After this record freezes and T1–T3 land: add a short "Publishing the agent +image" section to `docs/architecture/build-and-ci.md` (which already owns the +CI architecture narrative) covering the ref/tag contract, the one-derivation +reproducibility guarantee, the separate-workflow rationale (least privilege, +not a gate), and the smoke procedure. This record stays the design of record; +`build-and-ci.md` carries the durable operational description. + +Interfaces: + +- Consumes: the merged T1–T3 artifacts; this record. +- Produces: an updated `docs/architecture/build-and-ci.md`; no code changes. + +## Tasks + +- [ ] T1: `agent-image/publish.sh` — spec build + immutable-guarded two-tag + skopeo push. +- [ ] T2: `.github/workflows/publish-agent-image.yml` — main-only, + path-filtered, `workflow_dispatch`, least-privilege `packages: write`, + `GITHUB_TOKEN` auth. +- [ ] T3: in-workflow `skopeo inspect` verification + documented + `podman pull` / `--image` smoke. +- [ ] Platform contract: `linux/amd64` single-arch (settled per compass-native's + frozen record #1073); T3 asserts `.Architecture`/`.Os` as the tripwire. + macOS/`aarch64` multi-arch is a forward add in the GA native-packaging + record, not here. +- [ ] T4: fold the durable publish description into + `docs/architecture/build-and-ci.md`. +- [ ] One-time (whoever lands T2): set the GHCR package **public** after the + first push (Matt's ruling — compass is OSS). +- [ ] Deferred hardening (T4-adjacent): a cheap eval-only pre-merge check + (`nix eval` the image drv without realising it) so an image-build break + is caught at PR time instead of in the post-merge publish. + +## Open Questions + +- **[Resolved] Record placement.** This record lives in the sealed design + corpus (`docs/designs/platform/`), the wave's canonical home for frozen + design records, beside the other `compass-*` records. T4 folds the durable + operational content into the compass repo's `docs/architecture/build-and-ci.md` + once implemented; this record stays the design of record in sealed. +- **[Non-load-bearing] GA release tag.** SEA-1687 will likely want + `:v` alongside `:git-`; T1's tag-list CLI makes that a + no-redesign later add. Deferred to GA planning. +- **[Non-load-bearing] `:git-` tag retention.** Immutable per-sha tags + accumulate on GHCR unbounded. No retention policy now (cost is negligible at + this volume); a later cleanup (keep last N, or prune unreferenced shas) is a + no-redesign add. Noted so it is a deliberate deferral, not an oversight. diff --git a/docs/designs/platform/compass-dogfood-e2e/design.md b/docs/designs/platform/compass-dogfood-e2e/design.md new file mode 100644 index 00000000..dbe1223f --- /dev/null +++ b/docs/designs/platform/compass-dogfood-e2e/design.md @@ -0,0 +1,854 @@ +# Compass dogfood e2e harness — full-stack scenario testing (SEA-1681) + +> **Design record.** A reusable full-stack e2e HARNESS for Compass — shared +> stack-bring-up core, a scenario-authoring API of composable primitives, two +> fidelity tiers (backend-only headless, UI-inclusive), and a model-backend +> seam (deterministic vs live) — plus the FIRST scenario set authored on it: +> the five-leg dogfood capstone (bring-up → real agent turn → agent-driven +> spawn → cross-agent messaging → persist+resume) that is the acceptance gate +> for the Dogfood milestone. The design targets the **`sealedsecurity/compass`** +> repo; every `go/*`, `proto/*`, `packages/*`, `apps/*`, and `devenv.nix:*` +> citation below is a path in that repo at origin/main HEAD `abdb412c`, not +> this one. It lives in the sealed design corpus because that is where the +> wave's design records freeze. + +Status: **Draft** — SEA-1681. Three forks are ruled by Matt (2026-08-05): +model axis (D1), per-PR gate cadence (D2), harness shape (D3); he re-ratifies +at the design-PR gate. Two forks remain open and cross-lane (OQ3 UI tier, OQ4 +leg-2 sequencing) — batched in §Open Questions; the record designs against the +recommended arm of each so no task is blocked. + +## Problem / Intent + +Every e2e proof Compass has today is a subsystem seam test: the in-process +`e2eWire` suite proves spawn/despawn over real per-container sockets but with a +stub engine where "the test plays the agent" +(`go/server/lifecycle_e2e_pgtest_test.go:455` `newE2EWire`, `:641` +`e2eStubRuntime`), the runnerhub integration proves socket-Post → store + bus +(`go/internal/runnerhub/integration_pgtest_test.go:67`), and the merged +`compass-stack` podman test stands up the real server+runner+postgres but +deliberately "creates NO containers — per-agent containers are on-demand via +the ProvisionAgentWorkspace RPC, never called here" +(`go/cmd/compass-stack/integration_podman_test.go:20-28`) and uses +`docker.io/library/alpine:latest` as a stand-in agent image (`:64-68`). Nothing +drives the REAL deployable stack — real server, real runner, a real +`compass-agent` container running a real model turn — end to end, and nothing +composes the already-proven seams into one ordered, repeatable scenario. + +SEA-1681 closes that gap, and Matt expanded its scope (direct, 2026-08-05): +the deliverable is not one 5-leg test but a **reusable e2e harness** — test +infrastructure that stands the full stack up once and exposes composable +scenario primitives (create-agent, provision, start-session/drive-turn, +spawn-peer, post-message/@mention, teardown, resume), so that full +end-to-end functionality can be tested with different scenarios, at more than +one fidelity ("may even need to be multiple harnesses, with some including the +full UI client and some not"). The five-leg dogfood capstone — (1) full-stack +bring-up, (2) agent launch + a completed model turn with the transcript +persisted, (3) agent-driven spawn of a second agent, (4) cross-agent +messaging with @mention delivery, (5) teardown + resume from durable state — +becomes the first scenario set authored on the harness and remains the +concrete Dogfood acceptance gate. + +Note on ledgers: this record lives in the sealed design corpus +(`docs/designs/platform/`), which the design-ledger-gate governs only for the +**product** corpus (`docs/designs/product/DECISIONS.md`). A platform record +adds no DECISIONS row and declares no ledger delta, so nothing here is +ledger-tracked; the `compass` repo it targets has no design-ledger tooling of +its own. + +## Global Constraints + +- **Linux-only, rootless podman.** The harness suite is gated exactly like the + merged compass-stack integration test: `//go:build podman` + + `podmanUsable()`-guarded skip — "a missing binary or broken rootless setup + means skip, not fail" + (`go/cmd/compass-stack/integration_podman_test.go:1,77-81`). Today the runner + additionally refuses any uid but 1000 (`verifyRunnerUID(os.Getuid())`, + `go/cmd/compass-runner/main.go:93`; `const defaultAgentUID uint32 = 1000`, + `:163`), which `podmanUsable()` does not probe. The capstone gate runs its CI + runner AT uid 1000, so this does not block the Dogfood full-stack tier; + lifting it to an arbitrary uid — to run on ordinary CI runners — is a + GA-milestone follow-up (SEA-1691, milestone GA), not a capstone prerequisite + (D2). The interim uid handling for embedded Dogfood is preflight-and-refuse + (compass-native T4, SEA-1685). +- **AF_UNIX sun_path budget.** `stack.Config.Validate` rejects a `RuntimeDir` + whose per-container agent-socket tail would overflow the platform sun_path + cap — on Linux "a RuntimeDir over 38 bytes overflows the cap" + (`go/internal/stack/config.go:54`, enforcement `:77-81`). The harness MUST + use the short-root pattern the podman test established (`shortRoot`, + `integration_podman_test.go:191-203`: a short unique 0700 dir under `/tmp`, + because "a t.TempDir path would overflow it" `:144-146`). +- **No retries, no sleeps — event-gated determinism.** Every assertion waits on + a real signal: the comms-bus subscription (`waitMessagePosted` checks Replay + then Live, "nothing here is a sleep", + `go/internal/runnerhub/integration_pgtest_test.go:434-439`), store reads, or + the stack's own readiness/health probes (`go/internal/stack/stack.go:230-233` + `waitReady` "polls GetServerInfo until the server answers or the budget + elapses"). Matches the delivery suite's discipline ("never a sleep, never a + retry", `go/internal/delivery/mention_test.go:8`). +- **Scripts-over-bash.** The harness and its scenarios are Go test code (and, + for the UI tier, checked-in TS test code) — never shell orchestration. A + multi-agent, multi-leg scenario with typed assertions cannot be carried by a + shell script, and the repo's automated-test lane is `go test` + (`AGENTS.md:34`). +- **Process safety on a shared box.** Teardown is process-based via the + stack's own `Down` — "SIGTERM to each child's process group … NEVER + pkill/killall — only the stack's own Down (or the exact PIDs it owns) ever + stops anything" (`integration_podman_test.go:20-28`); agent containers are + removed by exact name only (deterministic `NamePrefix + accountID`, + `go/internal/runner/spec.go:85`). +- **One harness core, tiered consumers.** The stack bring-up, scenario + primitives, and model seam live in ONE shared core; fidelity tiers + (backend-only, UI-inclusive) and model modes (deterministic, live) are + configurations/consumers of that core, never forks of it (Decision D1; + §Approach A3/A4). +- **Deterministic tier gates PRs; live/UI tiers are on-demand.** Per D1: the + backend-only + deterministic-model configuration is the per-PR CI gate and + regression base; live-model and UI-inclusive runs are on-demand/nightly + (nondeterministic, keys + cost). The Dogfood capstone gate's one feasibility + prerequisite is agent-image distribution (SEA-1690, GHCR publish); until it + lands, the interim cadence keeps every PR gated by the in-process pgtest e2e + suite while the full-stack tier runs on the merge queue + nightly. Promoting + that gate to ordinary (arbitrary-uid) CI runners is a further GA-milestone + step (SEA-1691); both are the subject of Decision D2. +- **Commits** authored as Matt with the seal co-author trailer + (rule://commit-conventions); the spawning agent ships the PR. + +## Approach + +One shared harness CORE (stack bring-up + scenario primitives + the model +seam), consumed along three axes: + +```text + ┌──────────────── harness core ────────────────┐ + │ stack.Up/Down (real server+runner+postgres) │ + │ scenario primitives (Connect clients + store)│ + │ model-backend seam (COMPASS_MODEL) │ + └──────┬────────────────────────┬──────────────┘ + fidelity axis │ │ + backend-only (headless Go suite) UI-inclusive (real UI client) + model axis: deterministic = per-PR gate │ live = on-demand smoke + scenario axis: 5-leg capstone set first; new scenarios compose primitives +``` + +**Harness shape (Decision D3, Matt): option C — a Go test suite over the +merged compass-stack bring-up (`go/internal/stack`, `stack.Up`/`stack.Stack`).** +The harness is a +`//go:build podman`-tagged Go package that imports `go/internal/stack` and the +generated Connect clients, following the pattern +`go/cmd/compass-stack/integration_podman_test.go` established. Rationale: + +- `stack.Up(ctx, cfg, deps)` is an importable Go API that stands up the REAL + shipped artifact — "private postgres up+reachable → TLS anchor → + compass-server → poll GetServerInfo readiness → runner token → agent image + present → compass-runner (token via env). On any step failure the children + started so far are drained and the lock released, so no half-started stack + leaks" (`go/internal/stack/stack.go:52-91`). A Ready stack already encodes + the whole 7-step spawn chain (`integration_podman_test.go:41-49`). +- The podman-gated, `podmanUsable()`-skipped pattern, the short-root sun_path + discipline, `buildBinariesFromModuleRoot` (compile the three child binaries + from the tree, `:88-104`), `freePorts` (`:111-129`), and per-test config via + the same `resolveConfig`/`buildDeps` the CLI uses (`:168-184`, + `go/cmd/compass-stack/main.go:146,222`) all exist on main and are reused + verbatim. +- A harness with a scenario-authoring API, typed assertions over store rows + and bus events, and a model-backend seam is Go-native; shell orchestration + (option B's shape) cannot carry a scenario API (Global Constraints, + scripts-over-bash). +- The Matt-ruled model decision (D1) requires a per-PR CI-gating deterministic + test — that belongs in the `go test` lane next to the existing pgtest lane + (`.github/workflows/ci.yml:38-44`), not in a shell script. + +What the capstone adds over compass-stack's own test: that test stops at +enrollment and never provisions an agent container +(`integration_podman_test.go:20-28`) and pulls alpine as a stand-in because +"the local-only compass-agent:latest … is not pullable, so EnsureImage's real +`podman pull` would fail" (`:30-39`; `go/internal/stack/adapters/image.go:52-65` +"the pull IS the ensure"). This harness is the FIRST thing to drive +Provision → Start → a real agent turn through the real stack — which is why +A1 below needs a real-image path around `EnsureImage`'s pull semantics. + +### A1 — Harness core: bring-up over the compass-stack `stack.Up` + +A new `go/e2e` (name final at implementation) package with a fixture that: + +1. Compiles `compass-postgres`, `compass-server`, `compass-runner` from the + tree and prepends them to PATH (the `ProcessSupervisor` resolves components + via `exec.LookPath`, `integration_podman_test.go:83-104`). +2. Builds a `stack.Config` through `resolveConfig` semantics — short-root + `RuntimeDir`, free fixed ports (`Validate` rejects `:0`, + `go/internal/stack/config.go:64-72`), private-postgres DSN — and calls + `stack.Up(ctx, cfg, deps) (*stack.Stack, error)` with real adapters. +3. Registers `Stack.Down` via `t.Cleanup` ("Down stops the stack's children in + reverse start order and releases the lock", `stack.go:141-151`), so a + t.Fatal still drains children. +4. Dials the loopback TLS door with the stack's own state-dir anchor + (`Config.StateDir` holds `tls.crt`/`tls.key`, `config.go:17-19`) and the + bootstrap-admin token the network door writes 0600 to + `/admin-token` on startup (`go/server/network_door.go:36-39` + `adminTokenFile = "admin-token"`; write-before-serve ordering per + `go/server/serve.go:442-444`), yielding authenticated generated Connect + clients for `CompassService` + `CommsService`. + +**Real agent image, not alpine.** The capstone's `Config.AgentImage` is +`compass-agent:latest` built+loaded into containers-storage by the dogfood-loop +task (`devenv.nix:349-354` `dogfood:agent-image`, opt-in). Because +`EnsureImage` unconditionally `podman pull`s — "no pre-existence check is done +by deliberate choice — the pull IS the ensure" (`adapters/image.go:52-65`) — +and a containers-storage-local image is not pullable, this gap has TWO halves. +The LOCAL half is small and owned here: the harness either (a) pre-loads the +image and satisfies the ensure with a `containers-storage:` ref podman can +resolve, or (b) grows a present-check in the image adapter — scoped as task +H1's red case, with the adapter change preferred (mirror of the podman test's +documented avoidance, `integration_podman_test.go:30-39`). The CI half is NOT +small: the image is a large self-contained NixOS closure built only by that +opt-in task, with no publish/cache pipeline to put it on a CI runner — that +distribution story is the GHCR publish pipeline (SEA-1690), folded into +Decision D2, not an adapter tweak. + +### A2 — Scenario-authoring API: composable primitives + +The core exposes primitives as methods on the fixture; scenarios are ordered +compositions of primitives plus event-gated assertions. First-class, so a new +scenario is a new function, never new plumbing. The primitives and their +grounded wire contracts: + +| Primitive | Wire contract | +| --- | --- | +| `CreateAgent(handle, displayName)` | `CommsService.CreateAgent{handle, display_name, parent_agent_id}` → `{account}` (`proto/compass/v1/comms.proto:526-542`); find-or-create by handle via `ListAccounts` (CreateAgent has no idempotency key) | +| `Provision(accountID, reqID)` | `CompassService.ProvisionAgentWorkspace{agent_account_id, client_request_id, persona}` → `{container_name}` — repo carriage REMOVED (SEA-1527): "spawn/provision no longer clone a repo … the agent self-clones whatever it needs after launch"; tags 2-4 (`remote_url`/`local_path`/`ref`) are reserved (`proto/compass/v1/compass.proto:429-459`). Stable `client_request_id` so a timeout-retry dedups (`:442-447`) | +| `StartSession(container, prompt, resumeID)` | `CompassService.StartAgentSession{container_name, initial_prompt, resume_session_id}` → `{session_id}` (`compass.proto:480-500`); `resume_session_id` set ⇒ the server "reconstructs the stored transcript into a session-JSONL body the Runner materializes into the new container" (`:488-493`) | +| `PostMessage(channel, blocks, reqID)` | `CommsService.PostMessage{channel_id, blocks, topic, client_request_id}` (`comms.proto:663-689`); an `@handle` token inside a text block is the mention surface (`go/internal/delivery/consumer.go:299-308` `mentionRE`) | +| `SubscribeComms()` | `CommsService.SubscribeComms` streaming (`comms.proto:109`) — the event-gated wait source (Replay-then-Live, the `waitMessagePosted` shape, `integration_pgtest_test.go:434-449`) | +| `AwaitSessionSettled(sessionID)` | the leg-2/leg-5 settle wait — the ONE primitive with no grounded wire contract today ("turn settled" has no pinned signal; confirmed with the compass-agent owner during H3). Candidate sources: the PG hot-tail transcript append (`agent_transcripts.go:153`) and — likely better than polling the store — `CompassService.SubscribeAgentSession(SubscribeAgentSessionRequest{session_id}) → stream AgentSessionFrame`, the typed per-session trace stream (`proto/compass/v1/compass.proto:74,413-414`), whose lifecycle frames are a push-based settle source | +| `RemoveWorkspace(container, reqID)` | `CompassService.RemoveAgentWorkspace{container_name, client_request_id}` — "symmetric with ProvisionAgentWorkspace … same idempotency contract" (`compass.proto:466-476`) | +| Store-side asserts | direct `store` reads over the private-postgres DSN: `SessionTranscript(ctx, sessionID)` (`go/internal/store/agent_transcripts.go:215`), `AgentOwner`, message rows — the harness owns the DSN, so the store of record is directly assertable | + +The spawn primitive (leg 3) is NOT a harness-called RPC: `AgentGateway. +Lifecycle{Spawn: SpawnPeerRequest{handle, display_name, initial_prompt, +client_request_id}}` is an agent→Runner call over the per-container socket +(`proto/compass/v1/agent_gateway.proto:50-56,111-128`). In the real stack the +AGENT initiates it — so the deterministic mode's canned turn must include a +spawn action (A4), and the harness asserts the RESULT (a fresh peer account +owned by the spawner's owner + a second container), the same properties +`TestSpawnDespawnOverTheWire` pins in-process +(`go/server/lifecycle_e2e_pgtest_test.go:96-160`). + +Spawn-leg observability: the harness must be able to tell "the model never +emitted the spawn tool-call" apart from "the tool-call ran and failed in-band" +(a `LifecycleCallError` comes back in-band, never as a transport teardown — +`packages/compass-agent/src/lifecycle.ts:17-21`). No tool-call-introspection +assert is scoped; the distinguishing signal is the session trace itself — the +transcript hot-tail (or the same `SubscribeAgentSession` stream above) carries +the tool-call and its in-band result, and carries neither when the model never +called the tool. H4's red-case diagnosis names which of the two it saw. + +### A3 — Fidelity axis: backend-only tier now, UI-inclusive tier gated + +**Backend-only (headless).** Everything above: real +server+runner+agent-container, driven via generated Connect clients, asserted +via bus + store. This tier is the per-PR gate and carries the whole 5-leg +scenario set. + +**UI-inclusive.** Drives a human-shaped path through the real UI client: post +a message from the client, read a thread reply rendered through the UI. The +UI today is a browser SolidJS app that dials a door from +`VITE_COMPASS_BASE_URL` + `VITE_COMPASS_CALLER_ID` (`apps/ui/src/boot.ts:1-17` +"resolving the live connection from the Vite env"; +`apps/ui/.env.development:23`), and the native shell (Wails v3, product record +`docs/designs/product/compass-native-app/design.md`) adds an embedded mode +that itself supervises the stack via the SAME `go/internal/stack` (its §A3: +"the Wails v3 shell … spawns and monitors ONE" stack) — so the harness core +and the native app share the bring-up seam by construction. HOW a test drives +the UI (headless browser against the Vite app pointed at the harness's door +vs. driving the Wails shell vs. the native-client connect path) is genuinely +unsettled and cross-lane (compass-ui / compass-native own those surfaces) — +parked as OQ3, with the UI tier scoped as a follow-on task (H7) gated on that +coordination. The load-bearing design property locked HERE: the UI tier +consumes the same harness core (the stack fixture + its door URL + admin +token) — a UI-tier scenario points a real client at the harness's door rather +than standing up a second stack shape. + +### A4 — Model axis: the model-backend seam (Decision D1) + +**Decision (Matt, relayed 2026-08-05; re-ratified at the design-PR gate): +BOTH modes over ONE shared harness with a model-backend seam.** (1) A +deterministic full-stack test — real server/runner/agent-container/comms/ +spawn/resume with a canned/stub LLM — gates every PR in CI: the regression +base. (2) A live-model smoke — the SAME harness with only the model backend +swapped for a real agent turn — runs on-demand/nightly, never per-PR +(nondeterministic, keys + cost). Deterministic-first with live as a +fast-follow is explicitly acceptable. + +The seam is shaped for this at the RUNNER level: the runner forwards a model +selector — `--agent-model` → `COMPASS_MODEL` env on the agent exec +(`go/cmd/compass-runner/main.go:50-53`; +`go/internal/runner/agent_exec.go:80`) — and the agent entrypoint treats it as +"an opaque pattern string for `createAgentSession` to resolve against its own +model registry — the entrypoint deliberately does not parse provider/id +itself, so adding a provider never touches this file" +(`packages/compass-agent/src/cli.ts:122-136` `resolveModelSelector`). But the +STACK does not forward it yet: `runnerSpec` passes only +`--runner-id/--server/--ca/--image/--runtime-dir` +(`go/internal/stack/spec.go:39-47`), and `stack.Config` has no model or +egress field (`go/internal/stack/config.go:16-43`) — so a harness (or the +native shell) driving the stack today can set neither. The model selector +COULD ride parent-env inheritance (`AgentModel: orEnv(*agentModel, +"COMPASS_AGENT_MODEL")`, `go/cmd/compass-runner/main.go:156`; the +ProcessSupervisor appends `os.Environ()` to every child's env, +`go/internal/stack/adapters/process.go:64`), but live mode's egress allowlist +CANNOT — `parseEgress(*egressHosts)` reads the flag only, no env fallback +(`main.go:115`) — so extending `stack.Config{AgentModel, EgressAllow}` + +`runnerSpec` is REQUIRED for live mode, not a nicety. H1 owns that +extension. So: + +- **Deterministic mode** = a stub/canned model provider registered in the + agent's model registry, selected by a `COMPASS_MODEL` value the harness + sets (via H1's `stack.Config.AgentModel` → `runnerSpec` extension, or + interim via the `COMPASS_AGENT_MODEL` parent-env path above). The canned + script must + produce the turn shapes the scenarios assert on: a settled text turn + (leg 2), a spawn tool-call (leg 3), a post/@mention (leg 4). The provider + is only ONE of three compass-agent-lane gaps: the entrypoint also does not + REGISTER the native lifecycle/comms tools yet (`createAgentSession` + receives `customTools: mcp.tools` only, + `packages/compass-agent/src/cli.ts:608,633`; `createLifecycleTools` is + verbatim "NOT YET WIRED", + `packages/compass-agent/src/lifecycle.ts:137-141`), and all three native + tools carry `approval: "write"` with no headless approval mode wired + (`lifecycle.ts:146,191`; `comms.ts:212`). All three gaps are scoped as + task H3 — the full agent-lane contract, coordinated with the compass-agent + owner; the seam itself — an env-selected registry entry — is the settled + design. +- **Live mode** = the same scenarios with `COMPASS_MODEL` pointing at the + LiteLLM proxy (per the sibling record's D2: LiteLLM, key via user + `SetSecret` KIND_PROVIDER), the provider credential materialized as the + 0600 `$HOME/.compass/auth-seed.json` (`packages/compass-agent/src/cli.ts:13-14`), + and the LiteLLM host added to the runner's `--egress-allow` — an empty host + set is pure default-deny (`go/internal/runtime/egress.go:29-31`), so + without it the agent's own firewall drops the provider call. Because + `--egress-allow` has no env fallback, live mode is hard-gated on H1's + `stack.Config.EgressAllow` extension (above). + +Mode is a harness configuration knob (one field on the fixture options), not a +second suite: identical scenario code, different model backend + gating. + +### A5 — The first scenario set: the five-leg capstone + +One ordered scenario (state flows leg to leg, the +`TestSpawnDespawnOverTheWire` shared-wire shape, +`lifecycle_e2e_pgtest_test.go:96-101`): + +1. **Bring-up.** `stack.Up` → Ready. Assertion: `Stack.Health` Ready + (`stack.go:153-166`); admin token present 0600. (This is compass-stack's + own proven gate, re-used as the scenario's precondition.) +2. **Agent + real turn.** `CreateAgent` → `Provision` → `StartSession(prompt)` + — the dogfood-loop T5 driver sequence (sibling record §6/T5), reused as a + harness primitive chain, not re-authored. Assertions, event-gated: the + session's transcript lands in the PG hot-tail + (`store.SessionTranscript(ctx, sessionID)` non-empty, + `agent_transcripts.go:215`; entries appended via `AppendTranscriptEntry` + `:153`), and the turn's settled frames arrive on the board/bus. "Turn + completed" = the canned (or live) turn's final settled entry is present — + the exact settle signal is confirmed with the compass-agent owner during H3 + (the dossier's outstanding DM); the harness surface for it is the + `AwaitSessionSettled` primitive (A2), with `SubscribeAgentSession`'s typed + frame stream the leading candidate source. +3. **Agent-driven spawn.** The driven turn issues `Lifecycle(Spawn)` from + INSIDE the container (A2). Assertions: `SpawnPeerResponse` carries a fresh + `agent_account_id` + `container_name`; the peer's owner is the SPAWNER'S + owner, never the caller agent or admin (the F2 property, + `lifecycle_e2e_pgtest_test.go:150-160`); a second real container exists + (podman inspect by deterministic name, `spec.go:85`). +4. **Cross-agent messaging.** Post into a shared channel with an `@handle` + mention of the peer. Assertions: `MessagePosted` fans on the comms bus + (Replay-then-Live wait); the Message row is in the store; the mentioned + peer's session receives a STEER dispatch while an unmentioned subscriber + gets a plain DELIVER (`go/internal/delivery/mention_test.go:33,59` pin + exactly this split; delivery trigger `go/internal/delivery/dispatch.go:14-31`). +5. **Persist + resume.** `RemoveWorkspace` (or stack-down of the container leg) + → `Provision` fresh → `StartSession(resume_session_id=)`. + Assertions: `ReconstructSessionBody` semantics hold — the resume body is + rebuilt from the PG hot-tail, checkpoint-first, "a valid loadable session + file BY CONSTRUCTION" (`go/internal/runnerhub/reconstruct.go:29-41,57`) — + and the resumed container materializes the session file at the agent's + resume path (the property `TestStartWithResumeBodyMaterializesSessionFile` + pins at the host layer, `go/internal/runner/host_test.go:1087`); the + resumed agent's first turn continues without a fresh-session reset. + +New scenarios (multi-peer fan-out, despawn fail-closed at full fidelity, +config push, secrets) compose the same primitives — the scenario set grows +without touching the core. + +### A6 — Teardown and idempotence + +Two leak surfaces, each with an owner: + +- **Stack children**: `Stack.Down` drains runner → server → postgres in + reverse start order (`stack.go:141-151,278-281`); registered via `t.Cleanup` + per fixture. +- **Agent containers**: NOTHING removes them on runner shutdown — "every + container lives until the Runner process ends" refers to socket lifetime + only (`go/internal/runner/host.go:203-210` `Close` tears down sockets), the + podman container survives, container names are deterministic + (`spec.go:85`), and the `client_request_id` dedup is in-memory — so a + re-run's Provision hits a `podman create` name collision (the sibling + record's T6 finding). The harness therefore does BOTH: a preflight + `podman rm -f` of its own deterministic container names before Provision, + and a `t.Cleanup` sweep of `compass-agent-*` containers it created plus the + runtime socket dir. Exact-name removal only (process safety). + +Idempotence gate: the whole scenario runs twice back-to-back green in one CI +job — the red case for any leaked state. + +### A7 — Boundary with the dogfood-loop record (no duplication) + +The sibling record (`docs/designs/platform/compass-dogfood-loop/design.md`) +owns the HUMAN dev-loop: devenv wiring (T1-T4, T6), the `compass drive` admin +driver CLI (T5, compass-server lane, **not yet built** — `go/cmd/` on main has +only compass-{gen-cert,mint-runner-token,postgres,runner,server,stack}), and a +shell-smoke over `devenv up` (T7). T5/T7 are single-agent, single-turn. This +record owns the AUTOMATED harness and is strictly broader: legs 3/4/5 (spawn, +cross-agent messaging, resume) exist only here. The leg-2 primitive chain IS +T5's driver sequence (CreateAgent → Provision → StartSession → tail) — this +harness implements that chain as library primitives against the same RPC +contracts T5 specs, so T5's CLI can later wrap the same primitives, but the +harness does NOT wait for T5's CLI to land (the RPC contracts are on main +now; the sequencing note is OQ4-adjacent, resolved in-plan: no dependency). +What leg 2 DOES depend on is a runnable `compass-agent:latest` — SEA-1359's +runtime activation (artifacts merged: `packages/compass-agent/src/cli.ts`, +`agent-image/`, `devenv.nix:281` `--image compass-agent:latest`; final +activation in progress) — flagged in H2's red case, not an open fork. + +## Alternatives considered + +- **Option B — `devenv up` (SEA-1360) + shell-script orchestration (the T7 + shape).** The dogfood loop's own mechanism: `processes.{compass-server, + compass-runner}` + `services.postgres` with ordered start and a + GetServerInfo readiness probe (`devenv.nix:166-290`), the real + `compass-agent:latest` image via the opt-in `dogfood:agent-image` task + (`devenv.nix:349-354`). Its genuine strength: it IS leg 1's shipped + bring-up mechanism, and the sibling record's T7 smoke already rides it. It + loses to C for an AUTOMATED, scenario-bearing harness: orchestration is + process-compose/shell, a scenario-authoring API with typed assertions over + store rows and bus subscriptions cannot be carried by a shell script + (scripts-over-bash), a per-PR CI gate wants the `go test` lane, and the + five-leg multi-agent scenario would fight the grain at every assertion. + `compass-stack` exists precisely as the embeddable Go equivalent of this + bring-up (`go/cmd/compass-stack/main.go:98-146` resolves the same config + shape), so C loses nothing B has except the devenv process UI — which the + human loop keeps via the sibling record. The genuine alternative Matt ruled + against (Decision D3). +- **Option A — extend the in-process `e2eWire` pgtest suite.** Insufficient by + construction: its engine is a stub (`e2eStubRuntime`, + `go/server/lifecycle_e2e_pgtest_test.go:641` — "ExecStreaming spawns a real, + terminatable child (a shell-stub `podman` exec-ing `sleep`)"), so the test + process plays the agent over the gateway socket. No real agent image, no + real container, no model turn — leg 2 is impossible, and legs 3/4/5 would + prove the stub, not the deployable stack. It remains the right home for + subsystem seam proofs (it already covers the spawn/despawn wire); the + harness composes WITH it, not instead of it. +- **A single monolithic 5-leg test (the pre-steer framing).** Rejected by + Matt's scope ruling: a one-off test leaves every future e2e scenario paying + full bring-up plumbing again. The harness/scenario split makes the 5-leg + set the first consumer, not the deliverable. +- **A second, separate UI harness with its own bring-up.** Rejected as the + default: the native shell already supervises the stack through the same + `go/internal/stack` seam (product record compass-native-app §A3), so a + UI-tier scenario can point a real client at the harness core's door. Whether + the UI tier is a build-tag/tier of one harness or a sibling package SHARING + the core is parked (OQ3) — but a fork of the bring-up itself is off the + table. + +## Plan + +Tasks are lane-tagged: `[harness]` = this record's net-new Go e2e package +(compass repo, `go/`); `[compass-agent]` = the agent-lane work inside the +agent image; `[ci]` = GitHub Actions wiring; `[ui]` = the +UI-inclusive tier (gated, cross-lane). Each task carries a real red-green +cycle (rule://red-green-testing). H1-H6 use harness shape C (Decision D3). + +### H1 [harness] — harness core: bring-up fixture over `stack.Up` + +New podman-tagged package standing up the real stack per Approach A1: build +child binaries, short-root config, `stack.Up`, `t.Cleanup(Down)`, +authenticated Connect clients over the TLS door using the state-dir anchor + +admin token. Includes the real-image path: `Config.AgentImage` resolves the +locally-built `compass-agent:latest` (adapter present-check or +`containers-storage:` ref — implementer's choice, red case below). H1 ALSO +owns the model/egress plumbing gap (A4): extend `stack.Config` with +`AgentModel` and `EgressAllow` fields and teach `runnerSpec` to forward +`--agent-model`/`--egress-allow` — today it passes only +`--runner-id/--server/--ca/--image/--runtime-dir` +(`go/internal/stack/spec.go:39-47`), and egress has no env fallback, so live +mode is unreachable without this. Cross-lane: the compass-native shell +supervises via the SAME `go/internal/stack` (A3), so this Config surface is +shared — the compass-stack and compass-native owners sign off on the field +shape before it lands. + +Interfaces: + +- Consumes: `stack.Up(ctx context.Context, cfg stack.Config, deps stack.Deps) + (*stack.Stack, error)` / `(*Stack).Down(ctx) error` / `(*Stack).Health(ctx) + (Status, error)` (`go/internal/stack/stack.go:62,145,156`); + `stack.Config{StateDir, SocketPath, ListenAddr, DatabaseDSN, AgentImage, + RuntimeDir, Linger}` + `Validate` sun_path budget + (`go/internal/stack/config.go:16-43,63-83`); the podman test's + `buildBinariesFromModuleRoot`/`freePorts`/`shortRoot` patterns + (`go/cmd/compass-stack/integration_podman_test.go:88-203`); admin token at + `/admin-token` 0600 (`go/server/network_door.go:36-39`). +- Produces: a `Fixture` with `Compass()`/`Comms()` authenticated Connect + clients, the private-postgres DSN for store-side asserts, and the stack + handle — the substrate every scenario and both fidelity tiers consume — + plus the extended `stack.Config{AgentModel, EgressAllow}` + `runnerSpec` + forwarding of `--agent-model`/`--egress-allow`: a shared seam consumed by + this harness AND the compass-native shell's embedded supervisor + (cross-lane Interfaces note for the compass-stack + compass-native owners). + +Test cycle: red — no such package on main; additionally `EnsureImage`'s +unconditional `podman pull` (`go/internal/stack/adapters/image.go:52-65`) +fails on the local-only `compass-agent:latest`, and `stack.Config` cannot +carry a model selector or egress allowlist (`config.go:16-43`). Green — +fixture reaches Ready with the REAL agent image configured, both clients +answer an authenticated RPC, a configured `AgentModel`/`EgressAllow` reaches +the runner's flags, and `Down` leaves no child processes. + +### H2 [harness] — leg-2 primitives + real-turn scenario + +The `CreateAgent → Provision → StartSession` primitive chain (A2 table) and +the leg-2 scenario asserting a completed turn. + +Interfaces: + +- Consumes: `CommsService.CreateAgent{handle, display_name} → {account}` + (`proto/compass/v1/comms.proto:526-542`); + `CompassService.ProvisionAgentWorkspace{agent_account_id, + client_request_id, persona} → {container_name}` + (`proto/compass/v1/compass.proto:429-459`); + `CompassService.StartAgentSession{container_name, initial_prompt} → + {session_id}` (`compass.proto:480-500`); transcript reads via + `(*store.Store).SessionTranscript(ctx, sessionID) ([]TranscriptEntryRow, + error)` (`go/internal/store/agent_transcripts.go:215`). +- Produces: fixture primitives `CreateAgent`/`Provision`/`StartSession` and a + green "real container runs a turn; transcript persisted to the PG hot-tail" + scenario — the first time Provision→Start→turn is driven through the real + stack (compass-stack's own test stops at enrollment, + `integration_podman_test.go:20-28`). + +Test cycle: red — running the scenario against H1's fixture with today's +stack: the turn cannot complete without H3's model backend (and SEA-1359's +final activation); the transcript assert stays empty. Green — with H3 landed, +the scenario passes; the settle signal is the one confirmed with the +compass-agent owner (H3 coordination). + +### H3 [compass-agent] — the full agent-lane contract: tool registration, deterministic provider, headless approval + +Not "a canned model provider" alone: THREE gaps in the compass-agent lane, +all of which must close before legs 3/4 are buildable — + +1. **Native tool REGISTRATION in the entrypoint.** `createAgentSession` + today receives `customTools: mcp.tools` — MCP tools ONLY + (`packages/compass-agent/src/cli.ts:608,633`); the native + `agents_spawn_peer`/`agents_despawn_peer`/`comms_post_message` tools are + never passed. `createLifecycleTools` says so verbatim: "NOT YET WIRED: + there is no container entrypoint in this repo, so this has no non-test + caller. The registration leg is tracked separately (index.ts:13 is the + seam, beside createCommsTools)" + (`packages/compass-agent/src/lifecycle.ts:137-141`; `comms.ts:204-206` + carries the same notice). A canned model emitting `agents_spawn_peer` + today gets "unknown tool". This is tracked as **SEA-1741** (child of + SEA-1359), and is pure entrypoint wiring: the transport seam already + carries both arms (`transport/index.ts:57-58` `comms()`/`lifecycle()`, + socket-wired `:103-104`), and natives registered at construction survive a + later `config.tools` control via `agent.ts` `#withNatives` (DL-028; + SEA-1532) — so no new transport or config work, only constructing the + brokers in `cli.ts main()` and adding the returned tools alongside + `customTools: mcp.tools`. +2. **The canned/scripted provider MECHANISM.** A deterministic provider + selectable via `COMPASS_MODEL`, packaged in the agent image, whose + scripted turns produce the shapes the scenario set needs: a settled text + reply (leg 2), a spawn tool-call (leg 3), a post with an `@handle` + mention (leg 4), a resumable continuation (leg 5). The confirmed + mechanism (compass-agent owner, 2026-08-05): a `models.yml` custom entry + whose `baseUrl` points at a stub OpenAI-compatible server the harness + stands up, returning the canned tool-calls per turn — a test-fixture + concern, explicitly NOT part of SEA-1741. The injection point is the + existing opaque-selector seam — no new selector wiring. +3. **Headless APPROVAL semantics.** All three native tools carry + `approval: "write"` (`lifecycle.ts:146,191`; `comms.ts:212`) and neither + cli.ts nor agent.ts wires any approval mode. Confirmed (compass-agent + owner, 2026-08-05): the container runs headless with write-approval tools + auto-executing (yolo default) — there is no human to approve. This task + pins that approval policy in the entrypoint. + +**RECOMMENDED: fold gap (1) — tool registration (SEA-1741) — and gap (3) — +the headless approval policy — into SEA-1359's final runtime activation (its +own recommended arm, OQ4): ONE image change, not separate respins.** Gap (2), +the canned provider, is a harness-side test fixture (stub `baseUrl`) that +rides H1/H2, not the agent image. H4's green condition depends on all three +landing; without them it is unreachable. + +Interfaces: + +- Consumes: `--agent-model` → `COMPASS_MODEL` + (`go/cmd/compass-runner/main.go:50-53`; + `go/internal/runner/agent_exec.go:80` `spec.Env["COMPASS_MODEL"]`); + `resolveModelSelector(env) string|undefined` feeding + `createAgentSession`'s model registry + (`packages/compass-agent/src/cli.ts:122-136`); the registration seam + beside `createCommsTools` (`index.ts:13` per the lifecycle.ts docstring) + and `createAgentSession`'s `customTools` option (`cli.ts:608-633`). +- Produces: a registry-resolvable deterministic provider (exact packaging — + registry entry vs. local OpenAI-compatible endpoint baked into the image — + is the compass-agent owner's call, coordinated at implementation) + the + canned scripts for the four turn shapes + the entrypoint registering the + native lifecycle+comms tools alongside the MCP tools + a pinned headless + approval policy for write-approval tools; documented selector value the + harness sets. + +Test cycle: red — `COMPASS_MODEL=` today resolves nothing and the +session falls back to the SDK default (`cli.ts:128-129`), and a scripted +spawn tool-call gets "unknown tool" (native tools unregistered, +`cli.ts:633`). Green — the agent in a real container completes the scripted +turn, its registered native tools execute headlessly under the pinned +approval policy, with zero network egress (deterministic mode keeps +`--egress-allow` empty — default-deny, `go/internal/runtime/egress.go:29-31` +— which doubles as proof no live model is reachable). + +### H4 [harness] — legs 3+4: spawn + cross-agent messaging scenario + +Leg 3: the canned turn issues `Lifecycle(Spawn)`; assert fresh peer account +with F2 ownership + second real container. Leg 4: post + `@mention`; assert +bus fan, store row, steer-vs-deliver split. + +Interfaces: + +- Consumes: `AgentGateway.Lifecycle{Spawn: SpawnPeerRequest} → + {SpawnPeerResponse{agent_account_id, container_name}}` + (`proto/compass/v1/agent_gateway.proto:50-56,111-128`) — issued BY the + agent, asserted by the harness; `CommsService.PostMessage{channel_id, + blocks, client_request_id}` (`comms.proto:663-689`); + `CommsService.SubscribeComms` Replay-then-Live waits + (`go/internal/runnerhub/integration_pgtest_test.go:434-449`); mention + routing contract: mentioned member → steer, unmentioned subscriber → + deliver (`go/internal/delivery/mention_test.go:33,59`; `mentionRE` + `consumer.go:299-308`). +- Produces: `PostMessage`/`SubscribeComms`/`AwaitDelivery` primitives + the + legs-3/4 scenario green over the real stack. + +Test cycle: red — the in-process suite proves these seams with a stub engine +only; over the real stack the composed path is unexercised — and UNREACHABLE +until H3 lands in full: without registration (H3.1) the canned spawn call is +"unknown tool", and without a headless approval policy (H3.3) a +write-approval tool may never execute. Green — gated on H3 (1)+(2)+(3): one +ordered run: spawn observed, second container inspected by exact name +(`go/internal/runner/spec.go:85`), steer reaches the mentioned peer's real +session, deliver reaches the unmentioned one. Red-case diagnosis reads the +session trace (transcript hot-tail / `SubscribeAgentSession`) to tell "model +never emitted the tool-call" from "tool-call failed in-band" (A2 spawn-leg +note). + +### H5 [harness] — leg 5: persist + resume scenario + +Tear the leg-2 agent's container down (`RemoveAgentWorkspace`), re-provision, +start with `resume_session_id`, assert continuation from durable state. + +Interfaces: + +- Consumes: `CompassService.RemoveAgentWorkspace{container_name, + client_request_id}` (`proto/compass/v1/compass.proto:466-476`); + `StartAgentSessionRequest.resume_session_id` (`compass.proto:488-493`); + reconstruction semantics `(*Hub).ReconstructSessionBody(ctx, sessionID) + ([]byte, error)` — PG hot-tail, checkpoint-first, loadable by construction + (`go/internal/runnerhub/reconstruct.go:29-41,57`); host-layer + materialization property (`go/internal/runner/host_test.go:1087` + `TestStartWithResumeBodyMaterializesSessionFile`). +- Produces: `Remove`/`Resume` primitives + the leg-5 scenario: a NEW container + resumes the leg-2 session and its first resumed turn sees prior transcript + state (canned script asserts on carried context deterministically). + +Test cycle: red — resume across a REAL container boundary is unexercised on +main (host_test pins it with fakes). Green — the resumed session's transcript +continues the same `session_id` lineage and the resumed turn completes. + +### H6 [harness] — teardown + idempotence + +Preflight exact-name `podman rm -f` + cleanup sweep per Approach A6; the +double-run gate. + +Interfaces: + +- Consumes: deterministic container names (`NamePrefix + accountID`, + `go/internal/runner/spec.go:85`); socket-only runner shutdown semantics + (`go/internal/runner/host.go:203-210`); `Stack.Down` reverse-order drain + (`go/internal/stack/stack.go:141-151`). +- Produces: a harness whose full scenario set runs twice back-to-back green + in one job; no leaked containers, processes, or short-root state. + +Test cycle: red — two consecutive runs without cleanup: the second Provision +hits the podman name collision (in-memory `client_request_id` dedup is gone +after restart — the sibling record's T6 finding). Green — double-run passes. + +### H7 [ui] — UI-inclusive tier (gated follow-on; cross-lane) + +A scenario that drives a human-shaped path through the real UI client against +the H1 fixture's door: post from the client, observe the agent's reply +rendered. Gated on OQ3 (drive shape) and coordination with compass-ui / +compass-native; lands as a fast-follow unless Matt rules it into the first +increment (OQ3). + +Interfaces: + +- Consumes: the H1 fixture's door URL + admin/caller identity; the UI's env + connection contract `VITE_COMPASS_BASE_URL` + `VITE_COMPASS_CALLER_ID` + (`apps/ui/src/boot.ts:1-17`; `apps/ui/.env.development:23`); the native + shell's stack seam (product record compass-native-app §A3 — same + `go/internal/stack`). +- Produces: one UI-tier scenario proving the human-shaped path end to end; + the tier mechanism (headless browser vs Wails shell) per the OQ3 ruling. + +Test cycle: red — no automated path drives the UI against a live stack today +(UI tests run against fakes, `apps/ui/src/live/comms-fake.ts`). Green — the +UI-tier scenario passes against the harness fixture. + +### H8 [ci] — GitHub Actions wiring and gating (Decision D2) + +Wire the deterministic backend-only tier into the repo's ACTUAL CI — GitHub +Actions (`.github/workflows/ci.yml`; the repo has no Woodpecker config, so any +Woodpecker migration is out of scope for this record). Per Decision D2 the +Dogfood end state is a required per-PR check running the full-stack +deterministic tier — on a uid-1000 runner, its one prerequisite being SEA-1690, +which publishes `compass-agent` to GHCR so `EnsureImage`'s unconditional pull +resolves in CI (needed regardless, part of getting this e2e test into CI). +Until it lands, this task wires the interim cadence: the full-stack +deterministic suite runs on the merge queue and nightly, and the existing +in-process pgtest e2e step remains the per-PR gate +(`.github/workflows/ci.yml:296-316`) — never a skip-configured required check, +which would pass vacuously green. Promoting the check further to ORDINARY +(arbitrary-uid) runners is a GA-milestone follow-up (SEA-1691), which lifts the +uid-1000 requirement (`verifyRunnerUID`, +`go/cmd/compass-runner/main.go:93,178-187` — which `podmanUsable()` does not +probe, `integration_podman_test.go:74-81`, so on a podman-capable runner at +uid ≠ 1000 the suite would otherwise go RED, not skip); it is not a Dogfood +prerequisite. + +Interfaces: + +- Consumes: `go test -tags podman ./go/e2e/...` (mirroring the pgtest step + shape, `.github/workflows/ci.yml:296-316`); H3's documented `COMPASS_MODEL` + selector; the GHCR image ref (SEA-1690); the uid lift (SEA-1691); live-mode + secrets (LiteLLM key) injected only in the nightly workflow, never per-PR. +- Produces: interim (pre-SEA-1690) — a merge-queue/nightly full-stack job with + the per-PR gate remaining the in-process pgtest e2e suite; Dogfood end state + (post SEA-1690) — a required per-PR check running the deterministic full-stack + tier on a uid-1000 runner; GA end state (post SEA-1691) — the same check + promoted to ordinary arbitrary-uid runners. Either way: an on-demand/nightly + live-mode job, and documented runner prereqs (Linux, rootless podman, + subuid/subgid). + +Test cycle: red — no CI job compiles the podman-tagged suite (build tags keep +it out of `go test ./...`); a naive required check on a GitHub-hosted runner +(uid ≠ 1000, pre-SEA-1691) reds on `verifyRunnerUID`, and a skip-configured +one passes vacuously. Green — the interim cadence runs the full-stack tier on +the merge queue + nightly and reports honestly while the pgtest e2e suite +gates per-PR; once SEA-1690 lands the full-stack tier promotes to the required +per-PR check (uid-1000 runner), and SEA-1691 later widens it to ordinary +runners; the nightly live run reports without gating throughout. + +## Tasks + +- [ ] H1 [harness] harness core: bring-up fixture over `stack.Up`, real agent image, authenticated clients (red-green + clean-Down smoke) +- [ ] H2 [harness] leg-2 primitives (CreateAgent → Provision → StartSession) + real-turn scenario with transcript assert (red-green) +- [ ] H3 [compass-agent] full agent-lane contract: native tool registration in the entrypoint + deterministic provider behind the `COMPASS_MODEL` seam + headless approval policy; canned scripts for all four turn shapes (red-green + zero-egress proof; recommended folded into SEA-1359's activation) +- [ ] H4 [harness] legs 3+4 scenario: agent-driven spawn (F2 ownership + second container) and cross-agent @mention (steer/deliver split) (red-green; green gated on H3 1-3, i.e. SEA-1741 tool registration + the canned provider) +- [ ] H5 [harness] leg-5 scenario: remove → re-provision → resume across a real container boundary (red-green) +- [ ] H6 [harness] teardown + idempotence: exact-name preflight/cleanup; double-run gate (red-green via second-run provision) +- [ ] H7 [ui] UI-inclusive tier scenario — gated on OQ3 + compass-ui/compass-native coordination (fast-follow unless ruled otherwise) +- [ ] H8 [ci] GitHub Actions wiring (D2): interim merge-queue/nightly full-stack + pgtest e2e per-PR gate; promotes to a required per-PR full-stack check (uid-1000 runner) once SEA-1690 (GHCR image) lands; widens to ordinary arbitrary-uid runners once SEA-1691 (uid lift, GA) lands; live tier on-demand/nightly + +## Decisions + +1. **D1 — Model axis (Decision, Matt, relayed 2026-08-05; re-ratified at the + design-PR gate): BOTH modes, ONE shared harness, a model-backend seam.** + A deterministic full-stack test (real server/runner/agent-container/comms/ + spawn/resume, canned/stub LLM) gates every PR in CI as the + regression base; a live-model smoke is the SAME harness with only the + model backend swapped, run on-demand/nightly, never per-PR + (nondeterministic, keys + cost). Deterministic-first with live as a + fast-follow is explicitly acceptable. Folded through Approach A4 and + tasks H3/H8. +2. **D2 — Per-PR gate cadence (Decision, Matt, 2026-08-05).** The full-stack + deterministic tier is the per-PR gate; the feasibility constraints in its way + are FIXED, not worked around. Agent-image distribution rides SEA-1690 + (publish `compass-agent` to GHCR), which is needed regardless and becomes + part of getting this e2e test into CI — it is the one prerequisite for the + Dogfood per-PR gate, which runs its CI runner at uid 1000. The runner's + uid-1000 requirement is a known limitation that "can't be required long + term"; lifting it to arbitrary uids (SEA-1691, milestone GA) promotes the + gate to ordinary CI runners, a GA-timeframe step — NOT a Dogfood-capstone + prerequisite (the interim uid handling for embedded Dogfood is the + preflight-and-refuse of compass-native T4, SEA-1685). Until SEA-1690 lands, + the interim cadence runs the full-stack tier on the merge queue + nightly + while the existing in-process pgtest e2e suite remains the per-PR gate; once + it lands, the full-stack tier promotes to the required per-PR check. + Supersedes the drafted OQ5 arms: neither a bespoke uid-1000 runner ([A]) nor + a permanent nightly-only fallback ([B]) — fix the constraints, gate per-PR. + Folded through Global Constraints, Approach A1, and task H8. +3. **D3 — Harness shape (Decision, Matt, 2026-08-05): option C.** A + `//go:build podman` Go test suite over the merged compass-stack bring-up + (`stack.Up`/`stack.Stack`, `go/internal/stack`), not + option B (`devenv up` + script orchestration). A scenario-authoring API with + typed store/bus assertions and a `go test` per-PR gate are Go-native; a + shell script cannot carry either. Option B remains leg 1's own shipped + mechanism for the HUMAN dev loop (sibling record), recorded in + §Alternatives. Folded through Approach (head) and the Plan. + +## Open Questions + +Batched for Matt at the design-PR gate; the record designs against the +recommended arm of each so no task is blocked on a ruling. + +OQ numbering is preserved from the pre-ruling draft so cross-references stay +stable: OQ1/OQ2 were resolved into Decisions D1-D3 and OQ5 was superseded by +D2 (see §Decisions); OQ3/OQ4 remain open. + +1. **OQ3 — UI-inclusive tier: drive shape and packaging.** How a test drives + the real UI: (a) a headless browser driving the Vite app pointed at the + harness door via `VITE_COMPASS_BASE_URL`/`VITE_COMPASS_CALLER_ID` + (`apps/ui/src/boot.ts:1-17`), (b) driving the Wails v3 native shell + (embedded or native-client mode, product record compass-native-app), or + (c) the native-client connect path against the harness's door. Coupled: + one harness with tiers vs multiple harnesses sharing the core — + **recommended: a shared core with tiered harness packages** (a bring-up + fork is off the table, §Alternatives). Also: does the UI tier land in the + first increment or as a fast-follow (recommended: fast-follow, H7)? + Cross-lane coordination point with compass-ui and compass-native; their + owners should weigh in before the ruling. Sequencing (coordinator, not a + fork): this is parked-by-dependency, not dropped — compass-ui #1075 (the UX + foundation rewrite) is itself parked pending a brand/product-site freeze, and + the UI-shell shape OQ3 forks on (Wails native vs headless Vite vs + native-client) is exactly what #1075 settles. So OQ3 is a recommended + fast-follow (H7) off the capstone critical path; rule it once #1075 unparks + and the shell is finalized, not against a moving target. +2. **OQ4 — Leg-2 activation dependency (SEA-1359).** Leg 2 needs a runnable + `compass-agent:latest` doing a real (canned or live) turn. The artifacts + are on main (`packages/compass-agent/src/cli.ts`, `agent-image/`, + `devenv.nix:281` runner `--image compass-agent:latest`) but SEA-1359's + final runtime activation is In Progress. Is the capstone's H2/H3 sequenced + strictly after SEA-1359 closes, or may H3's deterministic backend land as + part of the activation itself (one image change instead of two)? + **Recommended: coordinate H3 into the SEA-1359 owner's lane as a single + image change.** Load-bearing for sequencing only — the design is identical + either way. + +Non-load-bearing deferrals (explicitly NOT open questions): the harness +package's final name/location under `go/`; the exact canned-provider +packaging inside the agent image (H3, compass-agent owner's call within the +settled `COMPASS_MODEL` seam); the exact turn-settle signal the leg-2 assert +waits on (confirmed with the compass-agent owner during H3 — candidates are +the PG hot-tail transcript append, the session-status transition, and the +`SubscribeAgentSession` frame stream, `compass.proto:74` — surfaced as the +`AwaitSessionSettled` primitive either way); whether `EnsureImage` grows a +present-check vs the harness using a resolvable `containers-storage:` ref +(H1, implementer's choice — the CI-distribution half of the image story is +Decision D2 via SEA-1690, not deferrable). diff --git a/docs/designs/platform/compass-dogfood-loop/design.md b/docs/designs/platform/compass-dogfood-loop/design.md new file mode 100644 index 00000000..1b8fa157 --- /dev/null +++ b/docs/designs/platform/compass-dogfood-loop/design.md @@ -0,0 +1,635 @@ +# Compass dogfood loop + +> **Design record.** The full `devenv up` dogfood loop for Compass: build + load +> the `compass-agent` image, bring up postgres + a TLS-door `compass-server`, +> mint a runner token, enroll `compass-runner`, and drive one real agent session +> end to end. The design targets the **`sealedsecurity/compass`** repo (its +> `devenv.nix`, `agent-image/`, and `go/cmd/*` binaries); every `devenv.nix:*`, +> `agent-image/*`, and `go/cmd/*` citation below is a path in that repo at HEAD +> `21241f720`, not this one. It lives in the sealed design corpus because that is +> where the wave's design records freeze. + +Status: **decided** — SEA-1360. Every fork (D1–D5) ruled by Matt; the record is +the frozen contract the implementation reads. Design-first per AGENTS.md. + +## Problem / Intent + +`devenv up` today brings up only the human-facing half of Compass: postgres plus +a `compass-server` serving the Unix socket and the loopback dev-http door +(`devenv.nix:164-167`: `--socket "$COMPASS_SOCKET" --dev-http 127.0.0.1:… --database "$COMPASS_DATABASE_DSN"`). +The Runner half — the TLS network door, a runner enrollment, the agent image, +and a real per-agent container running an agent turn — is never exercised +locally. Matt ruled a FULL dogfood loop, designed first: on `devenv up`, the +`compass-agent` OCI image is built and loaded into rootless-podman +containers-storage, postgres + compass-server come up with the TLS network door +open, a runner token is minted, `compass-runner` enrolls (trusting the +self-signed cert) and idles, and a real session is driven end to end +(`CommsService.CreateAgent` → `CompassService.ProvisionAgentWorkspace` → +`StartAgentSession`), so a real container spawns and runs an agent turn. + +Note on ledgers: this record lives in the sealed design corpus +(`docs/designs/platform/`), which the design-ledger-gate governs only for the +**product** corpus (`docs/designs/product/DECISIONS.md`). A platform record adds +no DECISIONS row and declares no ledger delta, so nothing here is ledger-tracked; +the `compass` repo it targets has no design-ledger tooling of its own. + +## Global Constraints + +- **Linux-only.** Every dogfood process/service/task lives under the existing + guards: `services.postgres = lib.optionalAttrs pkgs.stdenv.isLinux` + (`devenv.nix:124`), `processes = lib.optionalAttrs pkgs.stdenv.isLinux` + (`devenv.nix:131`), and the agent image itself is + `containers = lib.optionalAttrs pkgs.stdenv.isLinux` (`agent-image/devenv.nix:65`). +- **Rootless podman + uid 1000.** The runner refuses any other uid: + `verifyRunnerUID` enforces `defaultAgentUID uint32 = 1000` + (`go/cmd/compass-runner/main.go:148`, `:79-81` "Ahead of every operator-input + check"). Containers launch with plain `--userns=keep-id` + (`agent-image/devenv.nix:73-74` citing `internal/runtime/podman.go:357`). + Prereqs on the dev box: rootless podman with subuid/subgid configured + (verified on this box: podman 5.8.4, rootless, uid 1000). +- **One shared self-signed cert.** The gen-cert output "is both the Server's + `--tls-cert` and the Runner's `--ca` trust anchor" + (`go/cmd/compass-gen-cert/main.go:50-51`); no second keypair, no relaxed + loopback TLS. +- **Idempotent tasks (F2, DECIDED).** gen-cert skips when both files exist + (`main.go:85-89` `shouldSkipGen`); mint with `--token-out` is + skip-if-present/heal-without-rotate (`compass-mint-runner-token/main.go:53-59`). + Re-running `devenv up` mints/generates nothing new. +- **Ordering (F3, DECIDED).** gen-cert → postgres → compass-server(ready) → + mint-runner-token → compass-runner. Server readiness stays the existing + GetServerInfo probe (`devenv.nix:191-197`), which flips only after the store + is migrated. +- **Session drive is opt-in (D5).** The image build and the live session + drive land as opt-in tasks, not wired into `devenv up`; `up` itself stays + light. +- **Lane split.** compass-repo lane owns all `devenv.nix` wiring; the + compass-server lane owns the net-new admin session driver (D3), with a + scoped compass-runner mount flag (T5a). Tasks below are tagged so the wiring + ships independently of the driver. +- **Commits** authored as Matt with the seal co-author trailer + (rule://commit-conventions); the spawning agent ships the PR. + +## Approach + +Extend the existing `devenv up` bring-up (postgres + compass-server, +`devenv.nix:115-200`) with the Runner half, composed from binaries that already +exist for exactly this purpose — no new server code is needed for enrollment; +the only net-new code is the admin session driver (compass-server lane, D3). + +### Process/task graph + +```text +tasks: dogfood:gen-cert ──────────┐ +processes: postgres ── compass-server(+TLS door, ready=GetServerInfo) + │ +tasks: dogfood:mint-runner-token ◄─┘ (after server ready) +processes: compass-runner (enrolls, idles) ◄─ token file + cert +opt-in: dogfood:agent-image (build+load), dogfood:session (drive a turn) +``` + +All new units go under the existing Linux guards next to `compass-server` +(`devenv.nix:131`). Ordering uses devenv's task/process dependency names — +`after = [ "devenv:processes:" ]` with `@ready` the default for +processes and `@succeeded` for tasks (`forks/devenv/src/modules/processes.nix:186-191`: +"Use task names like \"devenv:processes:postgres\" or \"myapp:setup\""). + +### 1. gen-cert (idempotent task) + +A `tasks."dogfood:gen-cert"` runs +`compass-gen-cert --cert-out $STATE/compass/tls.crt --key-out $STATE/compass/tls.key`. +Defaults suffice: SANs default to `127.0.0.1,::1,localhost` +(`go/cmd/compass-gen-cert/main.go:38` `const defaultHosts = "127.0.0.1,::1,localhost"`), +and the run is skip-if-present — "when both files already exist, leave them be +so a restart does not swap the cert out from under a live Server/Runner pair" +(`main.go:82-84`). Cert is written 0644 (public trust anchor), key 0600 +(`main.go:49-53`). The binary is built the same way the server process builds +its binary today (`devenv.nix:158-163` go build into `$DEVENV_STATE/compass/`). + +### 2. compass-server grows the TLS network door + +Add `--listen 127.0.0.1: --tls-cert $STATE/compass/tls.crt --tls-key +$STATE/compass/tls.key` alongside the existing `--socket`/`--dev-http` argv +(`devenv.nix:164-167`), with a `ports.network.allocate` port. The three flags +are all-or-none (`go/cmd/compass-server/main.go:146-154`: "Either all three are +set (the authenticated TCP door is enabled) or none are"). Consequences, all +existing behavior: + +- The network door mounts CompassService + CommsService behind bearer + admin + interceptors, "plus the internal RunnerService door a Runner enrolls over" + (`go/server/serve.go:269-273`); the RunnerService door exists ONLY here — + "Runners are remote, so they dial the authenticated TLS door, never the + loopback socket" (`serve.go:199-201`). +- The bootstrap-admin token is minted and written 0600 to + `/admin-token` on network-door startup (`go/server/network_door.go:35-38` + `adminTokenFile = "admin-token"`; `serve.go:269-271` "It mints and writes the + bootstrap token 0600 under the state dir"). StateDir defaults to the socket's + parent dir (`network_door.go:249-256`), i.e. `$DEVENV_STATE/compass/` given + `COMPASS_SOCKET = "${config.devenv.state}/compass/server.sock"` + (`devenv.nix:174`). The driver reads this file for its bearer credential. + The write is SERIAL before any door serves: `Serve` runs bindListeners → + `store.Open` (migrate) → `BootstrapAdmin` → `buildNetworkServer` (which + synchronously mints + writes the token) and only then starts the serve + goroutines in one errgroup (`serve.go:106-115,168-186,276-306`; + `network_door.go:249-258`) — so GetServerInfo cannot answer before the token + exists, and there is no race for the driver. T7's "admin-token exists 0600 at + server-ready" smoke is the regression guard for this implicit ordering. +- Migrations still run at store open (`go/internal/store/store.go:55-57` + "applies any pending embedded migrations under an advisory lock"), and the + existing readiness probe (GetServerInfo over dev-http, `devenv.nix:184-197`) + already gates on post-migration serving — mint can safely depend on it. + +### 3. mint-runner-token (idempotent task, after server ready) + +`tasks."dogfood:mint-runner-token"` runs +`compass-mint-runner-token --runner-id dogfood --token-out $STATE/compass/runner.token` +with `COMPASS_DATABASE_DSN` set to the same DSN the server uses +(`go/cmd/compass-mint-runner-token/main.go:100-101`: "resolveDSN mirrors +compass-server's precedence exactly"). It must run after the server is ready +because `store.Open` verifies the migrated schema (`main.go:88`, +`store.go:55-59` "refusing to serve on a failed migration or a version +mismatch") — ordering: `after = [ "devenv:processes:compass-server" ]` +(default `@ready`). With `--token-out` the mint is idempotent: "if the file +exists and its token is already registered in the store, it no-ops; if the +file exists but the store no longer knows the token (e.g. the database was +replaced), it re-registers that same token without rotating it" +(`main.go:53-58`). File is 0600, written atomically, raw token no newline +(`main.go:192-196`). + +### 4. compass-runner (process; enrolls and idles) + +A `processes.compass-runner` execs: + +```text +compass-runner --runner-id dogfood \ + --server https://127.0.0.1: \ + --ca $STATE/compass/tls.crt \ + --image compass-agent:latest \ + --runtime-dir $STATE/compass/runner +COMPASS_RUNNER_TOKEN=$(cat $STATE/compass/runner.token) # env only, never a flag +``` + +Grounding: `--runner-id`/`--server`/`--image`/`--ca`/`--runtime-dir` flags at +`go/cmd/compass-runner/main.go:40-57`; the token is "env only, never a flag (a +flag leaks into the process table)" (`main.go:91-93`); `--ca` "swaps the +system root pool for a single trusted CA — the local dogfood path, where the +Server's self-signed 127.0.0.1 cert is the trust anchor" (`main.go:106-108`). +The image ref includes the tag: the runner resolves it out of +containers-storage with no pull (`agent-image/devenv.nix:93-97`), and the +runtime documents the ref shape as "e.g. compass-agent:latest" +(`go/internal/runtime/podman.go:71-72`). `--runtime-dir` overrides the +`/run/compass` default (`main.go:56-57`) — root-only to create — to a +state-dir path owned by uid 1000: the per-container agent socket is created at +`RuntimeDir/containers//agent.sock` (`main.go:56-57` flag doc), so +the dir must be writable by the runner's uid or socket creation EACCESes. +On start the runner enrolls then idles: `Dial` performs the `Enroll` RPC with +the bearer interceptor (`go/internal/runner/runner.go:101-113`), then +`link.RunSessions` "blocks until the stream ends" awaiting Provision/Start +commands (`go/internal/runner/run.go:96-121`). Ordering: +`after = [ "dogfood:mint-runner-token" ]` (a task dep, default `@succeeded`). +The uid-1000 guard runs before any flag validation (`main.go:75-81`), so a +wrong-uid box fails fast with a named cause. + +Egress: `--egress-allow` stays EMPTY (pure default-deny) for the base loop — +the `local_path` file:// clone needs no network and no credentials +(`go/internal/runtime/workspace.go:109-110`). Note the consequence for a real +turn: "An empty host set is pure default-deny (only loopback, established +flows, and DNS to the container's own resolver)" +(`go/internal/runtime/egress.go:29-31`), enforced by nftables armed at launch +(`go/internal/runtime/agent.go:148-150`) — so the agent's LLM provider call is +dropped by its own firewall until the provider host is added to +`--egress-allow`. That addition rides the real-turn leg (D1/D2 — the LiteLLM +proxy host is added when the credential path lands); do NOT add a provider +host to the base argv. + +### 5. Agent image build + load (opt-in task, VERIFIED command) + +`tasks."dogfood:agent-image"` runs, from `agent-image/`: + +```bash +nix run path:./forks/devenv#devenv -- container copy agent +``` + +This single command builds AND loads: `container_copy` first calls +`container_build` (`forks/devenv/devenv/src/devenv/container.rs:54` +`let spec = self.container_build(name).await?;`), then runs the copy script. +With `--registry` omitted the CLI passes `"false"` (`container.rs:85` +`"registry": registry.unwrap_or("false")`) and the script falls back to the +container's configured registry (`forks/devenv/src/modules/containers.nix:288-292` +`if [[ "$1" == false ]]; then registry="${cfg.registry}"`), which agent-image +pins to `registry = "containers-storage:"` (`agent-image/devenv.nix:98`). The +destination is `${registry}${cfg.name}:${cfg.version}` (`containers.nix:295`) +with `version` defaulting to `latest` (`containers.nix:324-327`) — i.e. +`containers-storage:compass-agent:latest`, exactly what the runner's +`--image compass-agent:latest` resolves. (`devenv container build agent` alone +builds the derivation but loads nothing — `container.rs:16-45` only +nix-builds.) The invocation is pinned to the vendored fork's own CLI via +`nix run path:./forks/devenv#devenv` rather than the PATH `devenv`: the +agent-image devenv pins only its MODULE set to the fork +(`agent-image/devenv.yaml:44-45`), so a PATH CLI could diverge from the +verified fork source — the pin eliminates that risk. + +This task is opt-in (not `after`-wired into `up`, D5): the image +closure is large ("The agent's store is large and grows", +`agent-image/devenv.nix:100-105`) and rebuilding on every `up` violates the +never-heavy-on-up constraint. The runner starts fine without the image present +— it only resolves the image at Provision time. + +### 6. The session drive (net-new admin driver — compass-server lane) + +The session drive is a net-new admin client owned by the compass-server lane +(confirmed with that owner): a `compass-dogfood` / `compass drive` CLI verb +under `go/cmd`, sequenced AFTER ITEM 7 (secret key-delivery) and ITEM 1 +(spawn/despawn record) so the driven agent can actually receive its LLM key. +It: + +1. Reads the bootstrap-admin token from `$STATE/compass/admin-token` + (`network_door.go:35-38`) and dials `https://127.0.0.1:` + trusting `$STATE/compass/tls.crt`, sending `Authorization: Bearer `. +2. `CommsService.CreateAgent{handle, display_name}` — the request carries only + those two fields (`proto/compass/v1/comms.proto:450-458`); "The owner is + the caller" (`comms.proto:40-42`). CreateAgent is `authenticatedOpen` on + the network door (`go/internal/auth/admin_gate.go:68-71`), so the admin + bearer passes. Idempotency: find-by-handle via `ListAccounts` first, create + on miss (CreateAgent has no idempotency key). +3. `CompassService.ProvisionAgentWorkspace{agent_account_id, local_path, + ref, client_request_id}` — adminOnly (`admin_gate.go:50-56`), relayed + Server → RunnerHub → Runner (`go/server/service.go:107-137`, + `go/internal/runnerhub/commands.go:48`). `client_request_id` set to a + stable key so "a timeout-retry with the same id returns the same + container_name" (`compass.proto:342-348`). Repo source: `local_path` + (D4) — "local_path clones a container-local bare mirror over + file:// for a hermetic, network-free clone" (`compass.proto:331-335`; + `go/internal/runtime/workspace.go:20-23` notes it is "typically a bare + mirror bind-mounted read-only from a host cache"). A file:// clone needs no + forge credentials (`workspace.go:109-110` "a file:// clone of a local + mirror needs none"). +4. `CompassService.StartAgentSession{container_name, initial_prompt}` + (`compass.proto:359-365`) with a trivial smoke prompt; returns the + `session_id` (`service.go:147-157`). + +**Mount-surface blocker (LOAD-BEARING).** The `local_path` mirror path is +unimplementable against the binaries at this HEAD: the RPC carries only an +in-container path string — `validateLocalPath` constrains it to "a plain +absolute in-container path" (`go/internal/runner/spec.go:202-218`) — and +container mounts come EXCLUSIVELY from `SpecDefaults.Mounts` +(`spec.go:26-32,107` `Mounts: d.Mounts`), which `cmd/compass-runner/main.go` +never populates: the `SpecDefaults` literal sets only +Image/Egress/CheckoutDir/HomeDir/UID/NamePrefix (`main.go:117-124`) and the +flag set has no `--mount`/`--volume` (`main.go:40-64`). `runtime.Mount` exists +and is wired through `podman` (`go/internal/runtime/agent.go:42-43` "Mounts is +read-only host mounts (e.g. a bare-repo mirror cache)", `:213`), but no +operator surface reaches it — so no host bare mirror can become +container-visible, and Provision's file:// clone would target a nonexistent +path. The missing piece is a small runner-lane flag (e.g. repeatable +`--mount host:container[:ro]` into `SpecDefaults.Mounts`); this mount-surface +flag is scoped as T5a (D3); the driver ownership itself is resolved +(compass-server lane). + +Ownership and shape are RESOLVED: the compass-server lane owns this driver as a +committed `compass-dogfood` / `compass drive` Go CLI (reusing the generated +Connect clients + the CA-trust client), which reads the admin token, then +CreateAgent → ProvisionAgentWorkspace → StartAgentSession → tails the session. +The runner mount surface below is the T5a scope (D3); the driver is a +compass-server-lane `compass-dogfood` / `compass drive` CLI — the record specs +the RPC contract, the owning lane implements it. + +### Model credentials (D1/D2) + +The runner forwards a model selector: `--agent-model` / `$COMPASS_AGENT_MODEL` +becomes the agent's `COMPASS_MODEL` (`go/cmd/compass-runner/main.go:50-53`; +`go/internal/runner/agent_exec.go:65-67`). The provider credential is separate: +the agent reads a 0600 `$HOME/.compass/auth-seed.json` "the Runner's +materializer writes (design §T5)" (`packages/compass-agent/src/cli.ts:11-12`, +`:47-49` `authSeedPath`), but the T5 materializer is not yet implemented in +the runner (`grep Materialize|auth-seed` over `go/internal/runner` + +`go/internal/runtime` at this HEAD: no matches; only the secrets-plumbing +types exist, `go/internal/secrets/secrets.go:48-57`). An agent with no seed +still boots and reports — "an agent with no provider credential must still +start and report, not crash on first call" +(`packages/compass-agent/src/cli.test.ts:166-168`) — so the loop can smoke +container-spawn + session-start today. But spawn+idle does NOT satisfy the +ruled acceptance ("runs an agent turn"), and a real turn needs BOTH a +credential in the container AND the provider host in `--egress-allow` +(Approach §4). The production cred path IS being built: the SEA-1327 secrets +materializer (ITEM 7 — FetchSecrets → the runner writes the frozen +`auth-seed.json`) is the writer the compass-server lane owns, so the real-turn +leg sequences AFTER ITEM 7 rather than needing a throwaway seed. How and when +the turn becomes real is D1 (sequencing) + D2 (LiteLLM proxy, key via user +`SetSecret`). + +### Cert model (summary) + +One `compass-gen-cert` artifact is simultaneously the server's `--tls-cert` +and the runner's (and driver's) `--ca` — "the single cert is its own CA, so +the same file is the Server's --tls-cert and the Runner's --ca trust anchor — +one artifact exercising the real production TLS enroll path locally (no +external CA, no relaxed loopback)" (`go/cmd/compass-gen-cert/main.go:6-9`). + +## Alternatives considered + +- **Wire everything (image build + session drive) into every `devenv up`.** + Rejected as the default (D5): the image closure is large and an + LLM turn on every shell entry is slow, costly, and needs credentials. + Enrollment-on-up + opt-in drive keeps `up` light while the full loop stays + one command away. +- **Skip TLS locally (serve the RunnerService over dev-http).** Not possible + and not desirable: RunnerService is mounted only on the network door + (`serve.go:199-201`), and the `--dev-http` door fail-closes adminOnly RPCs + (`serve.go:245-249`). The self-signed-cert path exercises the production + enroll seam, which is the point of dogfooding. +- **Mint the runner token to stdout and capture in the process script.** + Rejected: `--token-out` is the designed idempotent path + (`compass-mint-runner-token/main.go:53-59`); stdout "always mints" + (`main.go:60-62`) and would rotate the credential on every up. +- **A second devenv for the loop.** Rejected: agent-image is its own devenv + for image-env isolation reasons (`agent-image/devenv.nix:5-14`), but the + loop processes belong beside the existing `processes.compass-server` — + a second process tree would duplicate the postgres/DSN wiring. + +## Plan + +Tasks are lane-tagged: `[repo]` = compass-repo lane (devenv.nix wiring, +independently shippable); `[compass-server]` = the net-new admin session +driver (T5); `[compass-runner]` = the runner mount-surface flag (T5a). Each +task carries a real red-green cycle plus a smoke assertion +(rule://red-green-testing). + +### T1 [repo] — gen-cert task + TLS door on compass-server + +Add `tasks."dogfood:gen-cert"` and extend the `processes.compass-server` argv +with `--listen 127.0.0.1: --tls-cert --tls-key`, keeping the +existing `--socket`/`--dev-http` (`devenv.nix:164-167`). Build +`compass-gen-cert` into `$DEVENV_STATE/compass/` the same way the server +binary is built (`devenv.nix:158-163`). Order: the task runs `before` the +compass-server process. Allocate the network port beside `ports.devhttp` +(`devenv.nix:170`). + +Interfaces: + +- Consumes: `compass-gen-cert --cert-out --key-out ` (SAN default + `127.0.0.1,::1,localhost`, skip-if-present, `--force` to rotate) + (`go/cmd/compass-gen-cert/main.go:48-64`). +- Consumes: `compass-server --listen --tls-cert --tls-key ` + all-or-none (`go/cmd/compass-server/main.go:50-60,146-154`). +- Produces: `$DEVENV_STATE/compass/tls.crt` (0644), `tls.key` (0600), and a + server whose network door writes `$DEVENV_STATE/compass/admin-token` (0600) + on startup (`go/server/network_door.go:35-38,249-256`). Note the coupling: + the token path is a FUNCTION of the socket path — StateDir defaults to the + socket's parent, and `compass-server` has no `--state-dir` flag (the + ServeConfig literal sets only SocketPath/Version/DevHTTP/Listen/TLS/ + DatabaseDSN, `go/cmd/compass-server/main.go:136-143`), so the default ALWAYS + applies. Relocating `COMPASS_SOCKET` silently moves the driver's credential; + T7's admin-token smoke covers it. + +Test cycle: red — `devenv up` on main has no TLS door: `curl --cacert tls.crt +https://127.0.0.1:/...GetServerInfo` fails (connection refused). Green — +after wiring, the same probe returns 200-class over TLS with the generated +cert, and `admin-token` exists 0600. Idempotence smoke: second `devenv up` +leaves `tls.crt` mtime unchanged. + +### T2 [repo] — mint-runner-token task + +Add `tasks."dogfood:mint-runner-token"`, `after` compass-server readiness +(GetServerInfo probe, `devenv.nix:184-197`; mint needs the migrated store — +`go/cmd/compass-mint-runner-token/main.go:88` `store.Open`, which "applies any +pending embedded migrations" `go/internal/store/store.go:55-57`). + +Interfaces: + +- Consumes: `compass-mint-runner-token --runner-id dogfood --token-out + $DEVENV_STATE/compass/runner.token` with `COMPASS_DATABASE_DSN` from the + server's env (`devenv.nix:179`; DSN precedence parity per + `compass-mint-runner-token/main.go:100-101`). +- Produces: `$DEVENV_STATE/compass/runner.token`, 0600, raw token, no newline + (`main.go:192-196`), idempotent/heal-without-rotate (`main.go:53-58`). + +Test cycle: red — task absent; no token file after `up`. Green — token file +exists 0600 after `up`; a second `up` does not rotate it (byte-identical); +deleting the database but keeping the file re-registers the same token +(covered by the binary's own tests, `main_test.go`; the devenv smoke asserts +the no-rotate case). + +### T3 [repo] — compass-runner process + +Add `processes.compass-runner`, `after` the mint task, exec'ing the runner +with the flags in Approach §4 and `COMPASS_RUNNER_TOKEN` read from the token +file inside the exec script (env-only contract, +`go/cmd/compass-runner/main.go:91-96`). `ready` is left UNSET: the devenv +readiness type supports only `exec`/`http.get`/`notify` +(`forks/devenv/src/modules/lib/ready.nix:8-56`) — there is no log-line +readiness, and the runner exposes no HTTP surface to probe (it idles in +`RunSessions`, `run.go:115-121`). Unset is safe because nothing `after`s the +runner (the session drive is opt-in/manual) and enrollment is asserted by +T7's log grep. Set `restart.on = "on_failure"` (matching compass-server's +convention, `devenv.nix:198`): `Dial`/`Enroll` is single-shot with no retry +(`go/internal/runner/runner.go:101-125`; `run.go:96-104` returns the error), +so without a restart policy a transient enroll failure leaves a permanently +dead runner. + +Interfaces: + +- Consumes: `compass-runner --runner-id dogfood --server + https://127.0.0.1: --ca $DEVENV_STATE/compass/tls.crt --image + compass-agent:latest --runtime-dir $DEVENV_STATE/compass/runner` + + `COMPASS_RUNNER_TOKEN` env (`main.go:40-57,91-96`); `--egress-allow` empty + for the base loop (Approach §4). +- Produces: an enrolled, idle runner (`Enroll` RPC then `RunSessions`, + `go/internal/runner/runner.go:101-113`, `run.go:96-121`). + +Test cycle: red — no runner process on main. Green — `devenv up` log shows +"runner enrolled" with `runner_id=dogfood`; kill/restart the runner process +and it re-enrolls (reattach path, `run.go:100` `link.Reattached()`). Failure +smoke: a corrupted token file yields the runner's Unauthenticated error +("an Unauthenticated here means a bad/expired/wrong-kind token", +`runner.go:97-99`), proving the door actually authenticates. + +### T4 [repo] — agent-image build+load task (opt-in per D5) + +Add `tasks."dogfood:agent-image"` running +`nix run path:./forks/devenv#devenv -- container copy agent` from +`agent-image/` (build+load semantics verified in Approach §5; the invocation +is pinned to the vendored fork's CLI). Not wired +`after` into `up` (D5). + +Interfaces: + +- Consumes: `nix run path:./forks/devenv#devenv -- container copy agent` in + `agent-image/` (`forks/devenv/devenv/src/cli.rs:1100-1113`, + `devenv/container.rs:47-108`). +- Produces: `containers-storage:compass-agent:latest` + (`forks/devenv/src/modules/containers.nix:295`, `agent-image/devenv.nix:67,98`, + version default `latest` `containers.nix:324-327`). + +Test cycle: red — `podman image exists compass-agent:latest` fails on a clean +box. Green — after the task, it succeeds. Linux-only guard: the task is a +no-op (or absent) on non-Linux, matching `agent-image/devenv.nix:65`. + +### T5 [compass-server] — the admin session driver (owned by compass-server lane) + +Implement the driver in Approach §6: read +`$DEVENV_STATE/compass/admin-token`, dial the TLS door with the shared cert as +trust anchor, then CreateAgent (find-or-create by handle) → +ProvisionAgentWorkspace (`local_path` mirror, stable `client_request_id`) → +StartAgentSession (smoke `initial_prompt`). BLOCKED at this HEAD on the +missing runner mount surface (Approach §6 "Mount-surface blocker"): a +container-visible bare mirror requires a new compass-runner `--mount +host:container[:ro]` flag wiring into `SpecDefaults.Mounts` +(`go/internal/runner/spec.go:26-32,107`) plus devenv wiring to pass it — a +cross-lane dependency whose mount-surface flag is scoped as T5a (D3; the +driver itself is compass-server-owned). Preparing the bare mirror itself is +driver-side work +(the runtime integration test seeds one the same way, +`go/internal/runtime/lifecycle_test.go:99-102` — though that test drives the +runtime API directly, below the runner binary). + +Interfaces: + +- Consumes: `CommsService.CreateAgent{handle, display_name} → + {account}` (`proto/compass/v1/comms.proto:450-462`); + `CompassService.ProvisionAgentWorkspace{agent_account_id, local_path, ref, + client_request_id} → {container_name}` (`compass.proto:326-355`); + `CompassService.StartAgentSession{container_name, initial_prompt} → + {session_id}` (`compass.proto:359-371`); bearer auth per + `admin_gate.go:50-56,68-71`. +- Produces: a running `compass-agent-*` container (podman), a live session id, + and a nonzero exit + named cause on any step's failure. + +Test cycle: red — driver absent/failing against a stood-up loop. Green — +driver exits 0; `podman ps` shows the agent container; +`GetAgentStatus` (adminOnly, `admin_gate.go:50-56`) lists the session. + +### T5a [compass-runner] — the runner mount-surface flag + +Add a repeatable `--mount host:container[:ro]` flag on `compass-runner` that +populates `SpecDefaults.Mounts` (`go/internal/runner/spec.go:26-35`, the field +is designed and wired through to podman at `spec.go:100-107` but no operator +surface reaches it today, `cmd/compass-runner/main.go:40-64,117-124`). This +unblocks the `local_path` bare mirror (D3): the devenv wiring passes the host +mirror path so it becomes container-visible for Provision's file:// clone. + +Interfaces: + +- Consumes: `compass-runner --mount :[:ro]` (repeatable), + wiring into `SpecDefaults.Mounts` (`spec.go:26-35`). +- Produces: a runner that mounts the host bare mirror read-only into the agent + container at the path `ProvisionAgentWorkspace{local_path}` names. + +Test cycle: red — a runner built without the flag leaves `SpecDefaults.Mounts` +nil, so Provision's file:// clone targets a path absent from the container and +fails. Green — `--mount ::ro` populates +`SpecDefaults.Mounts` and the mirror is readable inside the container, so the +clone succeeds. + +### T6 [repo] — teardown: `dogfood:clean` task + +Nothing tears agent containers down: on runner shutdown only the agent +SOCKETS close — "every container lives until the Runner process ends" refers +to socket lifetime; `Close` touches `h.sockets` only +(`go/internal/runner/host.go:162-179`), and the podman container survives +`devenv down`. Container names are deterministic (NamePrefix + agent account +id, `go/internal/runner/spec.go:33-35`), and the `client_request_id` dedup is +in-memory (gone after restart) — so the SECOND dogfood run's Provision hits a +`podman create` name collision: without cleanup the loop works exactly once +per boot. Add an opt-in `tasks."dogfood:clean"` that `podman rm -f`s the +`compass-agent-*` containers and sweeps the runner socket dir +(`$DEVENV_STATE/compass/runner/containers/`). Alternative (implementer's +choice): a driver-side stale-container preflight that `podman rm -f`s its own +target container name before Provision. + +Interfaces: + +- Consumes: `podman ps -a --filter name=compass-agent-` + `podman rm -f`; + the runner socket dir layout `RuntimeDir/containers//agent.sock` + (`go/cmd/compass-runner/main.go:56-57`). +- Produces: a clean slate such that a re-run of the session drive provisions + successfully. + +Test cycle: red — two consecutive session drives without clean: the second +Provision fails on the name collision. Green — drive → clean → drive +succeeds twice. + +### T7 [repo] — end-to-end `devenv up` smoke + docs + +A scripted smoke (an opt-in `dogfood:session` task per D5 +chaining T4+T5, or CI-style script) asserting, in order: postgres up; server +ready with TLS door answering under the generated cert; `admin-token` + +`runner.token` present 0600 (the admin-token assertion doubles as the +regression guard for the serial token-write-before-serve ordering, Approach +§2); runner log shows enrollment; and — gated on the session-drive decision — +T5's green assertions. Update the `devenv up` header comment +(`devenv.nix:115-123`) to describe the full loop; document the box prereqs +(Linux, rootless podman, uid 1000, subuid/subgid) and cert expiry: gen-cert +is skip-if-present forever with a finite `--validity` +(`go/cmd/compass-gen-cert/main.go:57-58,82-89`), so on expiry the loop fails +with an opaque TLS error — rerun `compass-gen-cert --force` to rotate. + +Interfaces: + +- Consumes: everything T1-T6 produce. +- Produces: one command a developer runs to verify the loop; the record's + acceptance evidence. + +Test cycle: the smoke IS the test; red is any assertion failing on a clean +checkout before the wiring lands, green is all passing after. + +## Tasks + +- [ ] T1 [repo] gen-cert task + TLS door on compass-server (red-green + idempotence smoke) +- [ ] T2 [repo] mint-runner-token task after server-ready (red-green + no-rotate smoke) +- [ ] T3 [repo] compass-runner process: enroll + idle, restart on_failure (red-green + bad-token smoke) +- [ ] T4 [repo] agent-image build+load opt-in task via the fork-pinned CLI (red-green via podman image exists) +- [ ] T5 [compass-server] admin session driver `compass-dogfood`/`compass drive`: CreateAgent → Provision(local_path) → Start → tail (red-green + podman ps smoke) — owned by compass-server lane, sequenced after ITEM 7 + ITEM 1 +- [ ] T5a [compass-runner] runner `--mount host:container[:ro]` flag into SpecDefaults.Mounts (unblocks local_path bare mirror) — scoped here per D3 +- [ ] T6 [repo] dogfood:clean teardown task (red-green via second-run provision) +- [ ] T7 [repo] end-to-end `devenv up` smoke + prereq/cert-expiry docs (session leg is opt-in per D5) + +## Decisions (ruled by Matt) + +F2 (idempotent devenv tasks) and F3 (gen-cert → postgres → server-ready → +mint → runner ordering) are recorded in Global Constraints. The forks below +were batched to Matt at the design-PR gate and ruled as noted. + +1. **D1 — Real-turn sequencing (was OQ1): ship the enroll loop now; the + real-turn leg follows the ITEM 7 + driver chain.** The compass-repo enroll + loop (postgres, server+TLS, mint, runner enrolled+idle) has no credential + dependency and ships standalone as `[repo]` tasks T1-T4/T6/T7. The FULL + "runs a turn" acceptance completes once the SEA-1327 secrets materializer + (compass-server lane) and the driver (T5) land. A real turn needs three + coupled pieces, all deferred to that chain: (i) the provider credential — + the frozen 0600 `$HOME/.compass/auth-seed.json` the SEA-1327 materializer + writes (`packages/compass-agent/src/cli.ts:47-50,98-102`; + `cli.test.ts:110-111`); (ii) the provider host in `--egress-allow` (default + is pure default-deny, `go/internal/runtime/egress.go:29-31`, + `agent.go:148-150`); (iii) the model selector via + `--agent-model`→`COMPASS_MODEL` (`go/cmd/compass-runner/main.go:50-53`). +2. **D2 — Model + credential path (was OQ1b): LiteLLM proxy, key via the user + `SetSecret` (KIND_PROVIDER) path.** The dogfood agent targets the LiteLLM + proxy as its provider; a developer seeds the key once via the user-facing + `SetSecret` flow (the production cred path, ITEM 7), so the dogfood exercises + the real secret path end to end. `--egress-allow` gets the LiteLLM proxy + host for the real-turn leg. Applies when the real-turn chain (D1) lands. +3. **D3 — Mount-surface scope (was OQ2): a scoped T5a task in this record.** + Driver ownership is settled (compass-server lane, T5). The remaining gap — + nothing populates `SpecDefaults.Mounts` at this HEAD, so the `local_path` + bare mirror cannot reach the container (Approach §6 "Mount-surface + blocker") — is closed by a net-new compass-runner + `--mount host:container[:ro]` flag into `SpecDefaults.Mounts` (T5a, + `go/internal/runner/spec.go:26-32,107`), with the devenv wiring passing it + ([repo]). Not a separate design record — a single scoped task here. +4. **D4 — `local_path` mirror (was OQ3).** Repo source is `local_path` with a + bare mirror of the compass repo itself — hermetic, credential-free + (`compass.proto:331-335`; `go/internal/runtime/workspace.go:109-110`), + contingent on the D3 mount surface. Follows from D3. +5. **D5 — Session drive is opt-in (was OQ4).** The heavy legs (image build, + live LLM turn) land as explicit opt-in tasks (`devenv tasks run + dogfood:session`), not wired into `devenv up`. Note: `devenv up` is an + explicit command that starts the process stack — it is NOT run automatically + on shell entry (direnv loads env/tooling on entry; `up` is separate). So the + split keeps the on-demand heavy legs off the always-on `up` process set: `up` + brings up the light, idempotent enroll loop; the image build + real turn run + on demand. + +A related public-API fork in the ITEM 7 (SEA-1327 secrets) lane was ruled in +the same batch: the `SetSecret`/`ListSecrets`/`DeleteSecret` RPCs go on a new +`SecretsService` (not folded onto `CompassService`). That governs the ITEM 7 +proto edit, not this record's tasks; noted here only for provenance. diff --git a/docs/designs/product/DECISIONS.md b/docs/designs/product/DECISIONS.md new file mode 100644 index 00000000..d86e20ea --- /dev/null +++ b/docs/designs/product/DECISIONS.md @@ -0,0 +1,220 @@ +# Compass design-decision ledger + +Read this first. It is the canonical index of current Compass design truth: +every ratified, citable decision is one row — a stable ID, a one-line +statement, a live/superseded status with provenance, and a link to the frozen +record holding the full rationale. Per-decision truth for *ledgered* decisions +lives here; rationale lives in the linked records (two hops by design). + +**Absence of a row is not evidence of no ruling.** The ledger covers named, +citable decisions (a record's numbered Decision, a Global Constraint, or a +§-level ruling another record cites) plus every known overturn — not inline +micro-choices. For anything not here, the linked frozen records remain +authoritative. + +This is the one living document under `docs/designs/` — continuously appended +and flipped, never frozen. The rules that keep it honest (append-only rows, +immutable `Decision` cells, the same-PR flip that a design PR owes its ledger +delta) live in the design record and `AGENTS.md`; the `design-ledger-gate` CI +check enforces the mechanical half. Full rationale: +[the design-ledger record](compass-design-ledger/design.md). + +## Conventions + +- **ID** — `DL-`, globally unique, append-only, never reused. +- **Status** — exactly `Active (, YYYY-MM-DD)` or `Superseded by DL- + (, YYYY-MM-DD)`. Every row carries provenance so overlapping one-liners + have a recency signal. +- **Decision** — a one-line paraphrase, immutable after append. A new ruling is + a new row plus a `Superseded` flip on the old, never an in-place reword. +- **Record** — a relative link into the frozen record; an `#anchor` is required + for links into large records so rationale is one hop away. + +## Strategy & positioning + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-001 | Compass is an agent development environment: named workstream agents supervised by a Dispatcher, gated by a Warden | Active (Matt, 2026-06-30) | [v0.3 §Solution](compass.md#3-solution-compass) | +| DL-002 | OMP is the default/reference agent, carried as an external OSS fork; Compass builds on it, does not rebuild it | Active (Matt, 2026-07-01) | [v0.4 §OMP is the default](compass-0.4/design.md#omp-is-the-defaultreference-agent--and-what-omp-is) | +| DL-003 | seal is scoped to hosting the Warden, not the whole runtime | Active (Matt, 2026-07-01) | [v0.4 §seal scoped to Warden](compass-0.4/design.md#seal-is-scoped-to-hosting-warden) | +| DL-004 | Adopt Cotal as the coordination substrate behind a thin Compass-owned seam | Superseded by DL-005 (Matt, 2026-07-04) | [v0.4 §Cotal is the substrate](compass-0.4/design.md#cotal-is-the-coordination-substrate-behind-a-thin-seam) | +| DL-005 | The NATS/JetStream substrate is kept, but Cotal moves from adopted dependency to reference implementation; comms is first-party behind a Compass seam | Superseded by DL-021 (Matt, 2026-07-04) | [v0.5 §D1](compass-0.5/design.md#d1--a-first-party-rust-comms-service-on-natsjetstream-cotal-as-reference-behind-a-compass-owned-seam) | +| DL-006 | Upstream contribution to OMP is a distribution channel, not a governance dependency | Active (Matt, 2026-07-01) | [v0.4 §Upstream contribution](compass-0.4/design.md#upstream-contribution-is-a-distribution-channel) | +| DL-095 | The agent tree is Compass's organizing primitive — Compass is a tool to build and manage agent trees: `AgentAccount` carries `parent_agent_id` (field 4; empty = root, set at creation — the spawning agent, or user choice — and editable thereafter via a `ReparentAgent` mutation, so users iterate hierarchy without teardown), the workspaces sidebar and board views derive from and filter by the tree, REPLACING the user-defined folder organization (replace, not coexist); channels (SEA-1622) and roles (SEA-1623) compose onto the tree later | Active (Matt, 2026-08-01) | [agent trees §Approach](compass-agent-trees/design.md#approach) | + +## Topology & tiers + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-007 | Architecture is three tiers: Client → Server → Runner, with the communication layer as the spine | Active (Matt, 2026-07-04) | [v0.5 §The three tiers](compass-0.5/design.md#the-three-tiers) | +| DL-008 | Evolve `compass.v1` from a local daemon contract into the Client↔Server contract (multi-user) | Active (Matt, 2026-07-04) | [v0.5 §D2](compass-0.5/design.md#d2--evolve-compassv1-from-a-local-daemon-contract-into-the-clientserver-contract) | +| DL-009 | The Server tier realizes the reserved hosted-mode transport seam | Active (Matt, 2026-07-04) | [v0.5 §D3](compass-0.5/design.md#d3--the-server-tier-realizes-the-reserved-hosted-mode-transport-seam) | +| DL-010 | Multi-agent orchestration (Supervisor + Bridge) is in the MVP; single-agent is the first build increment | Active (Matt, 2026-07-04) | [v0.5 §D4](compass-0.5/design.md#d4--multi-agent-orchestration-is-in-the-mvp-supervisor--bridge-single-agent-is-the-first-build-increment) | +| DL-011 | The Server tier is built by promoting `compass-daemon` in place, not a new server crate | Active (Matt, 2026-07-08) | [v0.5-server §F1](compass-0.5-server/design.md#f1--server-crate-location-promote-compass-daemon-in-place) | +| DL-012 | The authenticated network door is rustls-via-tonic TLS + bearer-token accounts (in-memory token store for T2) | Active (Matt, 2026-07-08) | [v0.5-server §F2](compass-0.5-server/design.md#f2--the-authenticated-network-listener-rustls-via-tonic-tls-ring-operator-provisioned-certs-bearer-token-accounts) | + +## Transport + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-013 | Transport is gRPC everywhere (Client↔Server and Runner↔Server), authenticated by per-Runner provisioned tokens | Active (Matt, 2026-07-06) | [v0.6 §Transport: gRPC everywhere](compass-0.6/design.md#transport-grpc-everywhere) | +| DL-014 | NATS/JetStream is not a Client/Runner-facing transport; it is comms-internal only | Active (Matt, 2026-07-06) | [v0.6 §NATS as a transport](compass-0.6/design.md#nats-as-a-transport-server-internal-runnerserver-clientserver) | +| DL-015 | The in-container agent talks to the Runner alone (Runner-sole topology), off stdio, over a `RunnerCallTransport` seam | Active (Matt, 2026-07-21) | [agent↔Runner transport §Decisions](compass-agent-runner-transport/design.md#decisions-frozen-by-matt--not-reopened-below) | +| DL-016 | The concrete agent↔Runner transport is a per-container Unix socket, 1:1 with a session; network transport is a future additive | Superseded by DL-017 (Matt, 2026-07-21) | [agent↔Runner transport §The concrete impl](compass-agent-runner-transport/design.md#the-concrete-impl-unix-socket-per-container) | +| DL-017 | Consolidate agent↔Runner onto socket streams: telemetry via `Publish` client-stream, control via `Control` server-stream on `AgentGateway`; retire the protocol stdio pipe | Active (Matt, 2026-07-22) | [transport consolidation §What is superseded](compass-agent-transport-consolidation/design.md#what-is-superseded-exactly) | +| DL-018 | The frozen `RunnerService` transport recommendation #2 is socket-only, superseding the earlier off-stdio carrier clause | Active (Matt, 2026-07-22) | [agent comms tools §The frozen transport](compass-agent-comms-tools/design.md#the-frozen-transport-this-rides-was-the-keystone-fork-now-decided) | + +## Storage + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-019 | Postgres is the store of record; transcript bodies live in object storage behind a blob seam; JetStream is comms-only | Active (Matt, 2026-07-04) | [v0.5 §D12](compass-0.5/design.md#d12--postgres-is-the-store-of-record-transcript-bodies-live-in-s3-compatible-object-storage-behind-a-blob-seam-jetstream-is-comms-only) | +| DL-020 | Postgres is the substrate and is not swappable; the in-memory event bus is a cache/fan-out ring, not a second store | Active (Matt, 2026-07-06) | [v0.6 §State + storage](compass-0.6/design.md#state--storage-postgres-is-the-substrate-the-ring-is-a-cache) | +| DL-021 | The comms substrate is Postgres write-through fan-out, not a swappable NATS-backed seam | Active (Matt, 2026-07-06) | [v0.6 §The communication layer](compass-0.6/design.md#the-communication-layer-the-spine) | +| DL-022 | Config distribution is a Runner-mediated pull into a read-only mount; agent binary + base image ride versioned OCI pulls | Active (Matt, 2026-07-04) | [v0.5 §D11](compass-0.5/design.md#d11--config-distribution-is-runner-mediated-pull-into-a-runner-local-read-only-mount-agent-binary-and-base-image-ride-versioned-oci-pulls) | +| DL-052 | Only the Server holds forge write credentials, as a `server_only` declared secret filtered out of container injection — a NEW scoping mechanism this record introduces, since none exists in the tree. The agent keeps a push-scoped git credential. The boundary that creates is half enforced (the filter, which Compass controls and tests) and half operator hygiene (the agent token's scope, which Compass neither mints nor inspects); closing the second half would require Compass to hold secret values, which today it deliberately never does | Active (Matt, 2026-07-27) | [ownership layer §Decision 4](compass-server-ownership-layer/design.md#decision-4--the-servers-forge-credential-is-a-declared-secret-resolved-not-stored) | +| DL-053 | Forge subscriptions are Server-side Postgres rows with a per-artifact FETCH cursor (advanced on any 200) split from a per-subscriber DELIVERY cursor (advanced only on that subscriber's own successful notify), change-detected by conditional polling in v1 (webhooks are an additive accelerator), delivered by account on the existing `Sessions` → `AgentGateway.Control` push path | Active (Matt, 2026-07-27) | [ownership layer §Decision 5](compass-server-ownership-layer/design.md#decision-5--subscriptions-server-stored-poll-based-in-v1-delivered-on-the-existing-push-path) | +| DL-055 | Compass stores a row per artifact it authored (coordinate + agent + owner + session, written with the stamp), so agent ownership queries and the Bridge boards are local index scans over Server-recorded truth rather than forge-wide searches of untrusted header text; it is an ownership index, never a mirror of forge content | Active (Matt, 2026-07-27) | [ownership layer §OQ-8](compass-server-ownership-layer/design.md#oq-8-ruled-2026-07-27-matt--recommendation-reversed--does-a-compass-authored-artifact-need-a-compass-side-record) | +| DL-063 | Agent session transcripts persist to S3 for v1 via an ENDPOINT-AGNOSTIC backend (`COMPASS_S3_ENDPOINT`, generic `Bun.S3Client`, no S3-server-specific calls), reached under default-deny egress via an allowlisted endpoint — the S3 client path is exercised from v1, never a swap-later local-fs abstraction. Solo/self-host backend is a local-filesystem Garage (≥ v2.3.0) per Runner host, granting a full-bucket credential (cross-agent read/tamper accepted only within the single-trust-domain MVP); real/multi-user deployments MUST NOT use the Garage full-bucket credential — they use Cloudflare R2 with prefix-scoped tokens, which is a CONFIG change not a new build (endpoint-agnostic). Docs advisory + hardening follow-up ("R2 + prefix-scoped tokens for real deployments") stated; no bucket-per-agent, no Runner signing proxy | Superseded by DL-084 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-064 | The agent persists its transcript DIRECTLY from inside the container as the session runs: a Compass-owned S3 `SessionStorageBackend` wrapped in the SDK's `IndexedSessionStorage`, injected at the cli.ts composition root via `createAgentSession({ sessionManager })`; no runner-ships-the-log-after-teardown step exists. The persist model is a SEGMENTED PER-EPOCH LOG — a stable `session_id` keys the prefix `sessions//` and each container lifetime appends only to its own `.jsonl` segment (continuous per-append full-object PUT of that one segment + terminal drain); reconstruction is an S3 LIST of the prefix reconstructed in ascending epoch order with title-slot CHECKPOINT SUPERSESSION — a segment whose first line parses as a title slot is a full-body-rewrite snapshot that supersedes prior epochs (readFull resets its accumulator to it), so SDK-internal compaction rewrites do not double-count (readFull=LIST+reconstruct vs append=single-current-segment asymmetry). Fresh opens `<0>.jsonl` via `SessionManager.open`; resume opens the PRIOR latest existing epoch key (context is seeded by the backend readFull=LIST+reconstruct on that existing, indexed key) then repoints the write target to a fresh `.jsonl` via the public `captureState`/`restoreState` seams (`session-manager.ts:919/939`, `onDisk:true, needsRewrite:false`), so appends land only in the new segment and prior segments stay immutable — NO SDK patch | Superseded by DL-085 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-065 | A Postgres pointer row (`agent_session_transcripts`: stable logical session_id PK → bucket + key PREFIX + a nullable `endpoint` provenance column, FK-rooted in the post-0004 `agent_sessions`) indexes each stored log; resume reuses `StartAgentSession` with an additive authz-gated `resume_session_id` on the PUBLIC request, and the resolved pointer rides a NEW top-level `ResumeContext resume = 12` on the INTERNAL `SessionsResponse` (outside the command oneof, tag 2 untouched — additive, non-forgeable) so a client-set pointer can never reach the Runner (the public request is relayed verbatim). The new container reconstructs one logical log from the prefix and the built replay barrier gates live input (`ReplayComplete` alone lifts it — no TranscriptReplay frames on the resume path) | Superseded by DL-084 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-066 | The `TranscriptReplay` payload shape (`agent_message_json` + index/total) is a PROPOSAL handed to SEA-1310, which compass owns; this record does not freeze it and does not depend on it. The handoff MUST carry a replay-path admission/size bound, because defining the payload turns the replay path into an admission path the retention cap exempts today (control.go:546-560) | Superseded by DL-086 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-082 | Writer fencing for session-log persistence is force-teardown of the old container (PRIMARY fence) plus a per-container-lifetime epoch token in the object key (`sessions//.jsonl`, BELT): force-teardown removes the zombie writer in the normal case, and the epoch guarantees a zombie from a crashed lifetime a Runner could not confirm torn down can only clobber its OWN dead epoch segment, never erase another lifetime's segment | Superseded by DL-085 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-083 | Session-transcript credential/storage posture: a full-bucket S3 credential is acceptable for solo/self-host v1 (Garage, single trust domain) despite the stated cross-agent read/tamper threat; real/multi-user deployments use Cloudflare R2 with prefix-scoped tokens so each agent is confined to its own `sessions//` prefix. The S3 backend is endpoint-agnostic via `COMPASS_S3_ENDPOINT` (generic `Bun.S3Client`), so the hardening is config not a new build; the threat is stated and a hardening follow-up is filed | Superseded by DL-084 (Matt, 2026-07-31) | [session persistence §Appendix](compass-agent-session-persistence/design.md#appendix--superseded-by-the-reversal-2026-07-31-the-agent-direct-s3-model) | +| DL-084 | The Server owns durable session-transcript storage: a Postgres transcript-entry store (`agent_session_transcript_entries`: (session_id, entry_seq) PK, UNIQUE idempotency_key, checkpoint flag, FK `agent_sessions`) persisted on receipt of durable `TranscriptEntry` frames at the `CommitConversationFrame` at-most-once commit seam, with CHECKPOINT SUPERSESSION server-side (the read view is latest checkpoint + later deltas — the old read-side reconstruction rule relocated to the one owner that reads). Storage backend choice and credential posture are Server-internal; the agent and Runner hold zero storage detail, zero S3 credentials | Superseded by DL-093 (Matt, 2026-07-31) | [session persistence §Whole bodies in Postgres — rejected](compass-agent-session-persistence/design.md#whole-transcript-bodies-as-permanent-postgres-rows--rejected-the-dl-084-model) | +| DL-085 | The agent persists nothing: an injected emit-backend on the SDK `createAgentSession({ sessionManager })` seam turns every committed session entry into a durable `TranscriptEntry` AgentFrame variant (entry_json verbatim, checkpoint discriminator for SDK full-body rewrites, agent-stamped entry_seq) on the delivered-or-erred, idempotency-keyed PostConversationFrame lane — awaited per-path for per-session send order; the local SessionManager is ephemeral and dies with the container. Writer fencing is force-teardown (primary) + the durable lane's idempotency keys and entry_seq (belt); the S3 epoch-token fence is retired with the object model | Superseded by DL-089 (Matt, 2026-07-31) | [session persistence §Appendix (collapse)](compass-agent-session-persistence/design.md#appendix--superseded-by-the-collapse-2026-07-31-the-control-lane-replay-resume-model) | +| DL-086 | Resume is replay-driven: `StartAgentSession{resume_session_id}` (authz-gated, no storage locator on any message) makes the Server push its persisted transcript down the existing `SessionsResponse.deliver_control`/`DispatchControl` relay as `TranscriptReplay` ops consumed by `appendMessage`, gated by the Runner replay barrier until `ReplayComplete`/`ReplayCompleteAck`; the `TranscriptReplay` + `TranscriptEntry` payload shapes are co-ratified with SEA-1310 and BLOCK resume (inverting DL-066's independence), and the replay-path admission bound is in-scope, enforced at the retention-cap site on the SEA-1569 RESOURCE_EXHAUSTED refuse-and-redeliver model | Superseded by DL-087 (Matt, 2026-07-31) | [session persistence §Appendix (collapse)](compass-agent-session-persistence/design.md#appendix--superseded-by-the-collapse-2026-07-31-the-control-lane-replay-resume-model) | +| DL-087 | Resume is file-materialized + SDK-native: on `StartAgentSession{resume_session_id}` (authz-gated, no storage locator on any message) the Server reconstructs the post-supersession session-JSONL body from its transcript store (latest checkpoint body verbatim + later delta lines) and hands it to the Runner on a TOP-LEVEL internal `SessionsResponse` sibling field (outside the `command` oneof, NOT inside the verbatim public `start` request); the Runner writes it into the new container's session dir at PROVISION time via the provision-exec family, before the agent is exec'd; the agent loads it through the SDK's own `setSessionFile`/`loadEntriesFromFile` seam. No control-lane replay, no `TranscriptReplay` payload, no replay barrier or admission bound, and no SEA-1310 dependency — the co-ratification is retracted (peer-contract relief). Inline-image blobs are out of MVP scope (SEA-1582). | Active (Matt, 2026-07-31) | [session persistence §Appendix (collapse)](compass-agent-session-persistence/design.md#t5-compass-server--the-resume-body-reconstructor) | +| DL-088 | The durable session log and the live client trace are two projections of ONE canonical artifact — the SDK session-entry log: the agent emits committed entries, the Server persists them AND projects the block-level trace from the same store. MVP runs two agent→server streams (durable settled-entry log + ephemeral, non-persisted live per-token trace — token streaming is mandatory); collapsing them into one token stream carrying log metadata is the post-MVP follow-up SEA-1580 | Active (Matt, 2026-07-31) | [session persistence §One canonical artifact](compass-agent-session-persistence/design.md#one-canonical-artifact-the-session-log-unified-with-the-trace-r1) | +| DL-089 | The agent persists nothing DURABLE: the injected backend is a TEE — it reads and writes the container-local EPHEMERAL session file normally (so the SDK's own loader, compaction, and rewrites work) and tees each committed write upstream as a durable `TranscriptEntry` frame (append→delta, writeFull→checkpoint, awaited per-path for per-session send order; agent-stamped per-lifetime entry_seq, server-rebased to session scope). Zero S3 credentials, zero storage detail; the local file dies with the container. A definitively-erred emit buffers under a bounded cap with escalating warn→error retry and FAILS THE SESSION at cap exhaustion (resumable from the last committed prefix; cap value is tuning, not freeze-scope) | Active (Matt, 2026-07-31) | [session persistence §The SDK seam](compass-agent-session-persistence/design.md#the-sdk-seam-grounded-revised-for-the-tee) | +| DL-093 | The Server's durable session-transcript store is TWO-TIER: a Postgres hot-tail (`agent_session_transcript_entries`) holding `[latest checkpoint..now]` = the resume set, per-frame durable at the `CommitConversationFrame` seam and pruned at flush; superseded entries flush to an S3-compatible object-store cold archive (verbatim JSONL segments, PUT-before-prune, indexed by an `agent_session_archive_segments` manifest) at compaction (primary), a high size-cap safety valve, and session-end. Resume reads PG-only (S3 fallback only when the safety valve fired); the archive feeds the post-MVP opt-in analytics layer. Agent+Runner hold zero storage detail; the object-store client/credential is Server-internal (endpoint-agnostic: Garage/R2/MinIO). Realigns with DL-019 | Active (Matt, 2026-07-31) | [session persistence §T4](compass-agent-session-persistence/design.md#t4-compass-server--the-two-tier-durable-transcript-store-pg-hot-tail--object-store-cold-archive) | + +> Note (2026-07-31, SEA-1570 R5): DL-065's retired internal `ResumeContext +> resume = 12` envelope field is NOT silently reintroduced — the collapsed +> model's resume body rides a NEW internal `ResumeBody` carrier on a FRESH +> internal tag (never the freed `= 12` slot); see the session-persistence +> record's T1/T6 and its F8 appendix note. + +## Agent runtime & container + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-023 | The in-container agent is a first-party program on the OMP SDK, emitting `compass.v1` natively | Active (Matt, 2026-07-06) | [v0.6 §Agent runtime](compass-0.6/design.md#agent-runtime-a-first-party-agent-emitting-compassv1) | +| DL-024 | Each agent runs in a per-agent container on the Runner for blast-radius isolation, not credential avoidance | Active (Matt, 2026-07-06) | [v0.6 §Per-agent container isolation](compass-0.6/design.md#per-agent-container-isolation-on-the-runner) | +| DL-025 | Container base image is self-contained; the agent manages its own devenv | Active (Matt, 2026-07-15) | [agent container runtime §Decision 1](compass-agent-container-runtime.md#decision-1--self-contained-base-image-the-agent-manages-its-own-devenv) | +| DL-026 | Secrets are a Server-side SecretSpec + keyring store with inject-all, no repo manifest | Active (Matt, 2026-07-15) | [agent container runtime §Decision 2](compass-agent-container-runtime.md#decision-2--secrets-store-secretspec--keyring-server-side-no-repo-manifest-inject-all) | +| DL-027 | No in-container supervisor for the MVP | Active (Matt, 2026-07-15) | [agent container runtime §Decision 4](compass-agent-container-runtime.md#decision-4--no-in-container-supervisor-for-mvp) | +| DL-047 | The agent is integrated via ACP/BYOA over stdio, with OMP as the external default over ACP and other agents as BYOA citizens | Superseded by DL-023 (Matt, 2026-07-06) | [v0.4 §OMP is the default](compass-0.4/design.md#omp-is-the-defaultreference-agent--and-what-omp-is) | +| DL-078 | Agent config (skills/extensions/MCP configs) is declared into a Server-side FLEET-WIDE bundle store (one bundle all agents get) via new operator-scoped CompassService RPCs, never pushed as provision-request fields; the recommended MVP authoring workflow is a version-controlled config repo published via a CI `compass config put` step; persona/role-keyed bundles and native GitOps pull (Server reconciles a config repo) are the named post-MVP seams | Active (Matt, 2026-07-30) | [config delivery §Decision CD-1](compass-agent-config-delivery/design.md#decision-cd-1--declaration-surface-a-server-side-fleet-wide-bundle-store) | +| DL-079 | Config carriage is Runner-fetch — a server-STREAMING `FetchAgentConfig` RPC + a `ConfigVersion` signal on the Sessions response stream (signal-then-pull over the frozen dial-out inversion; no inbound Server→Runner route) | Active (Matt, 2026-07-30) | [config delivery §Decision CD-2](compass-agent-config-delivery/design.md#decision-cd-2--carriage-streaming-runner-fetch-over-the-frozen-inversion) | +| DL-080 | Config injection is a Runner-local read-only bind mount of the parent config dir at `/run/compass/agent-config` (agent reads through `current/`); env-vars ride SEA-1327's secret surface exclusively; MVP forbids credentials in MCP configs (SEA-1576 tracks the post-MVP credentialed path) | Active (Matt, 2026-07-30) | [config delivery §Decision CD-3](compass-agent-config-delivery/design.md#decision-cd-3--injection-per-config-type) | +| DL-081 | The MVP config-update path is re-materialize + in-place agent Reload (the exec-driven model), reserving throwaway-container restart for image changes; live structured injection (ConfigControl/SEA-1310) is the named future seam | Active (Matt, 2026-07-30) | [config delivery §Decision CD-4](compass-agent-config-delivery/design.md#decision-cd-4--update-path-in-place-agent-reload-live-pull-as-the-named-seam) | +| DL-123 | Fleet OMP `settings`/`config.yml` are delivered whole-file (yml-only, no key-merge) as a bundle member, injected at `createAgentSession` as a `Settings.loadIsolated` `settingsManager` overlay with overlay-over-project precedence — extending the CD-1 bundle store with a settings category | Active (Matt, 2026-08-03) | [config passthrough §CP-1](compass-agent-config-passthrough/design.md#decision-cp-1--settings-delivery-whole-file-passthrough-via-the-configfilessettingsmanager-injection-seam) | +| DL-124 | The fleet `AGENTS.md` is a top-level bundle member injected at `createAgentSession` as a composed `contextFiles` global (fleet-first, the entrypoint re-runs project discovery), never workspace-placement — extending the CD family with an agent-context category | Active (Matt, 2026-08-03) | [config passthrough §CP-2](compass-agent-config-passthrough/design.md#decision-cp-2--agentsmd-delivery-bundle-member-injected-as-a-composed-contextfiles-global-not-workspace-placement) | +| DL-125 | The complete OMP config-dir surface is enumerated with a per-category deliver/defer table; the MVP delivers every category the wave populates (the rest are named defer seams), fixing the config-passthrough scope boundary | Active (Matt, 2026-08-03) | [config passthrough §CP-3](compass-agent-config-passthrough/design.md#decision-cp-3--the-complete-omp-config-dir-surface-deliverdefer-table) | +| DL-126 | Agent-dir-anchored categories (`rules/`, `agents/`, `models.yml`) are delivered per SDK seam — `rules/` as a composed `rules: Rule[]` object injection, `agents/` and `models.yml` via user-level symlinks; the CD-3 server-door credential denylist extends to `models.yml` BOTH credential surfaces — `providers..apiKey` AND `providers..headers.*` literal secrets (env-indirection references pass; non-env-indirection literals are rejected with a client warning), because header values materialize outbound value-first | Active (Matt, 2026-08-03) | [config passthrough §CP-4](compass-agent-config-passthrough/design.md#decision-cp-4--agent-dir-anchored-categories-rules-as-an-injected-object-agents-and-modelsyml-via-user-level-symlinks) | + +## Comms & tools + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-028 | The MVP agent comms toolset is two native tools (`comms_post_message`, `comms_list_messages`); no ask-answering capability | Active (Matt, 2026-07-22) | [agent comms tools §Tool set](compass-agent-comms-tools/design.md#tool-set-and-shape--native-two-tools-for-mvp) | +| DL-029 | Agent comms identity/authz is session-resolved server-side, defaulting to the home channel | Active (Matt, 2026-07-22) | [agent comms tools §Identity / authz](compass-agent-comms-tools/design.md#identity--authz--session-resolved-server-side-home-channel-default) | +| DL-048 | The Server is the ownership layer: agents reach Issues/Chat/PRs only through the Server, which stamps a machine-parseable author/owner header — replacing per-agent forge accounts (Forgejo/Plane deprioritised) | Active (Matt, 2026-07-27) | [ownership layer §Approach](compass-server-ownership-layer/design.md#approach) | +| DL-049 | Forge tools ride the existing `AgentGateway` socket as a sibling `ForgeCall*` family relayed by `RelayForgeCall`, not a `CommsCallRequest` extension and not a second channel | Active (Matt, 2026-07-27) | [ownership layer §Decision 1](compass-server-ownership-layer/design.md#decision-1--a-sibling-forgecallrequest-on-the-same-agentgateway-socket) | +| DL-050 | The `compass:owner` header is a top-placed HTML comment + rendered line, stamped at one chokepoint (`forge.StampOwner`), idempotent, unforgeable ON WRITE; a header PARSED from forge text is untrusted display metadata that may not reach any authz/routing/ownership decision | Active (Matt, 2026-07-27) | [ownership layer §Decision 2](compass-server-ownership-layer/design.md#decision-2--the-authorowner-header-a-fenced-html-comment-plus-a-visible-line) | +| DL-051 | The forge adapter is `go/internal/forge` behind a swappable `Provider` interface (GitHub first, Linear issues-only), wired as a `runnerhub` sink | Active (Matt, 2026-07-27) | [ownership layer §Decision 3](compass-server-ownership-layer/design.md#decision-3--the-forge-adapter-lives-in-a-new-gointernalforge-package) | +| DL-054 | Notifications v1 is chat pings plus asks on the existing comms surfaces; no notifications page, centre, badge, or read state | Active (Matt, 2026-07-27) | [ownership layer §Decision 6](compass-server-ownership-layer/design.md#decision-6--notifications-v1-pings-in-chat-plus-asks-nothing-else) | +| DL-071 | Channel→agent delivery is a Server-side comms-bus consumer dispatching `deliver`/`steer` ops through the RunnerHub over a generic `SessionsResponse.DispatchControl` relay variant onto the built control lane; agent-authored (streaming) messages are held and delivered at the author's turn-settle edge (WORKING → READY/IDLE), human-authored at post (OQ-7, Matt 2026-07-29) | Active (Matt, 2026-07-29) | [notification delivery §D1](compass-notification-delivery/design.md#d1--the-fan-out-consumer-a-server-side-bus-consumer-dispatching-over-the-existing-sessions-relay) | +| DL-072 | The durable delivery cursor is Server-owned: a contiguous low-water cursor plus a bounded above-cursor set per `(agent_account_id, channel_id)` on `messages.seq` (mirroring `ControlAck`'s `acked_seq` + `applied_above`), reconstructed from per-message acks, swept gap-aware on session start/reconnect; the per-(agent, channel) keying is ratified (Matt, 2026-07-29), amending RT-3's "per-session" wording | Active (Matt, 2026-07-29) | [notification delivery §D2](compass-notification-delivery/design.md#d2--the-durable-delivery-cursor-server-owned-keyed-agent_account-channel-advanced-on-ack) | +| DL-073 | `DeliverControl` (and channel-borne `SteerControl`) carry the full first-party `compass.v1.Message` and nothing else (no `channel_seq`); the agent acks per message via `AgentFrame.delivery_ack { message_id }` — the frozen ack shape | Active (Matt, 2026-07-29) | [notification delivery §D3](compass-notification-delivery/design.md#d3--delivercontrol-payload-the-first-party-compassv1message-the-ack-is-the-message-id) | +| DL-074 | MVP presence is 4-state, Cotal-aligned (WORKING/IDLE/WAITING/OFFLINE): WORKING/IDLE/OFFLINE derived from the agent-session lifecycle, WAITING a server-side unanswered-ask overlay (an authored `Ask` with `Ask.answered=false` in a visible channel; WAITING > IDLE), no heartbeat, published as an additive `SubscribeCommsResponse` variant visible to actors sharing at least one visible channel with the agent, in-memory only with re-enroll reconciliation | Active (Matt, 2026-07-29) | [notification delivery §D4](compass-notification-delivery/design.md#d4--presence-derived-from-the-session-lifecycle-no-heartbeat) | +| DL-075 | Agent-facing spawn/despawn is owned by the spawning agent's owner (the human): all wave agents share `AgentAccount.OwnerUserID`, and despawn authority is same-owner-scoped for the MVP (finer caller-scoped authz deferred to SEA-1573) | Active (Matt, 2026-07-29) | [spawn/despawn §Identity and authz](compass-agent-spawn-despawn/design.md#identity-and-authz--owner-inherited-from-the-caller-resolved-at-the-rpc-edge) | +| DL-076 | Spawn/despawn rides a sibling `LifecycleCall*` family on the existing `AgentGateway` socket relayed by `RelayLifecycleCall` (mirroring DL-049), orchestrated server-side via a `LifecycleCaller` seam while the runnerhub resolves caller identity only; there is no public agent-callable spawn RPC (the egress-sealed agent holds no server token) | Active (Matt, 2026-07-29) | [spawn/despawn §Seam (a)](compass-agent-spawn-despawn/design.md#seam-a--spawn-a-lifecyclecall-family-on-the-agent-gateway-orchestrated-server-side) | +| DL-077 | Despawn stops the live session and calls a NEW public `RemoveAgentWorkspace` RPC (`compass.proto`, deliberately outside the SEA-1267 gen-fence); the agent account persists (teardown is compute-only). The MVP accepts the Runner's serial-dispatch head-of-line blocking, guarded by a bounded relay deadline as the sole guard with no spawn rate cap (limits → SEA-1574, concurrent dispatch → SEA-1575) | Active (Matt, 2026-07-29) | [spawn/despawn §Seam (b)](compass-agent-spawn-despawn/design.md#seam-b--despawn--the-new-removeagentworkspace-rpc) | +| DL-090 | Repo/auto-clone is removed from every agent-start contract: neither SpawnPeerRequest nor ProvisionAgentWorkspaceRequest carries repo/ref; agents self-clone after launch with the container's scoped git credential (post-MVP: a clone tool so agents hold no creds). Resolves OQ-4 (Workspace.Source optional->removed). | Active (Matt, 2026-07-29) | [spawn/despawn §Spawn carries no repo](compass-agent-spawn-despawn/design.md#seam-a--spawn-a-lifecyclecall-family-on-the-agent-gateway-orchestrated-server-side) | +| DL-092 | #995's forge-shaped proto domain family (`Issue`/`IssueComment`/`PullRequest`/`ForgeAuthor`/`ForgeChecksSummary`/`ForgeCheck` in `agent_gateway.proto`) is not built: per DL-069 the forge Provider layer's value types stay internal Go data at the ingestion boundary, the `ForgeCall*` carrier family survives with its result arms retyped to the canonical `compass.v1` types, and `AgentAttribution` supersedes `ForgeAuthor` (closing #995 OQ-1's field-set gate) | Active (Matt, 2026-07-31) | [ownership amendment §What this amendment asserts](compass-server-ownership-layer-amendment/design.md#what-this-amendment-asserts) | +| DL-135 | Agent roster is a pull: a public `CommsService.GetRoster` (account-visibility-scoped via `accountVisibleFromWhere`, tree-derived scopes NEIGHBORHOOD/SUBTREE/OWNER — OWNER ratified fleet-wide under the shared-owner model, activity strings fleet-visible) plus a native `compass_roster` tool on the `CommsCallRequest` relay family, reading the DL-074 in-memory presence enum joined with the agent tree; the activity string is DURABLE (its own `agent_activity` store table), recovered from Postgres on Server restart — agent-set via `compass_set_status` (write-through to the table + additive `AgentPresenceChanged.activity`), re-published by the agent-side harness on session (re)attach for freshness — a deliberate divergence from DL-074's in-memory posture for the STRING; the presence enum stays in-memory. Presence EVENTS keep the shared-channel visibility rule while the roster uses account visibility (divergence stated: an owner sharing no channel with its agent gets snapshot-only presence). SEA-1721 | Active (Matt, 2026-07-31) | [manager comms substrate](compass-manager-comms-substrate/design.md#a1--sea-1721-roster-query--a-getroster-rpc--compass_roster-tool-over-the-d4-presence-snapshot) | +| DL-136 | Channel post authority and forced subscription are channel-level policy fields (`post_policy` OPEN/OWNER_ONLY + `owner_account_id` + `mandatory_subscription`), never a `ChannelKind` or a visibility overload; `mandatory_subscription` is enforced read-side as a third disjunct in D1's subscriber resolution; an owner/operator `SetChannelPolicy` (create-or-update) is the only post-creation mutation path, its txn seeding delivery cursors for every member a mandatory flip newly targets. A Manager's coordination channel is auto-provisioned on first report gained via a reconciler hooked at the two store-level writers of `agent_accounts.parent_agent_id` (`store.CreateAgent`, `store.ReparentAgent`) — covering spawn, public `CreateAgent`, reparent, and any future writer by construction; resume on name-collision is ownership-checked (never adopts a user-created channel); membership reconciles with tree edges; dormant channels are never deleted (accepted MVP accretion). The coordination channel is OWNER_ONLY + mandatory-subscribe — a one-way manager→reports directive surface; report→manager and lateral coordination flows through DMs/group DMs, and agents at every level heavily prefer direct DMs/small targeted group DMs to keep coordination-token-cost low (standing directive, Matt). Precursor primitive to SEA-1622, not part of it. SEA-1722 | Active (Matt, 2026-07-31) | [manager comms substrate](compass-manager-comms-substrate/design.md#a2--sea-1722-coordination-channel--a-channel-level-post_policy--mandatory_subscription-auto-provisioned-from-tree-edges) | +| DL-137 | The pinned board is a server-side per-channel capped ordered POINTER set over existing topic-scoped messages: pinning references an existing `message_id` (validated to a topic of the channel, join through `topics`) and creates no `Message`, so DL-099's single-write-path stands; edit = a normal topic-mandatory post + compare-and-swap repoint (redelivery is ordinary DL-071/072/073 delivery of a new message id, dedup-safe); board txns serialize on `channels … FOR UPDATE` (cap + repoint races); startup delivery is a session-start pin sweep beside the D2 cursor sweep dispatching current pins regardless of cursor position; edits land at turn-settle (never steer); explicitly distinct from DL-096's client-local sidebar pins, and compatible with DL-054 (no centre/badge/read state). SEA-1723 | Active (Matt, 2026-07-31) | [manager comms substrate](compass-manager-comms-substrate/design.md#a3--sea-1723-pinned-board--a-pure-pointer-set-over-existing-topic-scoped-messages-edit--topic-mandatory-post--repoint-so-redelivery-is-delivery) | + +## Agent roles & prompts + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-138 | A Compass role's system prompt REPLACES OMP's block 0 via the fork's `customSystemPrompt` path (`custom-system-prompt.md`), which keeps auto-injected skills/rules/always-apply lists and the project/env footer by construction — append is rejected (it pays for both prose sets every turn; the skills-loss fear was the fixed #3014 pre-fix behavior) | Active (Matt, 2026-08-04) | [manager prompt §MP-1](compass-manager-prompt/design.md#decision-mp-1--replace-block-0-per-role-through-omps-custom-prompt-template-dl-129) | +| DL-130 | Compass OWNS both role prompts: the Manager block-0 is authored fresh (~350 words, coordinator-shaped, OMP as style reference only) and the implementer block-0 is a copy-and-adapt of OMP's full block 0 — an owned file diverging where Compass differs, never an inherited prompt | Active (Matt, 2026-08-04) | [manager prompt §MP-2](compass-manager-prompt/design.md#decision-mp-2--we-own-both-role-prompts-dl-130) | +| DL-131 | "Manager" is an agent ROLE label in prompts/docs/UI/marketing; proto/Go/UI data types stay `Agent`/`AgentAccount` (no rename); the role mechanism itself (role field, per-role bundles, per-role model/thinking) is SEA-1724 (Beta), forward-referenced only | Active (Matt, 2026-08-04) | [manager prompt §MP-3](compass-manager-prompt/design.md#decision-mp-3--manager-is-a-role-label-not-a-data-type-rename-dl-131) | +| DL-132 | Role prompts version v0→target: the frozen text is the target contract, v0 ships naming only primitives that exist at the shipped commit, and every unshipped affordance is an explicit `[TODO ]` line flipped on in the same PR that lands its primitive — updates ride the existing bundle re-materialize + Reload path (DL-081), never new delivery | Active (Matt, 2026-08-04) | [manager prompt §MP-4](compass-manager-prompt/design.md#decision-mp-4--v0target-prompt-versioning-dl-132) | +| DL-133 | The Manager content layer split is frozen: irreducible operating model (identity, tree position, async comms, work loop, human merge gate, compaction) in block 0; invariants as always-apply rules (never-block/own-your-issue/red-green-testing adapted, never-merge/design-first/compact-often new); procedure in on-demand skills (comms-playbook, management-trees, compass-setup, supervisor-channel, manager-coordination-channel, jj-stacking/review/design adapted) — with the name-Managers-by-FUNCTION-never-tool tenet | Active (Matt, 2026-08-04) | [manager prompt §The layer split](compass-manager-prompt/design.md#the-layer-split-dl-133) | +| DL-134 | Dogfood implementers are IN-PROCESS SUBAGENTS the Manager briefs via the live `task` mechanism (the Manager authors each subagent's brief — task + context — at spawn time and names a standing subagent def whose body supplies block-0; there is no per-spawn `systemPrompt` param); the implementer block-0 is therefore an ACTIVE Dogfood deliverable, shipping as a fleet-delivered subagent def under `config/agents/` (body = `AgentDefinition.systemPrompt`), consumed by `task` + `discoverAgents` at `cf048ca` with no `cli.ts` wiring; `agents_spawn_peer`/`agents_despawn_peer` are reserved for standing peer/child Managers; per-implementer containers are rejected for Dogfood (container-per-subagent too heavy) and gate on the SEA-1717 brain/hands split (Beta, distinct from SEA-1724's role mechanism), which alone defers the containerized non-subagent implementer path (not the prompt) | Active (Matt, 2026-08-04) | [manager prompt §MP-5](compass-manager-prompt/design.md#decision-mp-5--dogfood-implementers-are-in-process-subagents-dl-134) | +| DL-139 | The `task`-subagent prompt seam is ADDITIVE, not a replace: a subagent def body is spliced INTO the full default OMP block-0 (`executor.ts:2808-2810`, `[...defaultPrompt.slice(0,-1), subagentPrompt, defaultPrompt.at(-1)]`), which co-renders around it (identity, Tool Policy, Delivery Contract, Internal URLs, Delegation), and the def body renders VERBATIM with no injector (`task/types.ts:362`, `systemPrompt: string`). So the Dogfood implementer `config/agents/implementer.md` is a THIN ROLE DELTA — identity + only the Compass divergences the default gets wrong (async comms/no `ask`, one-slice-then-yield, jj-stacking push, operator-not-user) — dropping everything the default supplies and carrying no `[runtime-injected]` placeholders and no `lsp`/todo mandates (`task.enableLsp` defaults false, `settings-schema.ts:4528`; the wrapper COMPLETION already sets no-todo); the full copy-and-adapt of OMP's block-0 is kept as the FUTURE SEA-1717 standalone-container artifact (a REPLACE path absent at `cf048ca`), not the Dogfood deliverable. Corrects DL-134's "block-0" framing (the def is the delta, not the full block) | Active (Matt, 2026-08-05) | [manager prompt §Implementer block-0](compass-manager-prompt/design.md#implementer-block-0-dogfood-thin-role-delta--the-future-full-container) | + +## UI shell + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-030 | UI: the communication layer is the primary surface (comms-primary shell) | Superseded by DL-031 (Matt, 2026-07-06) | [v0.6 §T7](compass-0.6/design.md#t7--ui-the-communication-layer-as-the-primary-surface) | +| DL-031 | UI shell is board-primary: channel chat lives inside the board-primary workspace, not as the top-level surface | Active (Matt, 2026-07-12) | [v0.7 §Approach](compass-0.7-channel-workspace/design.md#the-shape-in-one-pass) | +| DL-032 | Compass state is canonical; the tracker is a projection of it | Superseded by DL-129 (Matt, 2026-08-04) | [ADE shell §D2](compass-ade-shell/design.md#d2--compass-state-is-canonical-the-tracker-is-a-projection) | +| DL-033 | The workstream lifecycle is Backlog→Todo→Queued→Blocked⇄In Progress→In Review→Done | Active (Matt, 2026-07-10) | [ADE shell §D1](compass-ade-shell/design.md#d1--workstream-state-model) | +| DL-034 | The right sidebar mirrors Orca; fleet + workstream conversations live there | Active (Matt, 2026-07-10) | [ADE shell §D5](compass-ade-shell/design.md#d5--right-sidebar-mirrors-orca) | +| DL-035 | The bottom dock is a tabbed Dispatcher + Warden surface | Superseded by DL-036 (Matt, 2026-07-11) | [ADE shell §D7](compass-ade-shell/design.md#d7--bottom-dock-is-tabbed-dispatcher--warden) | +| DL-036 | The bottom dock is folded into the right sidebar: one activity bar, one signal, the dock removed | Active (Matt, 2026-07-11) | [dock-in-sidebar §D1](compass-dock-in-sidebar/design.md#d1--one-activity-bar-one-signal-the-dock-state-is-removed) | +| DL-037 | Standalone channel asks are answerable wherever they are asked (first-responder-wins), not read-only-deferred | Active (Matt, 2026-07-20) | [ask-in-channel §The designed change](compass-ask-in-channel/design.md#the-designed-change) | +| DL-067 | The `workstream` concept is removed from the Compass UI entirely; the board unit becomes the canonical Compass `Issue` (and `PullRequest`) — the forge artifact's fields plus the Compass machinery (lifecycle state, priority, assignee, tracker projection), server-computed | Active (Matt, 2026-07-30) | [issue model §Approach](compass-issue-model/design.md#approach) | +| DL-068 | Agent attribution on issues and PRs is consumed from the canonical type's server-parsed `agent`/owner fields (parsed at ingestion per #995's stamping) and rendered as an untrusted claim, hedged unless the server's forge-login cross-check verifies it (DL-050, #995 OQ-1); the UI never stamps a header and never derives `assignee` (Compass truth) from `agent` (a parsed claim) | Superseded by DL-094 (Matt, 2026-08-01) | [issue model §Attribution](compass-issue-model/design.md#attribution-consumed-never-stamped) | +| DL-069 | Compass owns a single canonical `compass.v1` `Issue`/`PullRequest` type pair (forge fields + agent author/owner attribution + Compass machinery); the server translates raw forge data into these types at ingestion, the raw forge shape is never a proto/wire type, and the UI consumes only the generated canonical type from `@compass/client` | Active (Matt, 2026-07-31) | [issue model §The Compass Issue and PullRequest types](compass-issue-model/design.md#the-compass-issue-and-pullrequest-types) | +| DL-070 | The DL-033 issue lifecycle (its seven working states unchanged, extended by a terminal `ARCHIVED` state per DL-091) is server-authoritative: a server-side board projection — composing the existing `go/internal/board` recorded-state pattern and DL-055's board-as-local-index-scan — computes and streams the canonical type, moving DL-032's canonical Compass state server-side | Active (Matt, 2026-07-30) | [issue model §The server projection](compass-issue-model/design.md#the-server-projection) | +| DL-091 | Archiving a Compass issue is a lifecycle transition to a terminal `ARCHIVED` state via `UpdateIssueState`, not a separate `archived_at` marker field or a separate `ArchiveIssue` RPC; an archived issue drops off the active board and is listed in the Done view's Archived section, and every board item carries a forge identity (`ForgeRef` provider+host) so multi-forge artifacts never collide on `repo` alone | Superseded by DL-129 (Matt, 2026-08-04) | [issue model §The write path](compass-issue-model/design.md#the-write-path) | +| DL-094 | Compass agent attribution is a plain display fact, not a trust claim: `AgentAttribution` slims to `{agent_handle}` (field numbers 2–3 and names `owner_handle`/`verified` reserved), populated directly from the parsed owner header at ingestion with no forge-login cross-check and no population gating; the board and the forge-tool path both render the bare `@handle` with no hedge; owner is an account property (`AgentAccount.owner_user_id`), resolved server-side, never restated per artifact. Reverses DL-068's hedge-unless-verified board render and the verified/cross-check of DL-050 and #995 OQ-1 as applied to attribution display; routes around #1018/#1037 (unedited; no wire build shipped) | Active (Matt, 2026-08-01) | [attribution simplification §Approach](compass-attribution-simplification/design.md#approach) | +| DL-096 | Sidebar pinning is a user-configurable presentation layer over the agent set (the layer DL-095 names and defers): any agent can be pinned as a right-sidebar fleet tab, the hardcoded always-on Supervisor/Warden tabs and their `FleetTab` union literals are removed (no built-in privileged agent), the pin set is a per-user client-local UI preference (`localStorage`-backed; never DL-078's fleet-wide bundle; server per-user prefs a post-MVP seam), the default pin set is EMPTY, and the shell boots onto the first pin that resolves to a visible agent, else Status, instead of a special-cased Supervisor conversation | Active (Matt, 2026-08-01) | [sidebar pins §Approach](compass-sidebar-pins/design.md#approach) | +| DL-097 | The Bridge board splits into peer Issues/PRs tabs inside the board view (a Bridge-local tab signal, not a new `View` or a third `BoardMode`): the Issues tab is today's board ordered by Record C's `treeOrder`; the PRs tab is a flat one-row-per-OPEN-PR list (the `primaryPr` compression deliberately does not apply) grouped by assignee in `treeOrder` with the tab badge = the open-PR row count; issues ↔ PRs cross-link through the existing issue-keyed selection (`selectIssue`, no new PR-selection state); and the card's per-check pip strip collapses to a CI badge (the `ChecksSummary.state` roll-up) + a review badge (latest-per-author verdict, `changes_requested` > `approved` > `commented`) as pure `board-render.ts` helpers — a VIEW remodel consuming the frozen #1018 model, no proto/server/model change | Active (Matt, 2026-08-01) | [board view §Approach](compass-board-view/design.md#approach) | +| DL-129 | The issue-state write model is agent-primary: the Compass UI is read-only for lifecycle state; agents set state via a sibling `BoardCall*` family on the `AgentGateway` socket relayed by `RelayBoardCall` (DL-049/DL-076 convention), and users set state in the external tracker, whose native status is ingested into the DL-070 server projection through the reverse `TrackerStatusMapping` (DL-053 poll; echo-suppressed in tracker-status space, tracker-sourced transitions never mirror back, stale polls dropped by a recency guard). All issues flow from the tracker (no Compass-native/unlinked issues in the MVP); `ARCHIVED` is not agent-only (a Done issue auto-transitions to `ARCHIVED` after 24h, a user reopen un-archives via ingestion); MVP write-authz is single-trust-domain (hierarchical scope is a filed follow-up over DL-095's tree). `UpdateIssueState` on `CompassService` is never built (no wire build shipped). Supersedes DL-091 (the `UpdateIssueState`-on-`CompassService` mechanism; archive-as-transition survives here) and DL-032 (the tracker is now also a state write source); DL-070/DL-033/DL-067 unchanged | Active (Matt, 2026-08-04) | [agent-primary lifecycle §Approach](compass-agent-primary-lifecycle/design.md#approach) | +| DL-113 | An unresolvable pinned agent does not vanish: the pin keeps its activity-bar item, its pane renders an "agent unreachable" state, and removal is a manual unpin only — a visibility fluctuation to unresolvable never auto-switches the active tab to `status` (the unpin-active→status fallback is retained, and the pane arm closes the Switch's no-default-arm gap) — amending DL-096's unresolvable-pin handling (the frozen record's §T2 filter and §T3 fluctuation-coercion clauses are superseded; DL-096's pinning-layer core stands) | Active (Matt, 2026-08-02) | [unreachable-pin amendment §Approach](compass-sidebar-pins-unreachable-amendment/design.md#approach) | +| DL-127 | The Compass UI shell adopts `@solidjs/router` in `HashRouter` mode (Matt's library ruling) emitting the frozen `#/channel//topic/` route shape for a client-only SPA in the Wails v3 webview (DL-110); and the URL becomes the source of truth for view + routed selection — the store derives `View` and routed memos from the matched route (route drives store, not store drives route), with `MemoryRouter` for deterministic tests | Active (Matt, 2026-08-03) | [shell routing §A2](compass-shell-routing/design.md#a2--source-of-truth-url-drives-the-store-decision) | +| DL-128 | The Compass UI adopts `@tanstack/solid-query` + `@connectrpc/connect-query-core` for server-state: one app-lifetime `QueryClient` created beside the store singleton in `index.tsx` and provided via the SolidJS `QueryClientProvider` (Solid adapter, never React); server-state reads move off the store onto query hooks over the generated Connect clients, the store keeping only client/UI state | Active (Matt, 2026-08-03) | [query layer §Approach](compass-ui-query-layer/design.md#approach) | + +## Threading & rendering + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-038 | The session trace is a dedicated OMP-native opaque session stream, rendered by OMP's own renderer | Superseded by DL-039 (Matt, 2026-07-20) | [v0.6 §Approach](compass-0.6/design.md#approach) | +| DL-039 | Compass builds a first-party typed session renderer; session events cross a typed gRPC stream, not opaque bytes and explicitly not ACP | Active (Matt, 2026-07-20) | [v0.8 §Change 2](compass-0.8-threading-and-session-renderer/design.md#change-2--first-party-typed-session-renderer) | +| DL-040 | Threaded replies use a Slack-style side-thread panel keyed by a deterministic root id | Superseded by DL-098 (Matt, 2026-08-02) | [v0.8 §Change 1](compass-0.8-threading-and-session-renderer/design.md#change-1--reply-panel--side-thread) | +| DL-041 | The message surface is a virtualized thread list with streaming markdown (solid-markdown + Shiki), mention-first composition | Superseded by DL-098 (Matt, 2026-08-02) | [message surface rendering §Approach](compass-message-surface-rendering/design.md#approach) | +| DL-098 | Compass adopts Zulip's threading model: a channel is a collection of named topics and every message belongs to exactly one topic (`messages.topic_id NOT NULL`) and stores only that topic id — never a channel id; the channel is reached through `topics.channel_id` (so moving a topic between channels is a single-row update). `parent_message_id` and the message's channel container are removed outright (pre-dogfood: no reserved tombstone, field numbers reused). Topics are first-class (proto `Topic` + `topics` table), named, id-stable, in exactly one channel; posting is topic-mandatory via `topic_name` get-or-create (one call); there is no default, general, or catch-all topic anywhere — channels carry zero messages directly. Rename-to-existing merges topics; the delivery cursor stays per-(agent,channel) with topic as deliver-op metadata, its channel resolved through the topic join (clarifies DL-072). The pre-dogfood SQL migrations collapse to a single baseline (no data conversion). UI is Zulip two-level drill-in: a channel shows its topic index (no composer), a topic shows its messages + composer (no nested threading), the left sidebar lists a channel's recent topics. Reverses DL-040 (Slack side-thread panel) and DL-041 (virtualized thread list, re-pointed at the topic list) | Active (Matt, 2026-08-02) | [Zulip threading model §Decisions](compass-zulip-threading-model/design.md#decisions-ratified) | +| DL-099 | An agent's streamed turn does not write to comms at all: the SEA-1364 T3 conversation write-through (streamed-turn → home-channel comms auto-post) is removed. A comms `Message` is created only by an explicit `comms_post_message(topic)` call (agents) or the human client's PostMessage — the comms tool is the only agent comms-write path, and it carries a mandatory topic. The streamed turn survives in full on the session/trace surface (`SessionFrame.typed_event`). Structural enforcement of on-topic posting: an agent cannot flood a channel by talking, only by deliberately posting | Active (Matt, 2026-08-02) | [Zulip threading model §D7](compass-zulip-threading-model/design.md#d7--one-comms-write-path-the-streaming-write-through-is-removed-supersedes-sea-1364-t3) | + +## Ask contract + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-042 | The UI pivots around the communication layer; the frozen single-question `compass.v1.Ask` is D5's contract child | Superseded by DL-043 (Matt, 2026-07-04) | [v0.5 §D5](compass-0.5/design.md#d5--the-ui-pivots-around-the-communication-layer-the-acp-conversation-is-a-dm) | +| DL-043 | Reshape `compass.v1.Ask` to `repeated AskQuestion` (Option A, buf-breaking), carrying all native axes + free-text | Active (Matt, 2026-07-15) | [ask typed derivation §Approach](compass-ask-typed-derivation.md#approach--option-a-reshape-ask--repeated-askquestion-buf-breaking) | + +## Desktop shell + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-044 | The Tauri desktop shell is a thin webview→daemon shell over a swappable transport seam; the daemon spawns detached and outlives the shell | Superseded by DL-106 (Matt, 2026-08-02) | [Tauri shell §Approach](compass-tauri-shell.md#approach) | +| DL-106 | The Compass native app is one binary in two modes (embedded / native-client); the mode difference is confined to the connection provider and the stack supervisor, and nothing above the transport boundary assumes local | Active (Matt, 2026-08-02) | [native app §A1](compass-native-app/design.md#a1--one-binary-two-modes-mode--which-connection-the-resolver-produces) | +| DL-107 | One framework-neutral shell IPC contract (`compass_rpc` + `head\|body\|end\|error` frames) carries gRPC-Web over a custom `fetch` for both modes — embedded pumps to the UDS, native-client to the TLS network door with shell-side private-CA trust and a shell-injected bearer | Active (Matt, 2026-08-02) | [native app §A2](compass-native-app/design.md#a2--the-transport-seam-one-frame-contract-two-bridges) | +| DL-108 | Embedded lifecycle is a Go stack supervisor (`go/internal/stack` + `compass-stack up\|down\|status`) of spawned children (never in-process), linger-by-default, productizing the dogfood chain with a supervised private Postgres, expiry-aware cert rotation, and lockfiled attach | Active (Matt, 2026-08-02) | [native app §A3](compass-native-app/design.md#a3--embedded-lifecycle-a-go-stack-supervisor-spawned-by-the-shell) | +| DL-109 | Native-app mode selection is `$XDG_CONFIG_HOME/compass/app.toml` (absent → embedded default); the native-client bearer is entered in a connect screen and stored keychain-first (0600-file fallback), never in config-file or argv | Active (Matt, 2026-08-02) | [native app §A4](compass-native-app/design.md#a4--mode-selection-and-native-client-configuration) | +| DL-110 | The Compass native shell is Wails v3 (Go), its module under `go/` (`go/cmd/compass-app`) importing `go/internal/stack` and the bridge pump directly; re-decides DL-044's framework on the Go stack (Wails, not Tauri) | Active (Matt, 2026-08-02) | [native app §OQ1](compass-native-app/design.md#oq1--shell-framework-tauri-rust-vs-wails-go-vs-other--blocks-t3) | +| DL-111 | A new `compass.v1` `WhoAmI` RPC returns the caller's account id from its own credential (embedded: socket ambient-admin; native-client: bearer subject), retiring the parked `caller_account_id` seam and the connect-screen caller-id field | Active (Matt, 2026-08-02) | [native app §OQ7](compass-native-app/design.md#oq7--embedded-caller-identity-mechanism--blocks-t4-cross-lane-compass-server) | +| DL-112 | The `compass-agent` image is published to GHCR and `podman pull`ed by `compass-stack` at first run (not bundled in the app), making a GHCR publish lane for `compass-agent` a native-app packaging dependency | Active (Matt, 2026-08-02) | [native app §OQ6](compass-native-app/design.md#oq6--agent-image-distribution-for-embedded-mode--blocks-t4-reshapes-t6) | + +## Agent batteries + +| ID | Decision | Status | Record | +| --- | --- | --- | --- | +| DL-140 | The default Compass batteries bundle is LANGUAGE-NEUTRAL: no `go-*` rules or `golang-*` skills ship by default — for Dogfood the user adds language skills via the normal user-skill path; language packs (per-repo language → skill-pack selection) are the SEA-1739 Beta feature, an additive seam on the same bundle delivery surface | Active (Matt, 2026-08-05) | [batteries §BI-3](compass-batteries-included/design.md#decision-bi-3--language-neutral-default-packs-are-beta-dl-140) | +| DL-141 | `delegated-implementation` ships as NO separate Compass skill: its when-to-delegate litmus, review-every-diff discipline, and brief contract fold into the `management-trees` skill's delegation-mechanics section (SEA-1732 T5), with the OMP tier naming (`implement`/`implement-hard`, Opus thinking levels) stripped in favor of the `task` mechanism + standing subagent defs (DL-134) | Active (Matt, 2026-08-05) | [batteries §BI-2](compass-batteries-included/design.md#decision-bi-2--three-sourcing-modes-one-per-manifest-row) | +| DL-142 | The wave `version-control` rule's three footguns (auto-amend; submit-is-the-only-push-path; review-fixes-are-additive-never-amend+force-push) fold into ONE Compass always-apply rule that keeps the jj model + additive-fixes + never-`git push` and keeps `jj-vine submit` as the push path (Compass KEEPS jj-vine as its stacked-PR tool — submit, PR-tree-in-description, `gh stack` forge integration — retargeted to the Compass repo; the `jj-hp` push-guard is Compass's own push-authorization concern, resolved in SEA-1732 T8); the adapted skill is named `jj` (Compass names skills on their own merits, not by mirroring the wave) — renaming SEA-1732 T8's `jj-stacking` (that frozen record's `jj-stacking` text is superseded by this citation per the immutable-cell convention, §conventions); and there is no separate `stacking` skill (stacking lives in the one adapted `jj` skill, SEA-1732 T8) | Active (Matt, 2026-08-05) | [batteries §BI-2](compass-batteries-included/design.md#decision-bi-2--three-sourcing-modes-one-per-manifest-row) | +| DL-143 | Eight wave artifacts are EXCLUDED from the Compass batteries as wave-specific infrastructure that would confuse a Compass agent — `multi-agent-wave`, `spawn-agent`, `wave-status-sync`, `session-recovery`, `nix-hosts`, `github-pr-review` (folds into the adapted `review`), `woodpecker-ci` (Compass CI = GitHub Actions), `zellij-session-safety` (Compass agents are session-isolated); exclusion is curation, not deferral — a future need routes through a new decision row | Active (Matt, 2026-08-05) | [batteries §BI-4](compass-batteries-included/design.md#decision-bi-4--the-exclusion-set-dl-143) | +| DL-144 | Every forwarded always-apply rule in the Compass batteries self-scopes by ROLE in its authored body: the `task` executor forwards parent session rules into every subagent (`executor.ts:2793`) but NOT the session `customTools` (`executor.ts:2829`, MCP-proxy tools only), so a Manager always-apply rule rides every implementer-subagent turn without the Manager affordances it assumes — `hold-your-lane`, `decision-authority`, and `own-your-issue` are actively wrong there. Each such rule's body branches explicitly (hands-subagent behavior AND Manager behavior); role-invariant rules need no branch. This is body-level self-scoping, NOT per-role delivery (the SEA-1724 seam). Resolves batteries OQ-1 | Active (Matt, 2026-08-05) | [batteries §BC-7](compass-batteries-included/design.md#global-constraints) | +| DL-145 | The B1 `version-control` always-apply-rule fold (DL-142) is hard-ordered AFTER SEA-1732 T8: it ships with or after T8, never before, and carries no `[TODO T8]` placeholder — an always-apply rule injected full-text every turn (BC-6) cannot ship the `never git push` invariant with only a placeholder where the one correct submit verb goes; T8 names the Compass submit path first, B1 writes it in (same BC-3 posture B5 takes with SEA-1734) | Active (Matt, 2026-08-05) | [batteries §Tasks B1](compass-batteries-included/design.md#tasks) | +| DL-146 | The Compass batteries ship `process-safety` at RULEBOOK tier (not always-apply as the wave file is): per-container session isolation (one container per standing agent, DL-143) removes the cross-agent/host clobber blast radius that justified `alwaysApply: true` on the shared wave box; the intra-container sibling-process residual (a Manager and its in-process `task` subagents share one container) is covered by rulebook forwarding (BC-7) + on-demand pull before a destructive kill, not always-on injection. Resolves batteries OQ-3 | Active (Matt, 2026-08-05) | [batteries §Implementer domain rules](compass-batteries-included/design.md#implementer--domain-rules-configrulesmd-rulebook-tier) | +| DL-147 | The default Compass batteries are STACK-neutral, not merely language-neutral: no default battery names a language- or CI-engine-specific command as normative (illustrative tables permitted, marked). devenv/direnv is a deliberate product baseline (kept, B3); GitHub-Actions-specific `ci-failure-triage` hooks come OUT of the default skill and become SEA-1739 pack territory (same seam shape as a language pack), the default skill shipping the CI-engine-neutral 4-step triage discipline. Refines DL-140, and reverses DL-143's `woodpecker-ci`-row parenthetical rationale (`Compass CI = GitHub Actions`) — that parenthetical stays verbatim in DL-143 per the immutable-cell convention (§conventions), its normative posture superseded here; sharpens BC-4 to a checkable gate | Active (Matt, 2026-08-05) | [batteries §BI-3](compass-batteries-included/design.md#decision-bi-3--language-neutral-default-packs-are-beta-dl-140) | diff --git a/docs/designs/product/compass-0.4/design.md b/docs/designs/product/compass-0.4/design.md new file mode 100644 index 00000000..70ab9cd1 --- /dev/null +++ b/docs/designs/product/compass-0.4/design.md @@ -0,0 +1,205 @@ +# Compass v0.4 — Leverage the commodity layers, own the moat + +Status: Historical + +> Internal design record — July 2026. A strategic-posture revision of the Compass product +> design. The full ADE vision it builds on is the frozen v0.3 record +> ([`../compass.md`](../compass.md)); this record captures only what v0.4 changes +> and why. Living built-behavior is the spec ([`../../../specs/product/compass.md`](../../../specs/product/compass.md)). + +## Problem + +The v0.3 design implied Compass would build much of its own stack: a bespoke +coordination/messaging layer, its own agent process management, and — read +literally — a path toward its own general coding agent. Three things changed the +calculation: + +- **The commodity layers aren't the moat.** Compass's differentiators are the + security runtime (Warden), per-agent container isolation, and the + issue→workstream Dispatcher/Bridge. Every hour spent owning a messaging + protocol or re-implementing an agent is an hour not spent on those. +- **The agent runtime is enormous and already exists.** Oh My Pi (OMP) is a + mature, feature-rich agent harness (loop + tools + provider/AI layer + memory + + eval + TUI + ACP). Rebuilding that surface — in any language — is a + multi-hundred-KLOC reimplementation of a moving target, not a "fork." +- **A coordination bus we'd want already exists.** Cotal (Apache-2.0, + NATS/JetStream) is further along than a hand-rolled bus on the axes Compass + needs (role-addressed delivery, hierarchical channels with replay-on-join, + JWT/ACL identity), and the OMP-side receiver work already exists. + +The intent of v0.4: state, as the product's posture, that **Compass builds only +its moat and leverages the commodity layers behind thin seams it controls.** + +## Approach + +Three moves, each behind a seam Compass owns, sequenced so no single dependency +is load-bearing before it's de-risked: + +1. **Coordination → Cotal.** Adopt Cotal as the coordination substrate behind a + thin interface Compass owns; resolve the trust-model gate (below) before it + carries anything past the baseline tier. +2. **Worker + Dispatcher agent → OMP over ACP.** OMP is the default/reference + agent and the runtime the Dispatcher runs on for MVP. BYOA stays the + compatibility surface. Compass improves OMP in the open (upstream + contribution), not as a private fork it maintains alone. +3. **seal → the Warden runtime, scoped tight.** seal is the one agent Compass + builds first-party, scoped to exactly what hosting Warden requires. Growing + seal into a general worker agent is explicitly deferred. + +**Result:** Compass focuses on the wedge — the Bridge, per-agent containers, and +the Dispatcher(OMP)/Warden(seal) agents. Coordination is leveraged (Cotal), the +general agent is leveraged (OMP), and Compass owns exactly the two things that +are the moat: the security runtime and the ADE orchestration. + +### Alternatives weighed + +- **Rebuild seal as an OMP fork (absorb OMP's features into seal's Rust→WASM + runtime).** Rejected. OMP's tools freely touch the filesystem, network, and + clock; seal's entire guarantee is that code *can't* except through signed + capabilities. Running OMP's tools outside the WASM sandbox forfeits the + guarantee; porting them inside it is a multi-year rebuild that fights the + model. The WASM sandbox earns its keep for the *auditor* (Warden), not for + re-hosting a general agent. +- **Keep owning the coordination layer (a bespoke bus).** Rejected. It's not the + moat, and a capable Apache-2.0 bus already exists. The residual risk (a v0 + dependency) is handled by the thin seam + the trust-tier gate, not by + rebuilding. +- **Make OMP just "one BYOA target among many," own no default agent.** Rejected. + A default agent is needed for the dogfood path, for the Dispatcher runtime, and + for the one place Warden's gate can be *structural* (OMP's tool-dispatch path) + rather than hook-config-dependent. OMP is that default; the others stay + first-class BYOA. + +## Decisions + +### OMP is the default/reference agent — and what OMP *is* + +OMP is an **external open-source project** (`can1357/oh-my-pi`). Compass runs a +**fork** (`mattwilkinsonn/oh-my-pi`) and contributes fixes and features +**upstream** rather than maintaining a divergent private fork; the fork is the +integration point and the staging area for upstream PRs, not a permanent +diverging branch. This is the accurate ownership model — OMP is *leveraged +external OSS with Compass as an active contributor*, **not** an "in-house +harness." The dependency-risk framing (below) depends on stating this correctly. + +OMP is the default because it is ACP-native (no wrapping adapter), it carries +Warden's gate structurally in its tool-dispatch path (which the agent cannot +rewrite, unlike a hook configuration), and it is the runtime the Dispatcher and +the first-party security paths run on. The other targeted agents (Claude Code, +Codex, Antigravity, OpenCode, Amp, any ACP agent) remain first-class BYOA +citizens, driven identically over ACP. + +**Design-doc changes:** v0.3 §5.1 (BYOA) reframes OMP from "first integration +target" to default/reference agent; §4.2 (Dispatcher) states it runs on OMP over +ACP for MVP. The word "in-house" is dropped wherever it described OMP. + +### seal is scoped to hosting Warden + +Warden runs on seal, the Rust→WASM agent Compass owns end-to-end, because the +security auditor is the moat. seal is scoped to exactly what hosting Warden +requires — no more. Warden's actuator surface is tiny (`pause_agent` + a +clear/caution/flag gate decision), and its loop needs are ordinary, so the +additions are bounded: the `pause_agent` IPC channel, the tool-call event feed, +the session-scoped taint registry, the synchronous gate-decision channel, and a +pre-inference secret classifier/redactor. The WASM sandbox is justified *here* — +isolating the auditor from the agents it watches and bounding the +credential-bearing payloads it inspects — even though it is not an MVP +requirement for the worker agents. + +Whether seal ever grows into a general worker agent is a separate, deliberately +deferred decision (v0.3 §14 gains a bullet). Nothing in the MVP depends on it. + +**Design-doc changes:** v0.3 §6.5 (Warden Implementation) reframes seal's role +from a passing mention into the explicit scoping above. + +### Cotal is the coordination substrate, behind a thin seam + +Compass adopts Cotal (Apache-2.0, NATS/JetStream) as the messaging layer for +agent↔Dispatcher signals, cross-agent presence, and replay-on-join — leveraged, +not owned. NATS clustering fits the multi-container (and later multi-host) model; +the Compass daemon can host or embed the broker. Compass couples to Cotal behind +a thin coordination seam it controls, so the bus stays swappable: the wire +contract is documented and the daemon could reimplement it over NATS directly if +it had to. Coordination *logic* (assignment, conflict map, board) stays +Compass's; only the transport is leveraged. + +**Trust-tier gate.** Cotal v0 is a trusted-broker model (plaintext to the broker, +no e2e or non-repudiation; signed envelopes reserved for later). Compass's +high-assurance tier (Enterprise/Federal) sits above "trust the broker," so the +trust boundary **cannot** be fully outsourced to a v0 bus for that tier. This is +not disqualifying for the baseline tier — Warden watches agent *behavior*, a +different layer than wire security, and the per-agent container's egress +allowlist bounds the broker connection. Cotal is adopted for baseline +coordination now and gated out of the high-assurance trust path until the gate is +lifted. **Lift criteria + owner: SEA-1113.** + +**Design-doc changes:** v0.3 gains a §7.7 (Coordination substrate: Cotal). + +### Upstream contribution is a distribution channel + +Compass leverages two external OSS projects (OMP, Cotal) and contributes into +both. That is also GTM: the connectors and fixes Compass upstreams earn codebase +knowledge, protocol influence, first-class connectors those teams help maintain, +and exposure to their users. Leveraging the commodity layers instead of +rebuilding them frees the team for the moat while the leveraged projects' +communities become an audience. + +**Design-doc changes:** v0.3 §11 (Business Model) gains an upstream-contribution +paragraph. + +## Risks + +- **OMP release cadence (dependency risk).** Making OMP the default and the + Dispatcher runtime couples a load-bearing path to an external project's cadence + and direction. Mitigation: the agent is driven over ACP — a standard, swappable + interface, so a BYOA agent can stand in — and the upstream-contribution flow + keeps Compass close to the project rather than dependent on a divergent private + fork. The coupling is real and stated; the ACP seam is what keeps it from + becoming lock-in. +- **Cotal v0 trust model.** Trusted-broker, plaintext to the broker, no e2e. + Tracked and gated by SEA-1113; baseline coordination proceeds, the + high-assurance tier waits on the lift criteria. + +## Plan + +The v0.4 content lands as edits to the frozen v0.3 design doc's successor plus the +living spec and this record. Because `docs/designs//` records are frozen +once decided, the pivot is captured *here* (a new record) rather than by +rewriting `../compass.md`; the living spec is updated to point at the current +rationale and to reflect the pivot in its forward-looking overview. + +- **T1 — This design record.** Problem · Approach · Decisions · Plan, with OMP's + ownership stated accurately, the seal scoping, the Cotal seam + SEA-1113 + gate, and the two risks. *(This file.)* +- **T2 — Living spec cross-reference + overview.** Point the spec's design-record + link at the current rationale, and update its forward-looking overview so the + not-yet-built runtime/Dispatcher/Warden description reflects OMP-over-ACP, + seal-hosts-Warden, and Cotal coordination — without inventing + Requirement/Scenario contracts for unbuilt behavior. +- **T3 — Tracking issue.** SEA-1113 filed for the high-assurance Cotal + trust-model gate; referenced from this record and the spec. + +## Tasks + +- [x] Write this design record (`docs/designs/product/compass-0.4/design.md`). +- [x] File SEA-1113 for the Cotal high-assurance trust-model gate. +- [x] Update the living spec (`docs/specs/product/compass.md`): design-record + cross-reference + forward-looking overview reflect OMP-over-ACP, + seal-scoped-to-Warden, and Cotal coordination. +- [x] markdownlint the new + edited docs clean. + +## Global Constraints + +- **`docs/designs//` records are frozen once decided** (`docs/README.md`, + `docs/designs/platform/docs-system.md`): capture a change as a new record, never + by rewriting a decided one. v0.3 `../compass.md` stays as-is. +- **The living spec states only built behavior**, as `### Requirement:` + + `#### Scenario:` contracts; it defers rationale to the design records. The + pivot is not built, so it appears in the spec only as the design-record + pointer and the forward-looking overview prose — no fabricated contracts. +- **No persona names or agent-product names** in this record (it lives in the + repo; keep planning personas out). Linear issue refs and the Tasks checklist + are fine — this is an internal design record, not a published `` + artifact. +- **OMP is external OSS** (`can1357/oh-my-pi`) that Compass forks + (`mattwilkinsonn/oh-my-pi`) and contributes upstream to — never "in-house." diff --git a/docs/designs/product/compass-0.5-server/design.md b/docs/designs/product/compass-0.5-server/design.md new file mode 100644 index 00000000..3d4016fa --- /dev/null +++ b/docs/designs/product/compass-0.5-server/design.md @@ -0,0 +1,917 @@ +# Compass v0.5 Server tier — the networked, multi-user `compass.v1` door (T2) + +Status: Historical + +> Internal design record — July 2026. A **child record refining task T2** of the +> frozen v0.5 pivot record +> ([`../compass-0.5/design.md`](../compass-0.5/design.md), task lines 663-673): +> "Promote the daemon (`../compass.md:270-276`) into a network Server: add the +> authenticated TLS network listener + client transport-mode selector the +> reserved seam names (`../compass-tauri-shell.md:107-121`, D3), and evolve +> `compass.v1` (`../compass.md:278-286`, D2)". It decomposes that one task into +> an executable contract; it does **not** rewrite the parent or any earlier +> frozen record (records freeze on merge; supersede by citation only). +> +> Code claims are grounded against the working tree at branch +> `compass-sea-1195-comms-server` (T1 landed on it, PR #535, now merged) and, +> for the contract, against **PR #533 head `8ec7dcf5`** — cited below as "the +> contract tip". **#533 has since merged (`5eb5a063`), freezing the contract**; +> the body's tip-shaped citations and their "binds at freeze" caveats resolve to +> the frozen schema, with the one shape delta the freeze introduced (the +> console→workspace rename) recorded in Global Constraints and OQ4. + +## Problem / Intent + +The daemon serves `compass.v1` only over an owner-restricted Unix socket — +"bind the `compass.v1` service to a Unix domain socket … The socket serves both +native gRPC (HTTP/2) and gRPC-Web (HTTP/1.1) … no localhost TCP on the shipped +path" (`crates/compass-daemon/src/serve.rs:1-4`), with the socket as +"the daemon's whole trust boundary on the local machine" +(`serve.rs:95-96`) — while the T1 communication layer +(`crates/compass-comms`, PR #535) is a library with no wire surface +at all. T2 promotes the daemon into the **Server tier**: it wires the T1 `Comms` +service to the generated `CommsService` gRPC trait, and adds the authenticated +TLS network listener the hosted-mode seam reserved — "the daemon has no +authenticated network listener today (UDS + dev-loopback only), so hosted mode +needs a TLS+auth server transport on the daemon plus a client-side +transport-mode selector" (`../compass-tauri-shell.md:116-119`) — so multiple +authenticated Clients reach one Server over the network (D2, D3). + +## Approach + +The Server is the **daemon promoted in place**, not a new architecture — the +parent already frames it so: "This is the daemon of `../compass.md:274` promoted +to a network service — realizing the reserved hosted-mode transport …, not a new +architecture" (`../compass-0.5/design.md:86-88`). Five design forks were live; +each is decided below with its grounding. + +### F1 — Server crate location: promote `compass-daemon` in place + +**Decision: extend `compass-daemon`; no new `compass-server` crate in T2.** + +What the daemon crate already owns is exactly the scaffolding T2 reuses: + +- The serve loop with eager bind, single-instance socket probing, 0600 + permissions, inode-checked cleanup, and a shutdown watch fanned out to every + server task and every open stream (`serve.rs:50-227` — the `select!` racing + the UDS task, the optional dev-TCP task, and the external shutdown at + `serve.rs:175-202`). +- The sequenced event bus — "a monotonic-`seq` ring buffer plus a broadcast + live-tail … snapshot the ring at `since_seq = 0`, replay only what follows a + cursor, and signal a resync when the cursor predates the ring" + (`crates/compass-daemon/src/events.rs:1-5`) — plus the + `forward()` replay-then-tail stream driver selecting on shutdown + (`src/service.rs:183-229`). +- The gRPC-Web stacking (`GrpcWebLayer` over the same tonic services, + `serve.rs:132-150`). + +Alternatives considered: + +1. **New `compass-server` crate depending on `compass-comms` + reusing daemon + scaffolding.** Rejected for T2. The daemon still owns the single-host + container/session lifecycle (`src/runtime/` — podman, images, egress, + workspaces, ACP sessions; `src/runtime/mod.rs:1-19`), which D3 splits onto + the Runner only at T3 ("Supersedes/refines v0.3 §7.1's single-host daemon … + by splitting container hosting onto the Runner", + `../compass-0.5/design.md:253-255`). A new crate now must either duplicate + the serve/event scaffolding or move it out from under a crate that still + needs it — churn against a boundary T3 will move anyway. (Two weaker reasons + were considered and are *not* load-bearing: `compass-shell` dev-depends on + `compass-daemon` (`compass-shell/Cargo.toml:48`), but a dev-dependency does + not link the server, so it would not block a library `compass-server`; and + moon's "no application project `dependsOn` another" rule + (`../compass-tauri-shell.md:102-103`) bites only an *application* project, + whereas a `CommsGrpc`/serve library crate is library code. The decision rests + on the scaffolding co-location + the T3-rename point below, not on these.) +2. **Rename the crate/binary to `compass-server` now.** Rejected as + churn: the promotion is behavioral, and T3 — which physically removes + `runtime/` onto the Runner — is the natural point to settle naming, when the + crate's remaining responsibility set is final. + +**Migration implication:** T3 extracts `src/runtime/` into the Runner binary and +may then rename what remains; nothing in T2 hard-codes against that split. New +T2 modules (`auth.rs`, `comms_grpc.rs`) depend on `compass-comms` + +`compass-proto` only, so they move (or stay) wholesale. + +### F2 — The authenticated network listener: rustls via tonic `tls-ring`, operator-provisioned certs, bearer-token accounts + +**Decision: terminate TLS in-process with tonic's rustls stack; authenticate +each Client RPC with a per-user bearer token resolved to a D9 account.** + +**TLS stack.** The seal tree is already a rustls shop: `seal-daemon` pins +`rustls = { version = "0.23", default-features = false, features = ["ring", …] }` +with the comment "rustls process-level crypto provider needs to be installed +from main before the runtime's TLS stack tries to construct a ServerConfig / +ClientConfig" (`oss/seal/crates/seal-daemon/Cargo.toml:40-46`), installs it at +startup — `rustls::crypto::ring::default_provider().install_default()` +(`seal-daemon/src/main.rs:39-41`) — and `seal-runtime` already carries +`rcgen = "0.14"` + `tokio-rustls = { version = "0.26", … features = ["ring", …] }` +as dev-dependencies for TLS-exercising tests +(`oss/seal/crates/seal-runtime/Cargo.toml:82-86`). tonic 0.14 (the workspace +pin, root `Cargo.toml:22`) ships the matching server surface behind its +`tls-ring` feature (`tls-ring = ["_tls-any", "tokio-rustls/ring"]`, +tonic-0.14.6 `Cargo.toml`): + +- `ServerTlsConfig` with `identity(Identity)`, `client_ca_root(Certificate)`, + `client_auth_optional(bool)` (tonic-0.14.6 + `src/transport/server/tls.rs:8-56`), applied via + `Server::builder().tls_config(cfg)` (`src/transport/server/mod.rs:166`). +- TLS applies to a **custom incoming stream** — `serve_internal` wraps the + incoming connections with the configured acceptor + (`src/transport/server/mod.rs:786-790`), so the daemon's existing + `serve_with_incoming_shutdown` pattern (`serve.rs:145-147`) carries over + unchanged to a TLS TCP listener. +- The acceptor advertises ALPN `h2` only + (`src/transport/server/service/tls.rs:58`), so the network door is + HTTP/2-native; gRPC-Web browser clients negotiate h2 via ALPN and work over + the same port (`GrpcWebLayer` is protocol-version-agnostic; the daemon + already stacks it, `serve.rs:141-144`). + +**Cert provisioning.** Operator-provided PEM paths — `--listen +--tls-cert --tls-key `, loaded with +`Identity::from_pem(cert, key)` (tonic-0.14.6 `src/transport/tls.rs:55-59`). +This is the standard self-hosted posture (D12's rationale makes self-hostable a +hard product constraint, `../compass-0.5/design.md:489-491`); operators bring a +Let's-Encrypt/internal-CA cert or front the Server with their own re-encrypting +proxy. A dev/test convenience path mints a self-signed pair with `rcgen` +(dev-dependency only, mirroring the `seal-runtime` precedent above) for the +integration tests. There is **no plaintext network listener**: a bearer token +over cleartext is credential disclosure, so TLS flags are required whenever +`--listen` is given (the loopback `--dev-http` endpoint stays, unchanged, for +browser dev — `serve.rs:152-169`). ACME automation is deferred (out of MVP +scope; an operator concern the PEM seam already accommodates). + +**Client authentication → D9 account.** The contract tip pins the model: +"the caller is the account authenticated on the connection (the Server's TLS + +token transport, D3/D10) — never a field in a request, which would be +spoofable" (contract tip +`crates/compass-proto/proto/compass/v1/comms.proto:31-33`). mTLS +client certificates are rejected for Client↔Server: the MVP Client is a +**browser** (parent D7), and the browser gRPC-Web transport is fetch-based with +no workable client-cert story — while a bearer token rides both transports as +the `authorization` metadata/header (connect-web transports accept +`interceptors?: Interceptor[]`, +`packages/compass-client/node_modules/@connectrpc/connect-web/dist/esm/grpc-web-transport.d.ts:32`). +So: + +- The Server keeps a **token store**: SHA-256 hashes of issued tokens → + `AccountId` (`sha2`/`base64` are already workspace deps, root + `Cargo.toml:85-86`; `AccountId` wraps a UUID, + `crates/compass-comms/src/account.rs:16-19`). Tokens are + 32 random bytes, base64url-encoded, shown once at issuance; only hashes are + stored. Like the T1 account state it maps onto, the store is in-memory in T2 + and swaps to the T5 Postgres store behind the same accessors (T1 already + documents this swap: "in-memory here; the T5 store swaps Postgres in behind + these same accessors, D12", + `crates/compass-comms/src/service.rs:3-4`). **In-memory has two + consequences T2 accepts explicitly** (both resolved by the T5 durable store, + and bounded now by D12's single-host self-host posture): a Server restart + wipes all issued tokens (operators re-bootstrap; no token outlives a restart + in T2), and the store is **single-process** — two Server processes behind a + load balancer would hold disjoint token/account state (a token issued on one + is unknown on the other), so T2 is single-serving-process by construction + until T5. Neither is a defect at MVP scope; both are named so the executor + does not assume horizontal scale or restart-durability the store cannot give. +- A tonic **interceptor** — `Interceptor::call(&mut self, Request<()>) -> + Result, Status>` (tonic-0.14.6 + `src/service/interceptor.rs:41-44`) — reads `authorization: Bearer ` + from request metadata, resolves it in the token store, and injects the + account into request extensions (`Request::extensions_mut`, tonic-0.14.6 + `src/request.rs:350`). Unknown/absent token ⇒ `Status::unauthenticated`. + tonic 0.14's `Interceptor` is **synchronous** (`&mut self, Request<()>) -> + Result, Status>`, which the in-memory hash-map lookup satisfies + directly. **This is a real T5 seam, not a free swap:** an async Postgres + resolve cannot run in the sync interceptor, so the "same accessors" swap (the + token-store bullet) does *not* extend to the auth-path lookup unchanged — T5 + either fronts the durable store with a sync in-memory token cache (the + interceptor stays sync, the cache is the accessor that swaps) or moves token + resolution to an async Tower layer. T2 builds the sync interceptor; T5 owns + that choice. Recorded so T5 does not discover it as a surprise re-architecture. +- **Bootstrap:** T1 already provides the no-actor bootstrap + (`Comms::bootstrap_admin`, "Bootstrap only — there is no actor to authorize + against when the system has no accounts yet", + `compass-comms/src/service.rs:66-69`). On start with `--listen`, the Server + creates the bootstrap admin (handle from `--admin-handle`, default `admin`), + issues its token, and writes it **0600 under the daemon state dir — never to + stdout or a centralized log**, since a logged bearer credential lets anyone + who can read process output or aggregated logs impersonate the admin. The + operator reads it from that file; an explicit one-time interactive display + (behind a flag, with a documented "this is a live credential" notice) is the + only sanctioned way to surface it on a terminal. +- **The UDS door stays token-free.** The 0600 socket is already the local trust + boundary (`serve.rs:95-101`); its callers are the machine owner. Comms RPCs + arriving over the UDS are attributed to the bootstrap admin account via a + static-identity interceptor. (Alternative — requiring tokens on the UDS too — + rejected: it breaks the shell's zero-config local connect for no security + gain; the socket mode is the credential.) This mirrors how per-Runner tokens + (not per-agent) are D10's model for the other outbound surface ("one durable + token per Runner, enrolled once", `../compass-0.5/design.md:407-413`); + per-user tokens are the Client-side analogue. + +**Both services are served on both doors.** D2 keeps one contract and one door +("one gRPC service: typed request/response commands plus a server-streaming +event channel", `../compass.md:282`; "extending `compass.v1` from a single-user +local contract to a multi-user networked one", +`../compass-0.5/design.md:230-232`) — so the network listener serves +`CompassService` (the connect-time probe `GetDaemonInfo` is "the first +round-trip a UI makes after connecting", +`packages/compass-client/src/gen/compass/v1/compass_pb.ts:668-669`) +**and** `CommsService`, both behind the auth interceptor. This exposes the +agent-session RPCs on `CompassService` (`start`/`stop`/`reload`, etc.) to any +authenticated account, but those RPCs were written for the single-user UDS +trust boundary and take no account argument, so as-is a bearer token could +drive **another** user's session. **T2 therefore gates the `CompassService` +agent-session RPCs on the network door to the bootstrap admin only** — the +interceptor rejects a non-admin account on those methods with +`Status::permission_denied`, while `GetDaemonInfo` (the connect-time probe, +`compass_pb.ts:668-669`) and all of `CommsService` stay open to any +authenticated account. This keeps the single-owner semantics the RPCs were +built with until **T3 supersedes them with the per-account Runner path**, at +which point the admin gate lifts. (The alternative — threading an account +through every session RPC now — is T3's job, not a T2 security patch.) + +**CORS on the network door defaults closed.** The permissive-CORS layer is a +dev-endpoint property (`serve.rs:239-254`); the TLS door adds no CORS by +default. A browser Client served from a different origin needs +`--cors-allowed-origin ` (explicit, single origin, exposing the +gRPC-Web status trailers exactly as `grpc_web_cors()` does). Serving the UI +bundle same-origin off the Server (which would remove CORS entirely) is a T4 +delivery decision this record leaves open — see Open Questions. + +### F3 — Transport-mode selection: construction-time, in the client package; T2 owns the doors and the factories, T4 owns the UX + +**Decision: keep transport choice exactly where the shell record put it — at +client construction — and scope T2 to (a) both server-side doors and (b) the +`@compass/client` factory additions that reach them.** + +The seam already exists client-side: "Transport is chosen at client +construction (`createCompassClient(transport)`, §7.2/§7.5), so topology is just +which transport the client receives. A hosted mode is a **sibling transport**" +(`../compass-tauri-shell.md:110-113`). The generated TS package exports exactly +that shape today — `createCompassClient(transport)`, +`createCompassWebClient(baseUrl)`, `createCompassClientOverFetch(fetch, baseUrl)` +(`packages/compass-client/src/index.ts:13-27,37-50`) — but has **no +`CommsService` client factory** (PR #533 adds only the generated `comms_pb.ts`; +`index.ts` is untouched at the contract tip). + +T2 therefore owns: + +1. `createCommsClient(transport)` + `createCommsWebClient(baseUrl, token?)` + + `createCommsClientOverFetch(fetch, baseUrl, token?)` mirroring the existing + factories, where `token` installs a connect `Interceptor` setting + `authorization: Bearer ` on every request (the transports accept + `interceptors`, `grpc-web-transport.d.ts:32`). The same optional `token` + parameter is added to the two `CompassService` remote factories. +2. The invariant, unchanged: "the shell and UI must never assume 'local' beyond + the transport boundary — no socket path or `localhost` leaks above the + `fetch`/command seam" (`../compass-tauri-shell.md:119-121`). Nothing in the + factories or the UI may branch on which transport it received. + +T2 explicitly **defers to T4**: where the browser Client gets its server URL + +token (settings UI, storage), any connection-picker UX, and the deferred Tauri +command bridge (parent D7 defers the desktop shell entirely). There is no +"mode enum" anywhere: local vs. remote is only *which factory the caller +invokes*, which is what keeps the invariant enforceable. + +### F4 — Wiring the generated `CommsService` trait to the T1 `Comms` service + +**Decision: a mechanical `CommsGrpc` adapter in `compass-daemon`; missing +domain capability is added to `compass-comms` behind the same actor/authz +discipline; `SubscribeComms` rides a genericized daemon event bus with +snapshot-as-events semantics.** + +T1 was built for exactly this mapping: "The domain types here are deliberately +decoupled from the generated `compass.v1` wire types … and mirror its field +names, so the T2 mapping onto the proto is near-mechanical" +(`crates/compass-comms/src/lib.rs:16-18`; the comment's "not frozen +yet" parenthetical is now historical — #533 froze the contract), and +"Every mutating and reading call takes an `actor: AccountId` — the account +performing the action — and authorizes it before touching state or the bus" +(`compass-comms/src/service.rs:6-7`). + +**Actor injection.** The adapter reads the authenticated account out of request +extensions (inserted by the F2 interceptor) and passes it as the `actor` every +`Comms` method requires. No request message carries identity (contract tip +`comms.proto:31-37`), so this is the *only* identity path — a request arriving +without the extension is a server wiring bug and maps to +`Status::unauthenticated`. + +**Capability mapping (at contract tip `8ec7dcf5`; binds at freeze).** The +generated trait (`crates/compass-proto/src/gen/compass/v1/compass.v1.tonic.rs:1120-1218` +at the tip) has 13 methods; T1 covers the first group directly, the second group +is added to `compass-comms` in T2: + +| RPC (trait method) | Backing | +| --- | --- | +| `create_user` | `Comms::create_user(actor, handle, display_name, UserRole::Member)` (`service.rs:84-96`) — the contract's `CreateUserRequest` carries no role (`comms.proto:353-356`); elevation is deferred (`comms.proto:39-41`) | +| `create_agent` | `Comms::create_agent(actor, owner_user_id: actor, handle, display_name, harness)` (`service.rs:101-129`) — owner is server-set to the caller (`comms.proto:44-46,131-133`); T1 authorizes owner-or-admin, satisfied by construction | +| `list_accounts` | `Comms::list_accounts(actor)` (`service.rs:133-151`) | +| `post_message` | `Comms::post_message(actor, container, blocks)` (`service.rs:252-284`), extended to the contract's `container` oneof (`comms.proto:440-447`) | +| `list_messages` | **new** `Comms::list_messages(actor, container, limit, before_message_id)` — newest-first paging over the substrate's retained window (`MessagingSubstrate::history`, `compass-comms/src/substrate/mod.rs:59-66`); the T5 store makes paging complete beyond retention. `limit` is clamped to a server-side `LIMIT_MAX` (a `compass-comms` const): a larger request is served the cap, never an unbounded scan, and `limit = 0` maps to the default page size. | +| `list_channels` / `create_channel_group` / `list_channel_groups` | **new** visibility-scoped reads + group CRUD on `Comms`, per the frozen group model (`comms.proto:140-167`) | +| `open_agent_console` / `share_agent_console` | **new** — the contract's console replaces T1's agent-DM shape (`open_dm`/`promote_to_group_dm`, `service.rs:159-245`); the console keeps the same substrate mechanics (one subject per container, `channel_subject`-style, `service.rs:32-36`) with `participant_user_ids` authorization (`comms.proto:193-209`) | +| `respond_to_ask` | **new** `Comms::respond_to_ask(actor, ask_id, chosen_option_ids)` — records the answer on the message's `Ask` block and emits `MessageUpdated`; delivering the answer into the asking agent's session is the Runner/ACP bridge (T3), out of T2 scope | +| `search_messages` | **new** `Comms::search_messages(actor, query, scope, limit)` — v1 is a substring scan over the caller-visible containers' retained history; the D12 Postgres store (T5) upgrades it to real FTS with no contract change. `limit` is clamped to the same server-side `LIMIT_MAX`, so an unbounded scan is not reachable from the wire. | +| `subscribe_comms` | the comms event bus, below | + +**Error mapping.** T1's error split was designed for this edge: "The variants +split cleanly into client errors (unauthorized/not-found/invalid) and internal +errors (substrate), which is exactly the split the T2 gRPC layer needs to map +onto `tonic::Status` codes" (`compass-comms/src/error.rs:31-34`). Mapping: +`Unauthorized → permission_denied`, `AccountNotFound`/`ChannelNotFound`/ +`AskNotFound → not_found`, `Invalid → invalid_argument`, +`Substrate → internal` (with the detail logged, not leaked). + +**`SubscribeComms`: one caller-scoped stream over a genericized bus.** The +contract stream is a *single* stream per caller carrying every visible comms +event with one `seq` cursor + `instance_epoch` +(`comms.proto:288-312,478-485`), while the T1 substrate subscribes +**per-subject** (`MessagingSubstrate::subscribe(&subject, since_seq, req_epoch)`, +`substrate/mod.rs:73-78`). Fanning per-channel subscriptions into one stream +cannot produce a single monotonic cursor, so the design is: + +1. **Genericize the daemon event bus** (`events.rs`) from its hard-coded + `SubscribeEventsResponse` payload to `EventBus

` stamping a + `Stamped

{ seq, at_unix_ms, instance_epoch, payload }` envelope — the + ring/broadcast mechanics, the under-lock replay/live handoff + (`events.rs:104-157`), the `epoch_nonce()` per-boot epoch + (`events.rs:174-188`), and the `BufferUnderflow` rules are unchanged. The + comms and daemon envelopes are field-identical by construction (the contract + "mirrors compass.proto's SubscribeEvents replay model exactly", + `comms.proto:288-291`), so both streams map `Stamped

` onto their response + message at the stream edge. +2. The Server holds one + `EventBus`; the `CommsGrpc` adapter + publishes a projection event after each successful mutation + (`MessagePosted`, `MessageUpdated`, `ChannelChanged`, `ChannelGroupChanged`, + `AccountChanged`, `AgentConsoleChanged` — the tip's payload set, + `comms.proto:303-311`). Single-process publishing is sound in T2 because the + Server is the only writer; when a second writer appears (the Runner path), + emission moves behind `Comms`/the substrate — noted as the seam, not built + now. +3. **`since_seq = 0` is a state snapshot, not a ring replay.** The contract + defines 0 as "snapshot current state as events, then tail" + (`comms.proto:479-480`). The load-bearing subtlety the mechanism must respect: + the daemon bus's own no-dup/no-drop handoff (`events.rs:104-157`) holds + because `subscribe` attaches the live receiver **and** fills the replay batch + **from the bus's own ring under one lock** — but the comms current state is + *not* on the ring. It is `Comms` state (accounts, groups, channels, + workspaces) behind a **separate subsystem lock** + (`compass-comms/src/service.rs:59`), and the genericized + `EventBus

::subscribe(since_seq, req_epoch)` (S1) holds no `Comms` + reference — so the snapshot cannot be "synthesized under the bus lock": two + subsystems, two locks. The correct mechanism is **attach-first across the two + locks**: the adapter attaches the live receiver and reads the bus head + (`next_seq - 1`) as the snapshot's consistency-point seq, *then* reads the + `Comms` snapshot under the comms lock and emits it as `*Changed` events into + the subscription's **`replay` batch** (the same `Vec>` a + positioned cursor fills, `events.rs:145-151`), drained before any live event. + Attach-first guarantees **no drop** — any mutation after attach lands on the + already-attached `live` receiver. The narrow window between attach and + snapshot-read can put one mutation in *both* the snapshot and the live tail, + but every snapshotted variant is a **state-replace** `*Changed` (an entity's + current value keyed by id), so the client applies it **idempotently** and the + duplicate is a harmless re-apply, not a dup bug. `MessagePosted` is the one + append-semantics event, and messages are **never snapshotted** — + `ListMessages` is the paging path — so the append case never enters the + snapshot and ring eviction can never make a fresh subscribe lossy. **The + synthesized events carry that consistency-point seq as their cursor.** The + resync sentinel is *not* "any event with `seq = 0`": the contract defines it + as the `resync_required` **payload variant**, which carries `seq = 0` and is + not a cursor (`comms.proto:305-308,315-323`). A snapshot `*Changed` is + therefore never mistaken for it — a client distinguishes them by payload + variant, and MUST NOT treat a bare `seq = 0` as resync. On a fresh bus that + has published nothing, `next_seq - 1 == 0`, so a first snapshot's events + (e.g. the bootstrap account) legitimately carry `seq = 0` — the correct + cursor meaning "nothing on the ring yet, tail from the first live event." +4. **A positioned cursor** (`since_seq > 0`) replays the bus ring then tails — + `events.rs` semantics verbatim; epoch mismatch or eviction underflow answers + with a terminal `CommsResyncRequired` (seq 0) exactly as the daemon stream + does today (`service.rs:106-114`, `resync_required()` `service.rs:231-240`). +5. **Visibility filtering at delivery:** every event forwarded to a subscriber + passes a `Comms::visible_to(actor, &payload) -> bool` check (membership / + ownership / group visibility / console participation — the same D9 rules the + RPC reads enforce). Filtering makes per-subscriber seq *gaps* normal; the + contract only requires monotonicity, which the shared bus seq provides. A + membership change racing an in-flight event may deliver one event the caller + could already/no-longer see — accepted for v1 (the snapshot path + reconverges), documented in the module. +6. The per-subscriber forwarding task reuses the daemon's `forward()` idiom — + bounded mpsc, select on the shutdown watch so held-open streams never pin + graceful drain (`service.rs:183-229`, capacity `service.rs:26-29`). + +The per-channel substrate keeps its T1 role — durable per-container history and +audit reads (`channel_history`, `service.rs:290-310`; JetStream mapping +`substrate/jetstream.rs:8-19`) — and its seq space never crosses the stream's: +stream cursors are bus seqs, paging cursors are message ids. + +### F5 — `compass.v1` evolution + the CI drift gate + +**Decision: T2 evolves no schema itself — it binds to the comms contract at +PR #533's merge (the freeze), and any T2-discovered contract delta ships as a +separate additive schema PR through the existing buf pipeline.** + +The evolution the parent's T2 line names ("evolve `compass.v1` … to carry +multi-user accounts, channels, and the ACP-as-DM event stream", +`../compass-0.5/design.md:667-668`) *is* the comms contract PR #533 — additive +(`comms.proto` is a new file/service in the same package; its own header says +"an additive surface (new file, new service) … without a breaking change", +contract tip `comms.proto:13-18`). The gates it must stay green through are +already built in `compass-proto`'s moon project: + +- **drift** — regenerate into a temp dir and `git diff --no-index` against both + checked-in clients (`crates/compass-proto/moon.yml:59-67`), the + concrete mechanism behind "the generated clients are checked in and + CI-verified against the schema (drift fails the build)" + (`../compass.md:284`). +- **breaking** — `buf breaking` against `origin/main`'s schema + (`moon.yml:27-31`). + +Consequences for T2 execution: + +1. **Sequencing:** implementation tasks that touch the comms surface (S2 and + later) start only after #533 merges — the merge is the data-model freeze. + **#533 has now merged (`5eb5a063`), freezing the contract**, and the freeze + did rename the provisional console type: + `open_agent_console`/`share_agent_console` became + `open_agent_workspace`/`share_agent_workspace`, plus a new + `unshare_agent_workspace` (OQ4). This record's mapping table (F4) names the + pre-freeze shapes; the re-bind to the workspace names is mechanical — the + capabilities, authz model, and stream semantics above are shape-stable. +2. **T2 implementation PRs regenerate nothing:** they consume the checked-in + generated code (`compass_proto::v1`, `compass-proto/src/lib.rs:12-15`), so + drift/breaking stay green by construction. +3. **If T2 needs a contract delta** (the one identified candidate is token + issuance — OQ1, scheduled as **S4a**): it ships as its **own additive + schema PR** — `proto` edit + `moon run compass-proto:gen` + committed + regenerated clients, through the same lint/breaking/drift lanes. #533 has + merged, so the delta lands against the frozen `comms.proto` on `main` — no + longer coordinated against an open contract PR. + +## Global Constraints + +- **Frozen-record convention.** This is a child record of + `../compass-0.5/design.md`, refining its T2 only; it supersedes nothing and + rewrites nothing. New decisions of record live here; the parent's D1-D14 + govern wherever this record is silent. +- **Contract freeze dependency.** The comms contract is now **frozen**: PR #533 + merged (`5eb5a063`), and the T1 `compass-comms` substrate/service landed with + it (#535, `b71c86c7`). Tasks S2-S7 bind to that frozen schema; the + field-level names in this record are the pre-freeze tip's and re-bind + mechanically to the frozen names (the console→workspace rename, OQ4). No T2 + task edits `comms.proto`; any delta ships as an additive schema PR per F5. +- **Rust toolchain + pins.** Workspace `tonic = "0.14"` / `tonic-web = "0.14"` + (root `Cargo.toml:22-26`); the TLS door uses tonic's `tls-ring` feature only — + **not** `tls-native-roots`/`tls-webpki-roots`: server-side identity plus an + operator-provided client CA need neither, and the workspace license fence + (`deny.toml`) admits data-licensed CA bundles such as `webpki-root-certs` only + as a seal-scoped exception (`deny.toml:50-55`), never for compass. The rustls + process-level provider is installed once in `main` before any TLS + construction, per the `seal-daemon` precedent + (`oss/seal/crates/seal-daemon/src/main.rs:28-41`). +- **`cargo deny check bans licenses sources` stays green** (the workspace gate, + `compass-proto/moon.yml:104-119`) — new deps are `rcgen` (dev-only) and the + tonic TLS feature closure; both must clear the fence. +- **moon registration.** moon projects are an explicit map + (`.moon/workspace.yml`); `compass-comms` is **still absent from it** — T1's PR + (#535, merged) shipped the crate without registering it (only `compass-proto` + and `compass-daemon` are mapped, `.moon/workspace.yml:50-51`), so its + `moon.yml` lanes never run in CI. T2's first implementation PR registers + `compass-comms` (and any lane it adds) before relying on its gates. +- **Authorization is transport-derived.** No RPC trusts a caller-identity + field; the actor is the authenticated connection's account, injected + server-side (contract tip `comms.proto:31-37`). Every new `Comms` method + takes `actor: AccountId` first and authorizes before touching state — the T1 + discipline (`compass-comms/src/service.rs:6-10`). +- **No plaintext network listener; no permissive CORS off loopback.** TLS flags + are required with `--listen`; the dev gRPC-Web endpoint keeps its loopback + guard (`serve.rs:56-63`). +- **The transport-boundary invariant.** "The shell and UI must never assume + 'local' beyond the transport boundary" (`../compass-tauri-shell.md:119-121`): + no socket path, `localhost`, or transport-mode branching above the + `@compass/client` factory seam. +- **Spec discipline.** This record's PR changes no living spec + (Spec-impact: none — design only). Each T2 *implementation* PR that lands + served behavior updates `docs/specs/product/compass.md` in the same PR + (`AGENTS.md:73-80`). +- **`rule://planning-evidence`.** Every claim about existing code/design in + this record carries file+line verified this session; contract claims made + against tip `8ec7dcf5` resolved at the freeze — #533 merged at `5eb5a063`, + #535 (T1) at `b71c86c7`. +- **No persona / agent-product names** in this record (`AGENTS.md` convention, + restated by the parent at `../compass-0.5/design.md:628-631`). +- **markdownlint-clean** under the repo config (`.markdownlint.json`, + `.markdownlint-cli2.jsonc`). + +## Plan + +Tasks are ordered by dependency; each carries its own test gate. S1, S3 are +contract-independent and can start immediately; S2 and later start at the +contract freeze (F5). "Server crate" means `compass-daemon` (F1). + +### S1 — Genericize the daemon event bus + +Parameterize `events.rs` over its payload so one bus implementation backs both +sequenced streams (F4.1). `SubscribeEvents` behavior is unchanged; its existing +unit tests (`events.rs:190-467`) and the transport integration tests +(`crates/compass-daemon/tests/transport.rs`) stay green. + +*Interfaces:* + +- Produces (in `compass-daemon/src/events.rs`): + + ```rust + pub struct Stamped

{ + pub seq: u64, + pub at_unix_ms: i64, + pub instance_epoch: u64, + pub payload: P, + } + + pub struct EventBus { /* ring + broadcast, as today */ } + + impl EventBus

{ + pub fn new() -> Self; + pub(crate) fn publish(&self, payload: P) -> u64; // returns assigned seq + pub fn subscribe(&self, since_seq: u64, req_epoch: u64) + -> Result, BufferUnderflow>; + pub(crate) fn instance_epoch(&self) -> u64; + } + + pub struct Subscription

{ + pub replay: Vec>, + pub live: tokio::sync::broadcast::Receiver>, + pub epoch: u64, + } + ``` + +- Consumes: current `EventBus`/`Subscription` + (`events.rs:31-37,52-59,67-164`). The daemon service maps + `Stamped` → `SubscribeEventsResponse` at + the stream edge (today the bus stores the response struct directly, + `events.rs:89-94`); `serve()`'s `events.publish(Payload::DaemonStatus(…))` + call (`serve.rs:107-110`) is mechanical to update. +- Gate: `cargo nextest run --locked -p compass-daemon` green with zero + behavioral diffs in `tests/transport.rs`. + +### S2 — Extend `compass-comms` to the frozen contract capability set + +Add the domain capability the contract has and T1 lacks (F4 table), re-shaping +the T1 channel model to the frozen container shapes (groups; the agent console +replacing the T1 agent-DM `open_dm`/`promote_to_group_dm` pair, +`compass-comms/src/service.rs:159-245`, whose seam tests carry over +re-targeted). All methods follow the T1 actor/authz discipline and the existing +error taxonomy (`error.rs:36-63`). + +*Interfaces:* + +- Consumes: `Comms`, `MessagingSubstrate`, domain types as exported by + `compass-comms/src/lib.rs:28-36`; the frozen contract shapes (at tip: + `ChannelGroup` `comms.proto:146-157`, `AgentConsole` `comms.proto:201-209`, + `Message.container` `comms.proto:217-230`, `Ask` `comms.proto:265-276`). +- Produces (signatures shown against tip shapes; rename with the freeze): + + ```rust + pub enum Container { Channel(ChannelId), Console(ConsoleId) } + + impl Comms { + pub fn create_channel_group(&self, actor: AccountId, name: &str, + parent_group_id: Option, visibility: GroupVisibility) + -> Result; + pub fn list_channel_groups(&self, actor: AccountId) -> Result>; + pub fn list_channels(&self, actor: AccountId) -> Result>; + pub fn open_agent_console(&self, actor: AccountId, agent: AccountId) + -> Result; // idempotent + pub fn share_agent_console(&self, actor: AccountId, console: ConsoleId, + add_user: AccountId) -> Result; + pub async fn list_messages(&self, actor: AccountId, container: Container, + limit: u32, before_message_id: Option) -> Result>; + pub async fn respond_to_ask(&self, actor: AccountId, ask_id: &str, + chosen_option_ids: Vec) -> Result; // emits MessageUpdated + pub async fn search_messages(&self, actor: AccountId, query: &str, + scope: Option, limit: u32) -> Result>; + // Filter operates on the same envelope payload the bus carries, so the + // adapter passes `&Stamped::payload` straight through — no second type. + pub fn visible_to(&self, actor: AccountId, + payload: &subscribe_comms_response::Payload) -> bool; + } + ``` + +- Gate: seam contract tests + (`compass-comms/tests/seam_contract.rs` extended) covering each new method's + authz rejection + success path against `InMemorySubstrate`; clippy + nextest + for `compass-comms` (its moon lanes registered per Global Constraints). + +### S3 — Token auth: store, interceptor, bootstrap, UDS ambient identity + +The F2 authentication layer, self-contained in the Server crate. + +*Interfaces:* + +- Produces (in `compass-daemon/src/auth.rs`): + + ```rust + pub(crate) struct TokenStore { /* parking_lot::RwLock> */ } + + impl TokenStore { + pub fn new() -> Self; + pub fn issue(&self, account: AccountId) -> String; // 32B random, base64url; stores sha256 + pub fn resolve(&self, presented: &str) -> Option; + pub fn revoke_account(&self, account: AccountId); + } + + #[derive(Clone, Copy, Debug)] + pub(crate) struct AuthedAccount(pub AccountId); + + /// Bearer-token interceptor for the network door. + pub(crate) fn bearer_auth(store: Arc) + -> impl tonic::service::Interceptor + Clone; + /// Static-identity interceptor for the UDS door (socket mode is the credential). + pub(crate) fn ambient_identity(account: AccountId) + -> impl tonic::service::Interceptor + Clone; + ``` + +- Consumes: `tonic::service::Interceptor` (tonic-0.14.6 + `src/service/interceptor.rs:41-44`), `Request::extensions_mut()` + (`src/request.rs:350`), `sha2`/`base64`/`getrandom` (root + `Cargo.toml:95-97,127`; `getrandom` already a daemon dep, + `compass-daemon/Cargo.toml:29`), `Comms::bootstrap_admin` + (`compass-comms/src/service.rs:69-80`). +- Gate: unit tests — issue/resolve round-trip, unknown token rejected, hash-only + storage (no plaintext token retrievable), interceptor injects + `AuthedAccount`, missing/malformed header ⇒ `unauthenticated`. + +### S4 — The `CommsGrpc` adapter + `SubscribeComms` + +Implement the generated trait against `Comms` (F4): mechanical request/response +mapping, actor extraction, error mapping, the comms event bus, and the +snapshot/replay/resync stream semantics. + +*Interfaces:* + +- Consumes: the generated trait + server + (`compass_proto::v1::comms_service_server::{CommsService, CommsServiceServer}`, + contract tip `compass.v1.tonic.rs:1109-1226`), `Comms` (T1 + S2), + `EventBus` (S1), `AuthedAccount` (S3), + the `forward()` idiom (`compass-daemon/src/service.rs:183-229`). +- Produces (in `compass-daemon/src/comms_grpc.rs`): + + ```rust + #[derive(Clone)] + pub(crate) struct CommsGrpc { + comms: compass_comms::Comms, + bus: Arc>, + shutdown: tokio::sync::watch::Receiver<()>, + } + + impl CommsGrpc { + pub(crate) fn new(comms: compass_comms::Comms, + bus: Arc>, + shutdown: watch::Receiver<()>) -> Self; + } + + #[tonic::async_trait] + impl CommsService for CommsGrpc { + type SubscribeCommsStream = + tokio_stream::wrappers::ReceiverStream>; + // all 13 methods per the F4 mapping table + } + + /// CommsError → tonic::Status per the F4 error mapping. + fn comms_status(err: compass_comms::CommsError) -> tonic::Status; + /// The authenticated actor, or `unauthenticated` if the extension is absent. + fn actor(req: &tonic::Request) -> Result; + ``` + +- Stream semantics as decided in F4: `since_seq = 0` ⇒ visibility-scoped state + snapshot delivered as the pre-live `replay` batch of `*Changed` events (each + stamped the consistency-point seq, the bus head — which is legitimately `0` on + a fresh bus); the resync sentinel is the `resync_required` **payload variant** + (not a bare `seq = 0`), so a snapshot event is never mistaken for it; then live + tail; positioned cursor ⇒ ring replay + tail; underflow/epoch mismatch ⇒ + terminal `CommsResyncRequired`; per-event `visible_to` filter; id strings + parsed as UUIDs (`invalid_argument` on failure). +- Gate: in-process tests over `InMemorySubstrate` — one per RPC group + (happy + authz-rejection mapped to the right `Status` code), plus stream + tests: snapshot-then-live ordering, positioned resubscribe after a dropped + stream is gap-free, stale cursor ⇒ resync, non-member never receives another + channel's event. + +### S5 — The network door in `serve()` + +Add the TLS listener as a third raced server task, mirroring the existing +UDS/dev-TCP pattern (`serve.rs:136-202`), and register both services on all +doors with their interceptors. + +*Interfaces:* + +- Produces (in `compass-daemon`): + + ```rust + pub struct NetworkListener { + pub addr: std::net::SocketAddr, + pub tls_cert: std::path::PathBuf, // PEM chain + pub tls_key: std::path::PathBuf, // PEM key + pub cors_allowed_origin: Option, // gRPC-Web browser origin; None = no CORS + } + + pub async fn serve( + socket_path: &Path, + version: &str, + dev_http: Option, + network: Option, + shutdown: impl Future, + ) -> Result<()>; + ``` + + CLI (`main.rs`): `--listen `, `--tls-cert `, `--tls-key ` + (all-or-none, validated up front like `--dev-http`'s loopback guard, + `main.rs:44-51`), `--cors-allowed-origin `, `--admin-handle `; + `rustls::crypto::ring::default_provider().install_default()` before serving + when `--listen` is set (precedent `seal-daemon/src/main.rs:39-41`). +- Consumes: `ServerTlsConfig::new().identity(Identity::from_pem(cert, key))` + + `Server::builder().tls_config(…)` (tonic-0.14.6 + `src/transport/server/tls.rs:23-35`, `src/transport/server/mod.rs:166`); + eager TCP bind before on-disk state (the dev-endpoint pattern, + `serve.rs:65-78`); `CommsServiceServer::with_interceptor(inner, interceptor) + -> InterceptedService` (generated per service; contract tip + `compass.v1.tonic.rs:1240-1248`); the S3 + interceptors — `bearer_auth` on the network door for both services, + `ambient_identity(bootstrap_admin)` on the UDS/dev doors for the comms + service. +- Callers updated: `main.rs:61`, every `compass_daemon::serve(...)` call in + `tests/transport.rs` (e.g. `tests/transport.rs:106,165`) passes + `None` for `network`. +- Gate: integration tests (rcgen-minted self-signed pair, dev-dependency — + precedent `seal-runtime/Cargo.toml:82-86`): TLS client with the test CA + connects and `GetDaemonInfo` answers; no-token comms RPC ⇒ `unauthenticated`; + bad-token ⇒ `unauthenticated`; valid token ⇒ authorized `actor` observed; + `--listen` without cert/key refuses startup; UDS behavior byte-identical to + today (existing transport tests unmodified except the added `None` arg). + +### S6 — `@compass/client` comms factories + auth interceptor + +The client-package half of F3. + +*Interfaces:* + +- Produces (in `packages/compass-client/src/index.ts`): + + ```ts + export type CommsClient = Client; + export function createCommsClient(transport: Transport): CommsClient; + export function createCommsWebClient(baseUrl: string, token?: string): CommsClient; + export function createCommsClientOverFetch( + fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise, + baseUrl?: string, + token?: string, + ): CommsClient; + /** Sets `authorization: Bearer ` on every request. */ + export function bearerAuthInterceptor(token: string): Interceptor; + ``` + + `createCompassWebClient`/`createCompassClientOverFetch` gain the same + optional `token` parameter (backwards-compatible). +- Consumes: the generated `CommsService` descriptor (`src/gen/compass/v1/comms_pb.ts`, + landed by the contract PR), `createGrpcWebTransport({ baseUrl, fetch?, interceptors? })` + (`node_modules/@connectrpc/connect-web/dist/esm/grpc-web-transport.d.ts:32`), + the existing factory pattern (`src/index.ts:13-50`). +- Gate: `bun test` unit tests — the interceptor sets the header exactly once; + factories construct against a mock fetch; no import outside `@compass/client` + touches generated stubs (the existing fence, `src/index.ts:17-19`). + +### S7 — End-to-end multi-client gate + +The parent's T2 test gate, executed: "multi-client connect + event resubscribe +(sequenced, `../compass.md:282`) tests green; `compass.v1` contract-drift check +green" (`../compass-0.5/design.md:671-673`). + +*Interfaces:* + +- Consumes: everything above; the daemon integration-test harness idioms + (`tests/transport.rs:37-118` — connector, readiness probe, spawn/shutdown). +- Produces: `compass-daemon/tests/comms_transport.rs` — over one TLS Server: + 1. bootstrap admin token → admin creates two users, issues their tokens + (per the Open-Questions resolution on issuance), each user connects as a + separate client; + 2. user A opens a group/channel per the frozen shape, posts; user B (member) + receives `MessagePosted` on `SubscribeComms` with strictly-increasing + `seq`; a non-member client never receives it; + 3. user B drops the stream, reconnects with its cursor + epoch ⇒ gap-free + tail (no duplicate, no loss); reconnect with a stale epoch ⇒ + `CommsResyncRequired` then a clean `since_seq = 0` snapshot. +- Gate: the new integration test + full `moon ci` for the affected projects + (includes `compass-proto:drift`/`breaking`, untouched and green per F5). + +## Tasks + +- [ ] **S1 — Genericize the daemon event bus.** `EventBus

`/`Stamped

` in + `compass-daemon/src/events.rs`; `SubscribeEvents` behavior unchanged; + existing unit + transport tests green. +- [ ] **S2 — Extend `compass-comms` to the frozen contract capability set.** + Groups, console (per frozen shape), `list_channels`, `list_messages` + paging, `respond_to_ask`, `search_messages` v1, `visible_to`; seam + contract tests extended; `compass-comms` registered in + `.moon/workspace.yml`. *(Starts at contract freeze.)* +- [ ] **S3 — Token auth + session-RPC admin gate.** `TokenStore` (hashed), + `bearer_auth` + `ambient_identity` interceptors, bootstrap-admin token at + first start; **plus the F2 method-level guard that rejects a non-admin + account on the network door's `CompassService` agent-session RPCs + (`start`/`stop`/`reload`/inspect) with `permission_denied`, while + `GetDaemonInfo` and all of `CommsService` stay open to any authenticated + account** — `bearer_auth` only authenticates the token and injects the + account, so this per-method admin check is a distinct interceptor/guard, + not implied by it. Unit tests incl. non-admin ⇒ `permission_denied`. +- [ ] **S4 — `CommsGrpc` adapter + `SubscribeComms`.** All 13 trait methods per + the F4 mapping; error mapping; comms bus with snapshot-as-events, + positioned replay, resync, visibility filter; in-process tests. +- [ ] **S4a — `IssueToken` schema PR** *(prerequisite for S7; nothing in + S1–S6 depends on it)*. OQ1's admin-gated `IssueToken(account_id) → token` + RPC lands as its **own additive schema PR** (proto edit + `moon run + compass-proto:gen` + committed regenerated clients, through the buf + lint/breaking/drift lanes per F5), coordinated with the #533 contract + owner while it is open. Ships after #533 freezes. S3's token store binds + this shape; **S7's two-user step is blocked until S4a lands** — it is the + only public-contract path to mint the second user's token. +- [ ] **S5 — Network door.** `NetworkListener` in `serve()` (third raced task), + TLS via tonic `tls-ring`, CLI flags, rustls provider install, CORS flag; + TLS/auth integration tests **including a non-admin bearer token calling a + `CompassService` session RPC over the network door and getting + `permission_denied` (the S3 admin gate, end to end)**; UDS behavior + unchanged. +- [ ] **S6 — TS client factories.** `createCommsClient` family + bearer + interceptor in `@compass/client`; `bun test` green. +- [ ] **S7 — End-to-end gate** *(requires S4a landed — its two-user step mints + the second token via `IssueToken`)*. Multi-client TLS connect, + member-scoped event delivery, sequenced resubscribe, resync; `moon ci` + (incl. proto drift + breaking) green. + +> Each implementation PR that lands served behavior also updates +> `docs/specs/product/compass.md` in the same PR (Global Constraints). This +> record's own PR: **Spec-impact: none** (design only). + +## Open Questions + +- **OQ1 — Token issuance for non-bootstrap users** *(resolved — decided; the + contract froze without a credential RPC, confirming this path)*. The + contract has no credential RPC, and the account/token stores are in-memory + until T5 — so a token minted out-of-band by a second process cannot reach the + serving process's store. How does a user created via `CreateUser` obtain their + bearer token? + **Decision:** a dedicated admin-gated `IssueToken(account_id) → token` RPC, + shipped as its **own additive schema PR** per F5. Chosen over a one-time + `issued_token` field on `CreateUserResponse` because the RPC is the cleaner + shape — re-issuance and revocation fall out of it naturally, and it decouples + credential delivery from account creation. The RPC is admin-gated (only an + admin mints a token for an account), resolving to the D9 account the + bearer-token interceptor then keys on (F2). S3 builds the token store against + this shape; S7's two-user gate mints its second user's token via `IssueToken`. + The comms contract (#533) merged (`5eb5a063`) **without** a credential RPC, so + the separate additive-PR path (S4a) is now the *only* path to this capability + — not one option among several. The proto delta lands through the same buf + lint/breaking/drift lanes as F5, coordinated with the contract owner. +- **OQ2 — Browser origin for the MVP Client** *(non-load-bearing — deferred to + T4 with the seam named)*. T2 ships `--cors-allowed-origin` (closed by + default), which is sufficient for any T4 hosting choice. If T4 prefers + zero-CORS, the Server can grow a same-origin static route for the UI bundle + on the TLS listener; nothing in T2 precludes it. Defaulting to the explicit + CORS flag is the smaller, reversible surface. +- **OQ3 — Client self-identity (`WhoAmI`)** *(non-load-bearing — deferred)*. + The contract has no "who am I" RPC; `ListAccounts` scoping does not uniquely + identify the caller for an admin. MVP: the client's configuration carries its + handle alongside its token (the operator issued both together). A tiny + additive `WhoAmI` RPC can ride any later schema PR if T4 wants it; the design + is correct without it. +- **OQ4 — Contract-freeze rename fallout** *(resolved — the freeze reshaped as + anticipated; mechanical re-bind)*. The console type name was provisional at + the contract tip; the freeze (#533, `5eb5a063`) **renamed** it — + `open_agent_console`/`share_agent_console` became + `open_agent_workspace`/`share_agent_workspace`, and a third + `unshare_agent_workspace` was added, so the frozen `CommsService` trait has + **14** methods, not the 13 named at the tip + (`crates/compass-proto/src/gen/compass/v1/compass.v1.tonic.rs:1150-1254`; + `comms.proto:38-99`). S2/S4's signatures re-bind 1:1 to the workspace names — + the capability set, authz rules, and stream semantics in this record are + shape-stable — and S2 gains the `unshare_agent_workspace` method on the same + console/workspace substrate mechanics. Expected re-bind, not drift. +- **OQ5 — Should the network door expose `CompassService` agent-session RPCs at + all in T2?** *(resolved — decided)*. F2 serves **both** services on the + network door and gates the `CompassService` session RPCs + (`start`/`stop`/`reload`/inspect) to the bootstrap admin, because those RPCs + "were written for the single-user UDS trust boundary and take no account + argument" (verified — `start_agent_session` etc. take only + `container_name`/`session_id`, `crates/compass-daemon/src/service.rs:120-136`). + The critic weighed an alternative: do **not** route `CompassService` session + RPCs on the network door at all in T2 — serve only `GetDaemonInfo` plus all of + `CommsService` there, keeping session lifecycle **UDS-only** until T3 gives it + the per-account Runner path (a smaller network surface, no admin-gate branch + over actor-less RPCs). + **Decision (keep F2 as drafted):** expose the session RPCs on the network door + behind the bootstrap-admin gate. The dogfood MVP that would exercise the + alternative's tradeoff (a browser comms Client with no need for remote session + control) is itself deferred until past this point, so admin-gated exposure + carries no near-term cost and keeps the remote single-owner session-control + path available rather than removing then re-adding it. The admin gate holds + the single-owner semantics until **T3 supersedes these RPCs with the + per-account Runner path**, at which point the gate lifts (F2). The gate is a + distinct method-level interceptor check, not implied by `bearer_auth` (S3). diff --git a/docs/designs/product/compass-0.5/design.md b/docs/designs/product/compass-0.5/design.md new file mode 100644 index 00000000..b6e53762 --- /dev/null +++ b/docs/designs/product/compass-0.5/design.md @@ -0,0 +1,761 @@ +# Compass v0.5 — Client → Server → Runner, with the communication layer as the spine + +Status: Historical + +> Internal design record — July 2026. A topology + product-shape pivot of the +> Compass design. The full ADE vision it builds on is the frozen v0.3 record +> ([`../compass.md`](../compass.md)); the strategic posture is the frozen v0.4 +> record ([`../compass-0.4/design.md`](../compass-0.4/design.md)); the UI shell +> and state model are [`../compass-ade-shell/design.md`](../compass-ade-shell/design.md) +> and [`../compass-dock-in-sidebar/design.md`](../compass-dock-in-sidebar/design.md); +> the desktop shell is [`../compass-tauri-shell.md`](../compass-tauri-shell.md). +> This record captures only what v0.5 changes and why, and **supersedes specific +> prior decisions by citation** — it does not rewrite any frozen record (records +> freeze on merge: `../../platform/docs-system.md:31-33`, `../compass-0.4/design.md:191-193`). +> Living built-behavior is the spec ([`../../../specs/product/compass.md`](../../../specs/product/compass.md)). +> +> **Status: decided.** Every load-bearing Open Question is folded in as a +> Decision (Q1 → D1, Q2 → D9, Q3 → D10, Q4 → D11, Q5 + Q10 → D12, Q8 → D13, +> Q11 → D14); what remains under Open Questions is non-load-bearing deferrals +> only (Q6, Q7, Q9, Q12). The record is ready to freeze on merge as the contract +> executing agents build against. + +## Problem / Intent + +The v0.3/v0.4 design is a **single-machine ADE**: one persistent local daemon +that "owns everything privileged: the per-agent container lifecycle (rootless +podman …), agent process management, PTYs, the Warden security layer …" +(`../compass.md:274`), driving each agent as an ACP client on its own host, with +a thin desktop shell rendering the UI (`../compass.md:270-276`). v0.4 kept that +shape and chose the commodity layers to leverage behind seams — Cotal for +coordination (`../compass-0.4/design.md:36-38,113-134`), OMP as the +default/reference agent (`../compass-0.4/design.md:73-92`), seal scoped to +hosting Warden (`../compass-0.4/design.md:94-111`). + +Two things force a topology pivot now: + +- **The product wedge is collaboration, not a single-user cockpit.** v0.3 + explicitly parked "**Real-time human collaboration in the ADE** … rather than + routing it through a separate tool like Slack (the '@agent do X' pattern) … + a planned direction. It is not a v0 feature … its design is deferred until + after this document lands" (`../compass.md:425`). v0.5 **promotes that exact + deferred direction to the product's core pillar**: humans *and* agents are + first-class accounts in a management hierarchy, conversing in channels, with + the agent conversation itself flowing through the communication layer. +- **A single host can neither scale nor survive.** The daemon runs the agent + containers itself on its own host (`../compass.md:274`), so a hung agent, + crashed container, or dead host loses context, and there is no path to +multi-user, multi-host, or hyperscaler placement. The coordination bus v0.4 + chose (Cotal) is scoped to "agent↔Dispatcher signals, cross-agent presence, + and replay-on-join" (`../compass-0.4/design.md:115-116`) — coordination, not + the first-class users and ownership hierarchy v0.5's product depends on. + +**Intent of v0.5:** split the daemon into a networked, multi-user **Server** and +a placement **Runner**, make a Discord-style **communication layer** the spine +of the product (agents as owned accounts, the ACP conversation as a DM), and +hold **all agent state in the Server** so runners and agent containers become +throwaway. The topology is not a from-scratch rebuild: the Client↔daemon seam +(`compass.v1`, `../compass.md:278-286`) and the hosted/remote-daemon transport +seam v0.3 reserved (`../compass-tauri-shell.md:107-121`) are exactly the seams +v0.5 realizes. + +The MVP is deliberately narrowed on delivery form — browser-first (the desktop +Tauri app deferred, D7) and Warden deferred (D8) — but it **keeps multi-agent +orchestration** (D4): the product must dogfood the wave structure it runs on, a +supervisor agent coordinating worker agents on the Bridge board. A single agent +flowing end to end through the tiers is the first build increment, not the MVP +ceiling. + +## Approach + +### The three tiers + +A **Client → Server → Runner** topology replaces the daemon/shell/UI split +(`../compass.md:270-276`). Each tier owns one responsibility: + +- **Client** — the web UI, in a browser (MVP) or a Tauri app (deferred, D7). + Reuses the SolidJS shell and its workstream/agent state model + (`../compass-ade-shell/design.md:1-9`, `../compass-dock-in-sidebar/design.md:1-9`), + repivoted so the communication layer is the primary surface (D5). It is a + `compass.v1` client (D2) and assumes nothing "local" beyond the transport + boundary — the invariant the hosted-mode seam already demands + (`../compass-tauri-shell.md:119-121`). +- **Server** — the long-lived, networked, multi-user orchestrator and stateful + store: the swimlane board (`../compass.md:91-98`), issues/CI status, the + communication layer (D1), and the agent-state/config store (D6). It runs the + first-party comms service behind the Compass-owned comms seam (D1), speaks the + evolved `compass.v1` contract to Clients (D2), and accepts Runner connections + (D3). This is the daemon of `../compass.md:274` promoted to a network service + — realizing the reserved hosted-mode transport (`../compass-tauri-shell.md:107-121`), + not a new architecture. +- **Runner** — a binary deployed on a machine you want to run agent containers + on, like a CI runner: it manages the per-agent containers (the rootless-podman + clone-per-container model of `../compass.md:141-149`), connects out to the + Server, and streams container activity back. This splits out the "runs each + agent in its own container … on its own host" responsibility the daemon held + (`../compass.md:274`). Future Runner implementations target hyperscaler infra + (e.g. per-agent ECS containers); the MVP Runner is a single local binary. + +### The communication layer (the spine) + +The communication layer is a Discord/Slack-style channel system where **humans +and agents are first-class accounts in a management hierarchy** — the distinction +from a pure coordination bus, and the reason v0.5 builds a first-party comms layer rather than adopting a coordination bus as-is (D1). Agents are +**owned by users**; a user's agents, and the agents of users they manage, are +theirs to converse with, share, and audit. + +- **The ACP conversation is a DM channel.** v0.3 renders each agent's ACP + session — "tool calls, diffs, plans, permission prompts, terminal output" + (`../compass.md:282`) — as native UI via `compass.v1`. v0.5 routes that same + ACP conversation **through the communication layer as a DM channel** between + the agent's account and its owning user. The channel is upgraded to expose the + interaction affordances the ACP surface needs: a first-class **`ask`** + (ACP's `session/request_permission`, `../compass.md:139`), **tool-call + rendering**, diffs, and plans — the ACP-as-native-UI rendering of + `../compass.md:294-296` moving onto the channel surface. +- **All comms flow through the layer → audit/search for free.** Agent logs, + agent↔agent messages, and human↔agent messages are all channel messages, so + the audit log and search are properties of the substrate, not a separate + pipeline. +- **DM → group-DM session sharing.** A user can share an agent session with + another user by turning the agent's DM into a group DM — the concrete + mechanism for "real-time human collaboration in the ADE" (`../compass.md:425`). + +The concrete substrate is a **first-party Rust comms service on NATS/JetStream**, +sitting **behind a thin comms seam Compass owns** — the same seam pattern v0.4 +used to keep Cotal swappable ("behind a thin interface Compass owns … so the bus +stays swappable", `../compass-0.4/design.md:118-122`). Cotal is the working +reference for the substrate, not a fork; the seam keeps it swappable (D1). + +### Throwaway runners, state in the Server + +All of an agent's state lives in the **Server DB** (D6), making runners and agent +containers **throwaway**: + +- **Full transcripts** are held server-side and sent into a container on + (re)start, so an agent can be stopped, restarted, or moved to a different (or + bigger) machine without losing context. A hung/crashed agent, container, or + Runner host no longer loses its context — the Server spins it back up. +- **Centralized agentic config** — skills, extensions, hooks, MCP config, auth + config, agent settings — lives in one server-side place so the Server can + update every agent's copy. The mechanism (D11) is a **read-only mounted volume** + the agent reads its config from, plus an **update-notification path** so an + agent learns a new skill or a new `AGENTS.md` rule landed. This matters + because the base agent is assumed to update frequently (dozens of times a + day): long-lived containers need a way to pull those updates plus the agent + binary and base image. + +This generalizes the per-agent container model of `../compass.md:141-149` +(rootless-podman, clone-per-container, scoped creds) by moving the *host* onto +the Runner and the *state* into the Server. + +### MVP scope: what's in, what's deferred + +- **Multi-agent orchestration is in the MVP** (D4). The Supervisor + (`../compass.md:69-81`) and the Bridge board (`../compass.md:91-98`) ship, + because the product must dogfood the wave structure it runs on. A single agent + flowing end to end through the tiers is the first build increment; the + supervisor + board layer onto it. +- **Browser-first; Tauri deferred** (D7), relative to the Tauri shell workstream + (SEA-1022, `../compass-tauri-shell.md:3`). The Runner binary and its + Server connection are still built (D3). +- **Warden deferred** (D8): the seal-hosted security auditor scoped in v0.4 + (`../compass-0.4/design.md:94-111`) comes later; v0.5 builds the + orchestration + productivity product first. + +## Decisions + +### D1 — A first-party Rust comms service on NATS/JetStream (Cotal as reference), behind a Compass-owned seam + +The communication layer is a **first-party comms service, built in Rust on NATS +JetStream**, behind a thin comms interface Compass owns. JetStream supplies the +durable-messaging substrate — streams as channels, subject hierarchies, durable +consumers for replay-on-join, KV for state — and Compass builds the first-class +**user + agent accounts and the ownership/management hierarchy** natively on top. +The seam keeps Compass's collaboration logic (accounts, ownership, ACP-as-DM, +session sharing, audit/search) above the substrate. + +**Build, don't fork — and don't adopt as-is.** v0.4 adopted Cotal (Apache-2.0, +NATS/JetStream) as the coordination substrate, scoped to "agent↔Dispatcher +signals, cross-agent presence, and replay-on-join" (`../compass-0.4/design.md:115-116`) +— a coordination bus, not the account/ownership system v0.5's product depends on. +Cotal validates the substrate (it runs agent fleets on exactly this JetStream +foundation today), so it is the **working reference**, not a dependency to fork: + +- **A fork diverges past rebase.** v0.5 must add first-class accounts, an + ownership hierarchy, ACP events as first-class message types, and the + ask/tool-call/diff/plan affordances — enough net-new that tracking upstream + quickly becomes impossible; a fork accrues a divergent copy's maintenance cost + with none of the upstream-rebase benefit. +- **Rust-native meshes with the product.** A first-party service in Rust sits in + the same stack as the rest of the Server, rather than carrying a separate + runtime and its rough edges as a leveraged black box. +- **Owning the spine lets us shape it.** The comms core is the product spine; a + first-party build lets Compass evolve the schema and correctness properties + directly instead of bending an upstream's. + +Rationale: once Compass builds its own client and account model regardless (D5, +Q2), the value of adopting or forking an existing server collapses — its client +is discarded and its account model replaced — while its constraints remain. +Building first-party on the proven JetStream substrate keeps the already-solved +hard parts (durable ordered streams, replay-on-join) and spends the build effort +on Compass-specific value. The seam preserves the option to swap the substrate. + +**Supersedes** v0.4's "Adopt Cotal … as the coordination substrate behind a thin +interface Compass owns" (`../compass-0.4/design.md:36-38,113-134`): the +**substrate choice (NATS/JetStream) is kept and vindicated**, but Cotal moves +from *adopted dependency* to *reference implementation* for a first-party Rust +service. **Implication:** the Cotal-adoption work tracked by SEA-1115 (In Review) +is reframed to the first-party build, and the Cotal high-assurance trust-tier +gate SEA-1113 (`../compass-0.4/design.md:124-132,158-159`) no longer gates a +Compass dependency — Compass owns the trust boundary directly. The comms seam +**keeps** v0.4's swappable-interface discipline; the substrate behind it becomes +first-party. + +### D2 — Evolve `compass.v1` from a local daemon contract into the Client↔Server contract + +The `compass.v1` gRPC contract — "one gRPC service: typed request/response +commands plus a server-streaming event channel … served over Connect so the same +contract reaches native clients … and the browser (gRPC-Web) alike" +(`../compass.md:278-286`) — **survives as the Client↔Server seam** and is +**evolved** to carry the networked, multi-user Server surface (accounts, channels, +the ACP-as-DM event stream, board/CI/issue updates). The "generated client is the +only sanctioned way to reach" the Server (`../compass.md:283`), and the daemon's +translation of ACP `session/update` into sequenced `compass.v1` events +(`../compass.md:282`) is retained and extended for the channel surface. + +Rationale: the seam already exists and is designed to be UI-swappable and +CI-enforced (`../compass.md:284-286`); v0.5 needs the same door, now +network-facing. Building a new contract would discard a compiler/CI-enforced +boundary for no gain. + +**Supersedes/refines** v0.3 §7.2 (`../compass.md:278-286`) by extending +`compass.v1` from a single-user local contract to a multi-user networked one; it +does not replace the contract. + +### D3 — The Server tier realizes the reserved hosted-mode transport seam + +The networked Server is the **realization of the hosted/remote-daemon seam v0.3 +already reserved**, not a from-scratch rebuild. The Tauri-shell record documents +that "A hosted deployment — the daemon on a different machine than the client … +stays possible without changing this design … A hosted mode is a **sibling +transport** … the daemon has no authenticated network listener today (UDS + +dev-loopback only), so hosted mode needs a TLS+auth server transport on the +daemon plus a client-side transport-mode selector, its own future workstream" +(`../compass-tauri-shell.md:107-121`). v0.5 **is** that future workstream: add the +Server's authenticated network listener (TLS + auth) and let Clients select the +remote transport. The Runner connects to the Server as a separate outbound +connection (its own transport, distinct from the Client↔Server `compass.v1` +door). + +Rationale: framing the Server as a reserved-seam realization is accurate and +keeps the invariant that "the shell and UI must never assume 'local' beyond the +transport boundary" (`../compass-tauri-shell.md:119-121`) load-bearing. + +**Supersedes/refines** v0.3 §7.1's single-host daemon (`../compass.md:270-276`) by +splitting container hosting onto the Runner; realizes the reserved seam +(`../compass-tauri-shell.md:107-121`). + +### D4 — Multi-agent orchestration is in the MVP (Supervisor + Bridge); single-agent is the first build increment + +The usable MVP **dogfoods a multi-agent wave structure inside Compass**: a +supervisor agent coordinating worker agents across the communication layer, +surfaced on the Bridge board. So the **Supervisor** (v0.3's Dispatcher, +`../compass.md:69-81`, renamed per D13) and the **Bridge** swimlane board (`../compass.md:91-98`) +are **in the MVP**, not deferred. + +Multi-agent orchestration is not bolted on — it falls out of the comms pivot. +v0.5 already makes agents first-class accounts in the communication layer (D1, +D5), so the Supervisor is a **supervisor agent account** coordinating **worker +agent accounts** in channels (the same pattern used to run agent fleets today), +and the Bridge board is a Server-side projection over those agents' workstream +state. + +**Build order, not scope cut:** a single agent flowing end to end through +Client → Server → Runner is the **first build increment** (incremental PRs) — the +foundation the supervisor + board layer onto — so the tiers, the comms seam, and +`compass.v1` are de-risked before orchestration rides on them. OMP is the base +agent (`../compass.md:113-129`, `../compass-0.4/design.md:73-92`); users bring +whatever OMP auth they want (subscription, API keys). + +Rationale: the product's core differentiator and its dogfooding target are both +multi-agent — a single-agent cockpit ships neither. Sequencing single-agent first +buys architectural de-risking without cutting orchestration from the MVP. + +**Refines** v0.4's OMP-as-default (`../compass-0.4/design.md:73-92`): OMP stays +the base agent, now driven under multi-agent orchestration. The Supervisor and +Bridge (`../compass.md:69-81,91-98`) are in the MVP, sequenced after the +single-agent foundation. + +### D5 — The UI pivots around the communication layer; the ACP conversation is a DM + +The Client's primary surface is the communication layer. The agent's ACP +conversation renders as a **DM channel** to its owning user, with a first-class +**`ask`** (`../compass.md:139`), **tool-call rendering**, diffs, and plans — the +ACP-native-UI rendering of `../compass.md:294-296` moving onto the channel. The +existing SolidJS shell, its Orca-mirror layout, and its workstream/agent state +model (`../compass-ade-shell/design.md:1-9,32-34`) are reused; the dock-in-sidebar +record already moved the agent conversation into a first-class, full-height +sidebar tab beside the board (`../compass-dock-in-sidebar/design.md:26-30`), which +this pivot builds on. `DM → group DM` is the session-sharing mechanism +(`../compass.md:425`). + +Rationale: routing the agent conversation through the same channel surface humans +use is what makes "@agent do X" collaboration and audit/search fall out of one +substrate rather than two. + +**Refines** the shell records (`../compass-ade-shell/design.md`, +`../compass-dock-in-sidebar/design.md`): the swimlane/state-model work is reusable; +the interaction center of gravity moves to the communication layer. + +### D6 — All agent state (transcripts + centralized config) lives in the Server; runners/containers are throwaway + +The Server DB holds **full agent transcripts** and a **centralized store of all +agentic config** (skills, extensions, hooks, MCP config, auth config, settings). +On (re)start, the Server sends the transcript into the container; config is +served to agents (a **read-only mounted volume**, materialized Runner-locally by pull — D11) with an +**update-notification path** so an agent learns when a new skill or `AGENTS.md` +rule lands. Consequence: **runners and agent containers are throwaway** — stopping +an agent is free, and restarting or relocating it (to a bigger machine, another +Runner) loses no context. + +Rationale: the base agent is assumed to update frequently; long-lived containers otherwise have +no clean way to pull updates and no recovery from a crash. Centralizing state +turns a hung agent/container/host from data loss into a cheap restart, and unlocks +Runner placement flexibility (D3). + +**Supersedes/refines** v0.3 §5.3 per-agent containers (`../compass.md:141-149`): +the container model (rootless-podman, clone-per-container, scoped creds) is kept, +but its *host* moves to the Runner and its *state* moves to the Server, making the +container disposable rather than the seat of the agent's context. + +### D7 — Browser-first; the Tauri app is deferred (Runner binary still built) + +The MVP is a self-hosted Compass Server reachable in the **browser** (a pure +gRPC-Web client, already supported today per `../compass-tauri-shell.md:114-115`). +The **Tauri app** — which would package the Server + Runner as daemon-like +processes for the one-machine ADE case — is **deferred post-MVP**. The **Runner +binary and its Server connection are still built** in the MVP (D3). + +Rationale: the browser client removes desktop-packaging work from the critical +path while the Server/Runner split is the load-bearing architecture; the Tauri +app is a distribution convenience that layers on later. + +**Defers** the Tauri thin-shell workstream (SEA-1022, `../compass-tauri-shell.md:3`) +to post-MVP. The transport seam it documents (`../compass-tauri-shell.md:107-121`) +is what v0.5 leans on, so deferring the app costs nothing architecturally. + +### D8 — Warden is deferred + +The seal-hosted Warden security auditor — v0.4 scoped seal to "exactly what +hosting Warden requires" (`../compass-0.4/design.md:94-111`) — is **deferred**. +v0.5 builds the orchestration + productivity product first; the per-agent +container remains the structural sandbox (`../compass.md:141-149`) in the interim. + +Rationale: Warden is the security moat and stays a named differentiator, but the +collaboration/orchestration product is the MVP wedge; sequencing Warden after it +keeps the MVP focused without abandoning the moat. + +**Defers** v0.4's seal→Warden scoping (`../compass-0.4/design.md:94-111`); nothing +in the MVP depends on Warden shipping first. + +### D9 — Users and agents are first-class accounts; agents are owned, permissioned subtypes; forge identity is configurable + +The comms identity model (D1) has **two account classes**: + +- **Users** — human accounts with a standard permission model: an **Admin** role + and a **regular user** role to start, expandable later. Users own agents and may + manage other users (the management hierarchy the communication-layer spine needs). +- **Agents** — a **separate account class**, each with an explicit **owning user**. + An agent is permissioned like a user (which channels it can post to, and which it + can see/subscribe to) but constrained by its owner: users have **first-class + controls over what access their agents have**. So an agent account is a + constrained, owned subtype — a real peer account in channels, gated by owner-set + permissions rather than a fully autonomous peer. + +**Spaces / nested channels.** The channel namespace nests, so a user's owned agents +work in that user's space by default (e.g. `#matt.announcements`, +`#matt.coordination`) while cross-agent and cross-team channels stay available for +wider collaboration. This scopes an owner's fleet without walling it off from +shared channels. + +**Forge identity is configurable, not fixed.** How an agent's comms account relates +to its **forge machine-user identity** (`../compass.md:149`) depends on the forge: + +- **Forgejo (first-class):** each agent gets **its own forge identity**, created by + Compass when the agent is spun up — a first-class Forgejo integration provisions + the machine user per agent. +- **Per-seat forges (e.g. GitHub):** a per-agent forge identity does not scale to + per-seat pricing, so the **user selects which forge account/credentials the agent + uses**. Forge identity is therefore a **configurable per-agent setting**, not a + hardcoded one-identity-per-agent mapping. + +Rationale: users owning agents with explicit, first-class access controls is the +trust model the communication-layer product depends on; making forge identity +configurable keeps the per-seat-pricing reality of hosted forges from forcing an +unaffordable identity-per-agent model, while first-class Forgejo provisioning gives +the self-hosted path the clean per-agent identity v0.3 assumed. + +**Resolves Q2.** Refines v0.3's per-agent forge machine-user (`../compass.md:149`): +kept as the Forgejo default, generalized to a configurable forge identity for +per-seat forges. + +### D10 — Runner↔Server transport is gRPC, authenticated by a per-Runner provisioned token; the container↔UI ACP transport also rides gRPC + +The Runner connects out to the Server (D3) over **gRPC** — the same protocol family +as the Client↔Server `compass.v1` seam (D2), a second gRPC surface rather than a +distinct protocol, absent a specific reason to diverge. + +**Auth is a per-Runner provisioned token.** The Server provisions **one token per +Runner**; the Runner authenticates with it on connect. This deliberately rejects +the **per-agent / multi-token** model some CI systems use (a token minted per +job/agent) — that pattern is a code smell here: it multiplies credential surface +and lifecycle for no isolation gain, since the Runner (not the Server) is the trust +boundary for the containers it hosts. One durable token per Runner, enrolled once, +is the model. + +**The container↔UI ACP transport also rides gRPC.** Streaming an agent's ACP +activity (tool calls, diffs, plans, terminal output) from its container out to the +Client is a transport that must exist regardless. Rather than run it as a separate +JSON/HTTP channel, it is carried **over gRPC**, folded onto the same contract +surface — because an adapter from ACP into the channel event stream (D5) has to be +built either way, and one gRPC transport is cleaner to maintain than a second HTTP +one. + +Rationale: a single transport technology (gRPC) across Client↔Server, +Runner↔Server, and container↔UI keeps one contract-generation + CI-drift discipline +(`../compass.md:284-286`) instead of a gRPC/HTTP split; a per-Runner token +minimizes credential lifecycle without weakening the container trust boundary. + +**Resolves Q3.** Realizes the Runner's outbound connection named in D3. + +### D11 — Config distribution is Runner-mediated pull into a Runner-local read-only mount; agent binary and base image ride versioned OCI pulls + +Centralized agentic config (D6) reaches agents by **Runner-mediated pull**, not a +cross-host network volume: + +- The **Server is the config store of record** (D12), holding each agent's config + **versioned** (content-addressed per agent). +- The **Runner pulls** a hosted agent's config bundle over the connection it + already holds (the gRPC Runner↔Server transport, D10) and materializes it as a + **Runner-local read-only bind mount** into the container. The agent still just + reads its config off a path — D6's read-only-mount model holds — with **no + cross-host network filesystem**. +- **Change propagation:** the Server signals "config version N for agent X" over + the Runner↔Server stream (D6's update-notification path); the Runner pulls the new + version and **atomically swaps the mount**, and the agent's existing notification + path tells the running process to re-read. +- **Agent binary + base image** — the heavy, frequently-updated artifacts (the base + agent updates on the order of dozens of times a day, D6) — ride **versioned OCI + image pulls** (the container is already an OCI image built from the project's + devenv, `../compass.md:141-149,157-163`), applied via the **throwaway-container + restart** property (D6): stop, restart on the new image, transcript replayed. A + frequent bump costs nothing beyond a restart. + +Rationale: a read-only *network* volume reachable from every Runner would force a +shared network filesystem onto arbitrary Runner hosts — breaking the "drop a binary +on any machine, like a CI runner" property (D3) — and does not fit config that is +**per-agent** (per-agent auth/MCP credentials). Pull-on-notification reuses the D10 +connection and the D6 notification path already being built and keeps Server-side +connection state minimal, versus a Server-push model that makes the Server track and +fan out to every live agent. + +**Resolves Q4.** Refines D6's "likely a read-only mounted volume": the mount is +kept, materialized **Runner-locally by pull** rather than as a network volume. + +### D12 — Postgres is the store of record; transcript bodies live in S3-compatible object storage behind a blob seam; JetStream is comms-only + +The Server's datastore is **PostgreSQL**, and it is the **system of record** for +all structured state: accounts, channels, config, board/issue state, and the +**transcript index/metadata**. **JetStream is treated as the communication layer +only** — a durable message bus, not long-term storage — so it is never the store of +record; the audit/search property (D1) is served from the canonical Postgres store, +keeping restart/replay idempotent against one authority. + +**Transcript bodies go to object storage, behind a Compass-owned blob seam.** +Transcripts are large and append-heavy, so their **bodies** live in **S3-compatible +object storage** (keyed, indexed from Postgres), not in Postgres rows. The design +depends on the **S3 API behind a thin blob-store seam** — the same swappable-seam +discipline D1 uses for comms — so the backend is a deployment choice: + +- **Hosted (seal):** Cloudflare **R2** (S3-compatible). +- **Self-hosted default:** **SeaweedFS** — Apache-2.0 (embeds/distributes with no + copyleft obligation), mature, production-proven, strong small-object I/O. +- **Any S3-compatible backend** (AWS S3, Ceph RGW, Garage, …) drops in behind the + same seam at the operator's choice. + +Old transcripts are **not dropped** — retained in object storage as useful data; +volume-reduction (compaction, tiering) is a later optimization needing no schema +change. + +Rationale: Postgres-as-record with object-storage bodies keeps the DB lean and makes +indefinite transcript retention cheap. Depending on the **S3 API**, not a vendor, is +what keeps Compass **self-hostable** — a hard product constraint; SeaweedFS is the +bundled self-hosted default specifically because it is Apache-2.0 (bundling an +AGPL-licensed store, or the now-archived MinIO community edition, would attach +copyleft/licensing strings to a **distributed** product). + +**Resolves Q5 and Q10.** Q10's store-of-record question resolves to Postgres; Q5's +datastore resolves to Postgres + the S3-compatible blob seam. Couples to D1 +(JetStream stays comms-only) and D6 (the state this store holds). + +### D13 — The Supervisor (renamed from v0.3's Dispatcher) ships in the MVP with agent task-assignment; conflict map and backlog automation layer on later + +**Naming:** v0.3's **Dispatcher** (`../compass.md:69-81`) is **renamed the +Supervisor** — the built-in supervisor agent that coordinates worker agents. This +record uses **Supervisor** in v0.5 prose; citations of the frozen v0.3/v0.4 records +keep their original "Dispatcher" wording (those records are not rewritten). + +**MVP depth (the D4 scoping call):** the MVP Supervisor ships the ability to +**assign tasks/issues to worker agents** — the cheap, proven primitive (a supervisor +agent sending an assignment message to a worker over the communication layer, +exactly how agent fleets are supervised today). The **Bridge** board +(`../compass.md:91-98`) ships alongside as the Server-side projection of agents' +workstream state. + +**Deferred to post-MVP** (layered onto this foundation, not in the first cut): + +- the **conflict map** (advisory file-zone scheduling, `../compass.md:74`), +- **automatic assignment** and **backlog auto-pickup** (`../compass.md:73,75`). + +Rationale: task-assignment over channels is nearly free (it is a message to a worker +agent, the pattern already in daily use) so it belongs in the MVP; the conflict map +and auto-assignment are genuine additional systems whose value comes after the +multi-agent loop is running, so they sequence after without blocking the MVP. + +**Resolves Q8.** Refines D4: D4 puts the Supervisor + Bridge in the MVP; D13 fixes +the MVP depth (assignment yes; conflict-map/auto-assign later) and records the +rename. + +### D14 — The centralized config store enforces a defined secret-handling boundary, upgradable over time + +The centralized config/secret store (D6) serving disposable containers (T5) carries +a defined secret-handling boundary: + +- **Encryption at rest** for stored secrets. +- **Per-user / per-agent authorization** on secret access (an agent reaches only its + own scoped credentials, under D9's owner-gated access). +- **Runner/container isolation** of delivered secrets — a secret materialized into + one agent's container (via the D11 read-only mount) is not visible to another, + extending v0.3's scoped-`$HOME` container isolation (`../compass.md:143,149`). +- **Rotation and revocation** paths for stored credentials. +- **Redaction of credentials** from transcripts and the audit log, so the + everything-through-the-comms-layer property (D1) never leaks secrets into + searchable history. + +This boundary is the **target contract for T5**; the concrete hardening (cipher +choice, key custody, rotation cadence) is an implementation detail that **does not +change the topology and can be strengthened over time** without a design change. + +Rationale: centralizing config (D6) concentrates secret risk, so the boundary must +be named for the store task to build against; fixing the boundary while leaving the +hardening specifics upgradable avoids over-specifying crypto in a design record +while still giving T5 a clear security contract. + +**Resolves Q11.** Downstream of D11 (config distribution) and D6 (centralized +store); extends v0.3 container isolation (`../compass.md:143,149`). + +## Open Questions + +> The load-bearing questions are resolved and folded in as Decisions (see the +> Status banner's map); each is kept below as a one-line pointer to its Decision. +> What stays live is **non-load-bearing** only — deferrals the merge ratifies (the +> design is correct without them; each is an optional refinement settled at its +> task). + +- **Q1 — [Resolved → D1] Comms substrate.** First-party Rust comms service on + NATS/JetStream, Cotal as reference. See D1. +- **Q2 — [Resolved → D9] Account / identity + management hierarchy.** See D9. +- **Q3 — [Resolved → D10] Runner↔Server transport + auth.** See D10. +- **Q4 — [Resolved → D11] Config-distribution mechanism.** See D11. +- **Q5 — [Resolved → D12] Server datastore + transcript storage.** See D12. +- **Q8 — [Resolved → D13] Multi-agent orchestration depth.** See D13. +- **Q10 — [Resolved → D12] Store of record for transcripts + messages.** See D12. +- **Q11 — [Resolved → D14] Secret-handling boundary.** See D14. + +- **Q6 — How much of the existing SolidJS shell state model survives the comms + pivot?** *(non-load-bearing)* + D5 asserts the swimlane/state-model work (`../compass-ade-shell/design.md`, + `../compass-dock-in-sidebar/design.md`) is reusable. The exact re-mapping of the + agent-conversation sidebar tab onto a channel DM — rendered Slack-style as a DM + entry in the left sidebar that opens the ACP surface (split panes + terminal + tabs) on click — is a UI-task detail settled during that task; the design is + correct either way. + +- **Q7 — Transcript hand-off format into a (re)started container.** *(non-load-bearing)* + D6 requires the Server to send the transcript into a container on restart. The + serialization/replay format is an implementation detail of the state-store task + (v1: write it into the base agent's session directory; a later extension may feed + the transcript to the agent over ACP directly), not a cross-cutting contract; + deferrable to that task. + +- **Q9 — Managed-tier / hyperscaler Runner shape.** *(non-load-bearing)* + Future Runners target hyperscaler infra (per-agent ECS-style containers). The + managed-Runner design is post-MVP and deferred; the MVP Runner is a single local + binary. + +- **Q12 — Port-forwarding an agent-container dev server to the user.** *(non-load-bearing)* + A user viewing a dev server running inside an agent's container on their own + machine needs a port-forward path from the container out through the Runner/Server + to the Client. The concrete mechanism (a tunnel over the existing Runner↔Server + connection vs. a directly forwarded port) is a UI/transport detail resolvable at + the Runner/UI tasks; it does not change the topology, so it is deferred. + +## Plan + +This PR lands the v0.5 design record alone; it makes no living-spec edit — the +forward-looking overview edit to `docs/specs/product/compass.md` is deferred to +task **T7** (see **Spec impact**). Because `docs/designs//` records +are frozen once decided (`../../platform/docs-system.md:31-33`, +`../compass-0.4/design.md:191-193`), the pivot is captured *here* as a new record; +prior records are superseded by citation, never rewritten. The load-bearing Open +Questions are resolved and folded in as Decisions (Q2 → D9, Q3 → D10, Q4 → D11, +Q5/Q10 → D12, Q8 → D13, Q11 → D14); only non-load-bearing deferrals (Q6, Q7, Q9, +Q12) remain, and the merge ratifies them. + +Tasks are ordered by dependency: comms substrate + seam → Server → Runner → UI → +state/config store → multi-agent orchestration. Each carries its own test/gate cycle. + +## Global Constraints + +- **Frozen-record convention.** New record at + `docs/designs/product/compass-0.5/design.md`. Never rewrite v0.3 + (`../compass.md`), v0.4 (`../compass-0.4/design.md`), or the shell/Tauri records; + supersede by citation (`../../platform/docs-system.md:31-33`, + `../compass-0.4/design.md:191-193`). +- **Tracker = Linear, team SEA** (not GitHub Issues). Reference SEA-NNN; + SEA-1115 (Cotal adoption, In Review) is reframed to the first-party comms + build and SEA-1113 (Cotal trust-tier gate) no longer gates a Compass + dependency (D1); SEA-1022 (Tauri shell) is deferred by D7. +- **No persona / agent-product names** in this record (it lives in the repo; per + `AGENTS.md`). OMP, ACP, Tauri, the forge, NATS/JetStream, and Cotal are + interop facts and fine to name; Cotal is named as the reference implementation + the first-party comms service is built against (D1). +- **The comms seam is Compass-owned and thin** (D1): the first-party comms + service sits behind it, mirroring v0.4's Cotal seam + (`../compass-0.4/design.md:113-122`); the substrate stays swappable. +- **`compass.v1` evolves, not replaced** (D2, `../compass.md:278-286`): the + Client↔Server seam is the existing contract extended to networked multi-user; a + generated client stays the only sanctioned door. +- **`rule://planning-evidence`.** Every claim about existing code/design carries a + file+line + quoted snippet verified in this repo. +- **The living spec states only built behavior** as `### Requirement:` + + `#### Scenario:` contracts (`../compass-0.4/design.md:194-197`); the pivot is + unbuilt, so it appears in the spec only as a design-record pointer + a + forward-looking overview edit — no fabricated contracts. +- **markdownlint-clean** (`.markdownlint.json` / `.markdownlint-cli2.jsonc` at + repo root); the design skill requires it. + +## Tasks + +- [ ] **T1 — Comms substrate + Compass-owned comms seam.** Define the thin + Compass-owned comms interface (accounts, channels, DM/group-DM, messages, + ask/tool-call rendering hooks, audit/search) and build the first-party + Rust comms service on NATS/JetStream behind it, so the substrate stays + swappable (D1); consumes the **D9** account model (users + owned, + permissioned agent subtypes). + *Interfaces:* consumes the D9 account model + the JetStream substrate; + produces the comms seam interface definition and the first-party service + behind it. Test/gate: seam contract tests green against the comms service: + CRUD flows (account create, DM open, message post, group-DM promote, audit + query), plus — per the D9 account model — authorization + isolation + (owner/manager permissions, rejection of unauthorized account/channel + access, group-DM share authorization, audit-visibility boundaries). + +- [ ] **T2 — Server tier: networked multi-user, evolve `compass.v1`.** Promote the + daemon (`../compass.md:270-276`) into a network Server: add the + authenticated TLS network listener + client transport-mode selector the + reserved seam names (`../compass-tauri-shell.md:107-121`, D3), and evolve + `compass.v1` (`../compass.md:278-286`, D2) to carry multi-user accounts, + channels, and the ACP-as-DM event stream. + *Interfaces:* consumes the T1 comms seam + the existing `compass.v1` crate; + produces the evolved `compass.v1` schema (regenerated, CI-verified per + `../compass.md:284`) + the Server's authenticated listener. Test/gate: + `compass.v1` contract-drift check green; multi-client connect + event + resubscribe (sequenced, `../compass.md:282`) tests green. + +- [ ] **T3 — Runner binary + Server connection.** Build the Runner binary that + manages per-agent containers (the clone-per-container rootless-podman model, + `../compass.md:141-149`), connects out to the Server, and streams container + activity back. Uses the **D10** Runner↔Server transport (gRPC, per-Runner + provisioned token; ACP streamed over gRPC). + *Interfaces:* consumes the D10 transport/auth + the Server listener + (T2); produces the Runner binary + its enrollment/stream path. Test/gate: + Runner enrolls with the Server, starts a container, and streams activity end + to end in an integration test. + +- [ ] **T4 — Communication-layer UI (ACP-as-DM).** Repivot the SolidJS Client + (`../compass-ade-shell/design.md`, `../compass-dock-in-sidebar/design.md`) so + the communication layer is the primary surface: render the agent's ACP + conversation as a DM channel with first-class `ask` (`../compass.md:139`), + tool-call rendering, diffs, and plans (`../compass.md:294-296`), and the + DM→group-DM sharing action (`../compass.md:425`), reusing the + swimlane/state-model work (D5). + *Interfaces:* consumes the evolved `compass.v1` channel/event surface (T2) + + the existing shell; produces the comms-centric Client with ACP-as-DM + rendering. Test/gate: DM renders a live ACP session (ask + tool-call + + diff), group-DM promotion visible to a second user (component/E2E tests). + +- [ ] **T5 — Agent state/config store + throwaway-container lifecycle.** Implement + the Server-side store for full transcripts + centralized agentic config + (skills/extensions/hooks/MCP/auth/settings), the config-distribution + mechanism (Runner-mediated pull into a read-only mount, D11), and the + throwaway-container lifecycle (stop free; restart/relocate replays transcript + into a fresh container). Consumes **D11** (config distribution), **D12** + (Postgres store of record + S3-compatible transcript blob seam), and **D14** + (secret-handling boundary). + *Interfaces:* consumes D11/D12/D14 + the Runner container lifecycle + (T3); produces the Server state/config store + the transcript replay-into- + container path. Test/gate: stop→restart an agent on a different Runner with no + context loss; a config update notifies a running agent (integration test). + +- [ ] **T6 — Multi-agent orchestration: Supervisor + Bridge board.** + Layer multi-agent orchestration onto the single-agent foundation (D4): a + **supervisor agent account** (the Supervisor — v0.3's Dispatcher, + `../compass.md:69-81` — per D13) coordinating **worker agent accounts** over + channels, and the **Bridge** swimlane board (`../compass.md:91-98`) as a + Server-side projection of the agents' workstream state. Depends on the + single-agent path (T1–T5) working end to end; scoped by **D13** (MVP ships + task-assignment; conflict-map + auto-assign deferred). + *Interfaces:* consumes the comms seam (T1), the multi-user Server + channel + event surface (T2), the Runner's multi-container capability (T3), and the + agent-state store (T5); produces the supervisor-agent coordination path + the + Bridge board surface. Test/gate: a supervisor agent assigns work to two + worker agents over channels and the board reflects their live workstream + state (integration/E2E test). + +- [ ] **T7 — Reconcile the living spec.** Update `docs/specs/product/compass.md`: + point its design-record cross-reference at this v0.5 record and adjust the + forward-looking overview so the not-yet-built runtime description reflects the + Client→Server→Runner topology + the communication-layer spine — without + inventing Requirement/Scenario contracts for unbuilt behavior + (`../compass-0.4/design.md:194-197`). See **Spec impact**. + *Interfaces:* consumes this record; produces the spec overview/cross-ref edit. + Test/gate: `spec-impact` CI check satisfied (`../../platform/docs-system.md:123-128`); + markdownlint clean. + +- [ ] **T8 — Design record markdownlint-clean.** This file lints clean under the + repo config (`.markdownlint.json` / `.markdownlint-cli2.jsonc`). *(This file.)* + +> **Explicitly deferred** (not tasks in this MVP): **Warden** +> (`../compass-0.4/design.md:94-111`), deferred by D8; and the desktop Tauri app +> (SEA-1022, `../compass-tauri-shell.md:3`), deferred by D7 — the Runner binary +> still ships. A memory backend for all agents on the Server is noted post-MVP and +> not designed here. + +## Spec impact + +**Spec-impact for this PR: none.** This PR adds only the design record; it changes +no living spec. The v0.5 pivot is entirely unbuilt, so it creates no new +`### Requirement:` / `#### Scenario:` contracts — the living spec states only +**built** behavior (`../compass-0.4/design.md:194-197`), so there is nothing to +reconcile until the first increment ships. + +When implementation begins, task T7 makes the forward-looking spec edit: repoint +`docs/specs/product/compass.md`'s design-record cross-reference to include this +v0.5 record (today it points at v0.3 and v0.4, +`../../../specs/product/compass.md:6-8`) and adjust its overview +(`../../../specs/product/compass.md:15-31`) so the "designed but not yet built" +description reflects the Client→Server→Runner topology and the communication-layer +spine rather than the single daemon — no fabricated contracts for unbuilt behavior +(the docs-system gate, `../../platform/docs-system.md:114-131`). diff --git a/docs/designs/product/compass-0.6/design.md b/docs/designs/product/compass-0.6/design.md new file mode 100644 index 00000000..815e21cd --- /dev/null +++ b/docs/designs/product/compass-0.6/design.md @@ -0,0 +1,1848 @@ +# Compass v0.6 — the end-to-end product architecture + +Status: Historical + +> Internal design record — July 2026. The complete current Compass architecture +> in one place: Client → Server → Runner plus a first-party in-container agent, +> with the communication layer as the spine, Postgres as the store of record, +> and gRPC as the transport. It supersedes specific decisions of frozen records +> by citation — it does not rewrite any frozen record (records freeze on merge; +> `../compass-0.5/design.md:10-12`). + +## Problem / Intent + +The current Compass architecture is spread across frozen records that each +capture one delta: the v0.3 ADE vision (`../compass.md`), the v0.4 posture +(`../compass-0.4/design.md`), the v0.5 topology pivot +(`../compass-0.5/design.md`, which "captures only what v0.5 changes and why", +`../compass-0.5/design.md:10`), the v0.5 Server-tier refinement +(`../compass-0.5-server/design.md`), the UI-shell records, and the ACP session +record (`../../agents/sea-1023-acp-session.md`). An executing agent must stitch +six citations to see the whole system — and on two axes the stitched picture no +longer matches the tree: the live implementation is the Go module +`go/**` (there is no Rust under ``), and the live +event-delivery layer is the in-memory server event bus +(`go/events/events.go:1-2`: "Package events is the server event +bus: a monotonic-seq ring buffer plus a per-subscriber live tail"), not a +brokered stream substrate. + +**Intent of v0.6:** state the complete, current, end-to-end Compass product +architecture — Client → Server → Runner, the communication layer as the spine, +the `compass.v1` contract, a first-party agent runtime on the OMP SDK, +per-agent container isolation on the Runner, Postgres as the store of record +with an S3-compatible blob seam and the in-memory bus for live fan-out, config +distribution, the authenticated network door, and gRPC as the single transport +— in one record that an executing agent can build against without archaeology. +It **supersedes specific frozen decisions by citation** (the comms-substrate +seam, the internal broker role, the ACP/BYOA agent model, and — by amendment — +the agent's primary interaction surface, inverting it to channel-primary with an +observation-only trace; see *Superseded decisions* below) and **builds on** the +frozen vision/UI/platform records by citation without restating them: v0.3 +(`../compass.md`), v0.4 +(`../compass-0.4/design.md`), the UI shell (`../compass-ade-shell/design.md`, +`../compass-dock-in-sidebar/design.md`), the desktop shell +(`../compass-tauri-shell.md`), and the Rust→Go platform port +(`../../platform/go-toolchain-default.md` — SEA-1243, whose T8/T9 build the +Server↔Runner seam this record conforms to). + +One structural gap drives the plan's sequencing: the Server today is fully +ephemeral. The Go module has no database dependency at all +(`grep -rlE 'pgx|database/sql|jackc|lib/pq' go/` returns no +matches, verified against `main` this session), and the only stateful component +the serve loop constructs is the bus — "The one event bus every sequenced +stream rides" (`go/server/serve.go:138-140`, +`bus := events.NewBus[busPayload]()`), which `NewBus` mints **empty** at every +boot ("NewBus constructs an empty bus with a fresh per-boot instance epoch", +`go/events/events.go:151-152`). A restart therefore loses every +message, channel, and account. The Postgres store of record is the fix, so it +lands as the plan's earliest substantive task, not as a late swap. + +## Approach + +### Superseded decisions (by citation) + +This record supersedes exactly four decisions of the frozen records — two +axes of v0.5, the ACP/BYOA agent model of the SEA-1023 session record, and +(this amendment) the agent's *primary interaction surface* of v0.5 D5. +Every other v0.5 decision (D2–D4, D6–D11, D13, D14) carries forward unchanged +and is cited in place below. + +**Carried, not superseded: the implementation language.** v0.5 D1's Rust +choice ("a first-party **Rust comms service on NATS/JetStream**", +`../compass-0.5/design.md:122`) was already superseded by the frozen platform +record, not here: Rust→Go is SEA-1243's ruling — "This record designs the +*how*; the *whether* is settled" +(`../../platform/go-toolchain-default.md:16-17`). v0.6 builds on it by citation: +the backend implementation is the Go module `go/**` (module +`github.com/sealedsecurity/compass/go`, +`go/cmd/compass-server/main.go:19`), gated by the Go CI battery +(`go/moon.yml:141-147`, `deps: ['fmt', 'vet', 'lint', 'nilaway', +'test', 'build', 'vuln', 'licenses']`). + +1. **The swappable comms-substrate seam.** v0.5 D1 keeps the substrate behind + a seam so it "stays swappable" (`../compass-0.5/design.md:123-126`: "sitting + **behind a thin comms seam Compass owns** … The seam preserves the option to + swap the substrate", `:200`). Superseded: **Postgres is the substrate**, and + it is not swappable — the store of record is a relational database with + real queries, indexes, and transactions, and every tier reads and writes it + through the Server. The only seam this record keeps is a **narrow + event-fan-out seam** at the Runner↔Server hop (see *State + storage* and + *Resolved decisions*), the sole place a broker could later earn a role. +2. **The internal broker role.** v0.5 D1/D12 give JetStream the + "communication layer only" role — "JetStream is treated as the + communication layer only — a durable message bus, not long-term storage" + (`../compass-0.5/design.md:468-470`) — an **external broker process** the + Server publishes to and subscribes from (D1 runs the comms service **on** + NATS/JetStream, `../compass-0.5/design.md:122`). Superseded: there is **no + broker at all**. With the Server as both sole publisher and sole + subscriber, the broker is a self-loop — a redundant, non-authoritative + copy of data Postgres already holds (D12 already reserved the store of + record for Postgres) — plus an external operational dependency every + self-hoster would have to run; the live fan-out role it would fill is + already built as the in-memory event bus + (`go/events/events.go:1-13`), which stays. +3. **The ACP/BYOA agent model.** SEA-1023 fixes the agent runtime as ACP: + adopt the `agent-client-protocol` SDK (Fork 1, + `../../agents/sea-1023-acp-session.md:39-44`), ACP over stdio across the + container boundary (Fork 2, `:46-52`), an ACP→`compass.v1` translation + seam (Fork 4, `:64-68`), and "BYOA over ACP" with OMP as the external + default/reference agent (`:19`). Superseded (Matt's decision, July 2026): + the in-container agent is a **first-party program built on the OMP SDK, + emitting `compass.v1` natively** — no ACP, no translator, and no BYOA + machinery in the MVP (see *Agent runtime*). The frozen platform record's + ACP-facing rulings (OQ1 "adopt the upstream Go SDK", + `../../platform/go-toolchain-default.md:1219-1226`; T7 "ACP client in Go", + `:861-868`; the boundary-table row "OMP (the base agent) | Rust | outside + the boundary — the Runner drives it over ACP", `:88`) are mooted by this + product decision; that record is SEA-1243's to amend, so the ripple is + recorded as a **coordination item for SEA-1243**, never an edit here. +4. **The agent's primary interaction surface (v0.5 D5).** The frozen comms + contract fixes the agent's surface as a first-class `AgentWorkspace` that is + *itself the conversation* — "the agent's tool calls and asks render here" + (`proto/compass/v1/comms.proto:207-213`), an agent turn being "a + workspace message whose blocks stream in and update" (`comms.proto:225-228`), + with `Ask` blocking until answered (`comms.proto:271-276`). Superseded (Matt's + decision, July 2026): **the channel is the primary human↔agent surface, and + the execution trace is observation-only.** The agent communicates with its + user through exactly two *durable* surfaces — **channel messages** (regular or + `ask`) and **pull requests** — while its execution trace (assistant/thought + chunks, tool calls, plans, diffs) is a live, ephemeral **observation** stream, + not persisted as comms `Message` rows (see *The communication layer* and + *State + storage*). Three consequences ride this inversion, all ratified (see + *Resolved decisions*): (a) **`Ask` is async** — the "blocks until answered" + clause is dropped; blocking becomes the agent's turn-level choice, and a human + steer (`@`-mention) and an ask-answer are one session-injection path; (b) the + **comms `MessageBlock` surface narrows** to the durable conversation + (`text` + `ask`), the trace variants moving to the observation stream; (c) + **owner-membership is transitive** — an agent's DMs and the channels it starts + always include its owning user(s), so a user can inject in response to + anything their agent said. Three round-two forks were then decided (July 2026, + this amendment; see *Resolved decisions*): (d) **threading gets a carrier** — + an additive `parent_message_id` on `Message`; (e) **the trace is a dedicated + OMP-native session stream** (`SubscribeAgentSession`), not typed + `SubscribeEvents` variants — the three ACP-translation variants are dropped + and `SubscribeEvents` keeps only Compass projections (liveness, lifecycle, + board); (f) **one ACL** — observation-pane access is a projection of channel + membership, so the workspace `participant_user_ids` + share/unshare RPCs are + removed. This supersedes only the *primacy and persistence* of the surface; + the workspace type survives, demoted to the observation pane (the trace plus + terminal/file panes), no longer a message container. + +### The three tiers + +The topology is **Client → Server → Runner** (`../compass-0.5/design.md:71-95`), +each tier one responsibility, all first-party code — Go on the Server and +Runner, TypeScript on the Client and the in-container agent (see *Global +Constraints*): + +- **Client** — the web UI in a browser (MVP; the Tauri desktop app is deferred, + `../compass-0.5/design.md:330-336`). It is a `compass.v1` client over + gRPC-Web/Connect; the generated client is the only sanctioned way to reach + the Server (`proto/compass/v1/compass.proto:1-4`: "The generated + clients are the only sanctioned way to reach the server"). It reuses the + SolidJS shell and its workstream/agent state model + (`../compass-ade-shell/design.md`, `../compass-dock-in-sidebar/design.md`) + with the communication layer as the primary surface, and assumes nothing + "local" beyond the transport boundary + (`../compass-tauri-shell.md:119-121`: "the shell and UI must never assume + 'local' beyond the transport boundary — no socket path or `localhost` leaks + above the `fetch`/command seam"). +- **Server** — the long-lived, networked, multi-user orchestrator and the seat + of all state: accounts, channels, messages, agent workspaces, agent + transcripts, centralized agent config, and the board projection. It serves + both `compass.v1` services — `CompassService` + (`proto/compass/v1/compass.proto:14-53`) and `CommsService` + (`proto/compass/v1/comms.proto:38-100`) — off one connect-go + handler stack (the serve loop already serves "native gRPC (HTTP/2), gRPC-Web, + and Connect off one connect-go handler", + `go/server/serve.go:3-5`), owns the Postgres store of record, + and fans live events out on the in-memory bus. +- **Runner** — a binary dropped onto any machine that should host agent + containers, "like a CI runner: it manages the per-agent containers … connects + out to the Server, and streams container activity back" + (`../compass-0.5/design.md:89-95`). The container-runtime layer it drives is + built: `ContainerRuntime` is "the container engine seam … An interface so the + Runner can hold a ContainerRuntime and tests can substitute a fake" + (`go/internal/runtime/podman.go:271-276`), with `PodmanCLI` as + the rootless-podman implementation (`podman.go:321-324`; "Rootless is a hard + requirement … no daemon, no root, no rootful fallback", `podman.go:24-25`). + Runners hold no durable state: all agent state lives in the Server, so a + Runner host dying costs a restart, never context. + +### The communication layer (the spine) + +The communication layer is a Discord/Slack-style channel system in which +**humans and agents are first-class accounts in a management hierarchy** — the +product's core pillar, carried from v0.5 +(`../compass-0.5/design.md:99-121`). The frozen `compass.v1` comms contract +states the model directly (`proto/compass/v1/comms.proto:1-11`): + +> "The compass.v1 communication layer: the Discord/Slack-style channel system +> that is the spine of Compass … Humans and agents are first-class accounts in +> a management hierarchy. Channels nest in channel groups … An agent's +> interactive surface — its ACP UI: the conversation (tool calls, plans, +> diffs, structured asks) plus terminal and file panes — is a first-class +> AgentWorkspace … All comms flow through this layer, so audit and search are +> properties of the substrate, not a separate pipeline." + +(The proto comment's "ACP UI" wording is pre-cutover doc-intent: under v0.6 the +agent is first-party, not ACP — see *Agent runtime*. This amendment goes +further and **inverts the surface's primacy** (superseded decision 4 above): +the frozen contract makes the `AgentWorkspace` *itself* the conversation, with +the agent's turn streaming in as workspace `Message` blocks. Under this +amendment the **channel is the primary human↔agent surface** and the workspace +is demoted to an **observation pane** — the live execution trace plus the +terminal and file panes — no longer a persisted-message container. The bullets +below are restated accordingly.) + +Concretely, as the contract fixes it: + +- **Accounts** (`comms.proto:104-142`): `Account` with `UserAccount` (role: + member/admin) and `AgentAccount` subtypes; an agent is "a constrained, owned + subtype … gated by its owning user, who has first-class control over which + channels and groups the agent may see and post to" (`comms.proto:132-135`). + An `AgentAccount` additionally carries an **additive `home_channel_id`** (RT-2, + ratified) — the agent's named channel/DM, minted at `CreateAgent` — which fixes + "the agent's own channel" for the always-subscribed row, turn-end delivery, and + the observation-pane ACL (see *Ratified additive contract changes*, *Resolved + decisions* RT-2). +- **Channel groups + channels** (`comms.proto:144-203`): nested namespace + groups carry visibility that can only narrow toward the leaves ("a child + group cannot widen its parent's scope", `comms.proto:150-151`), so a user's + owned agents work in that user's space by default while shared channels stay + open. **Owner-membership is transitive** (superseded decision 4): an agent's + DMs and any channel an agent creates always include the agent's owning + user(s) in `member_account_ids`, so a user can read and inject into anything + their agent said or was told — enforced server-side at channel/DM creation + (an agent↔agent DM carries *both* owners). **Precedence:** for `DM`/`GROUP_DM` + kinds, `member_account_ids` **grants visibility regardless of the group's + `ChannelGroupVisibility`** — the frozen `OWNER`/`SHARED` enum + (`comms.proto:174-180`) has no value that represents a cross-owner DM (`OWNER` + excludes the other owner; `SHARED` leaks to all accounts), so membership, not + the lattice, governs DM visibility; plain channels stay lattice-governed. This + is a **deliberate cross-owner disclosure**: both owners read the full + agent↔agent DM, so one owner's agent can surface context it holds into a DM the + other owner reads — the intended consequence of agents-as-accounts, not a leak. + See *Ratified additive contract changes*. +- **The channel is the primary human↔agent surface** (superseded decision 4): + a user talks to an agent in a channel named for it — a Slack/Discord-style DM + conversation with threading (an additive `parent_message_id` on `Message`; + see *Ratified additive contract changes*) — and the agent replies there. Two + message kinds + carry the whole durable interaction: a plain `text` message and a structured + **`ask`** (`Ask`/`AskOption` + `RespondToAsk`, `comms.proto:277-296,86-88`). + `Ask` is **async** — the frozen "blocks until answered" clause + (`comms.proto:271-276`) is superseded: the agent posts the question and + chooses at the *turn* level whether to wait (end the turn) or keep working and + fold the answer when it lands (`rule://never-block` at the human boundary). A + human **steer** — `@`-mentioning the agent in the channel — and an ask-answer + are the **same session-injection path** into the running agent (see *Ratified + additive contract changes*). +- **The `AgentWorkspace` is the observation pane, not the conversation** + (`comms.proto:207-213`, superseded decision 4): it renders the agent's live + **execution trace** (assistant/thought chunks, tool calls, plans, diffs) plus + the terminal and file panes, streamed from the **dedicated OMP-native + session-tail stream** (opaque frames rendered by OMP's own renderer; *not* + `SubscribeEvents`, which keeps only Compass's own projections) — + observation-only. The user watches here, can **stop** the agent, and steers + *through the channel* (not here). **Access is a projection of the agent's + home-channel membership** (RT-2, ratified) — a member of the agent's **home + channel** may watch its pane, scoping `SubscribeAgentSession` the same way; the + trace carries nothing more sensitive than the conversation the same members + already read, so it is the one shared ACL, not a stricter trace-specific one. + There is no separate workspace share (`participant_user_ids` and the + `ShareAgentWorkspace`/`UnshareAgentWorkspace` RPCs are removed, fork f). It is + **not** a persisted-`Message` container: the trace is live session frames plus + the S3 session, never comms `Message` rows (see *State + storage*). +- **Durable messages are the conversation, not the trace** (`comms.proto:225-229`): + a `Message` on the comms surface carries the durable human↔agent conversation + — a human's channel message, or an agent's `text` reply or `ask`. The trace + variants of `MessageBlock` (`thought`/`tool_call`/`plan`/`diff`, + `comms.proto:247-262`) leave the comms surface entirely — they are OMP-native + session data on the observation stream, and **diffs and plans are surfaced as + pull requests** (link-out for the MVP; native PR viewing in Compass is a later + increment). The `MessageBlock` oneof narrows to `text` + `ask` (the trace + variants are removed, OQ-A resolved; see *Ratified additive contract changes*). +- **Audit + search cover the durable conversation** (D1, narrowed by superseded + decision 4): `SearchMessages` is "the audit/search property served from the + store of record" (`comms.proto:90-93`), scoped server-side to the caller's + visible set — never a separate pipeline. With the trace no longer persisted as + `Message` rows, full-text search covers the durable conversation (channel + messages + asks) and the PR trail; the *execution trace* is reviewed by + replaying the S3 session, not by comms search. This narrows D1's + "search is a property of the substrate" from "everything the agent emitted" to + "everything said in the conversation" — a deliberate, ratified cut (see + *Resolved decisions*): search what was *said*, replay what was *done*. +- **Authorization is connection-bound**: "the caller is the account + authenticated on the connection … never a field in a request, which would be + spoofable" (`comms.proto:31-33`). + +### The `compass.v1` contract (cited, not redesigned) + +The contract is frozen and generated; this record builds on it (see *Ratified +additive contract changes* below) — **four** public RPCs (`CreateChannel`, +`ProvisionAgentWorkspace`, `SubscribeAgentSession`, `UpdateChannelMembers`), two +internal agent-stdio messages (`AgentFrame`, `AgentControl` — carrying the RT-3 +`deliver` control + `delivery_ack` frame), the `snapshot_seq` + +`parent_message_id` + `home_channel_id` fields, the channel-membership carriers, +and a refinement of `since_seq = 0`'s +documented meaning (see *State + storage*) — **plus three pre-launch buf-breaking +removals** (the ACP-translation variants, the share/participant RPCs, and the +`Message.container` `workspace_id`; enumerated and justified below), and nothing +else beyond that set. Two services in one owned `compass.v1` +package: + +- **`CompassService`** — the server/agent-session lifecycle door + (`proto/compass/v1/compass.proto:14-53`): `GetServerInfo` (the + connect-time liveness/version probe), `SubscribeEvents` (the sequenced + server-stream event channel — "Each response carries a server-assigned + monotonic `seq`; reconnect with `since_seq` for a gap-free resubscribe", + `compass.proto:20-22`), `StartAgentSession` / `StopAgentSession` / + `ReloadAgentSession` / `GetAgentStatus` (the agent-session lifecycle), + `IssueToken` (admin-gated token minting: "Token is 32B random, base64url, + returned once; the server stores only its SHA-256 hash", + `compass.proto:47-49`), and — **added** by the inversion — `SubscribeAgentSession`, + the dedicated OMP-native session-tail stream for the observation pane (see + *Ratified additive contract changes*, fork e). +- **`CommsService`** — the communication-layer door + (`proto/compass/v1/comms.proto:38-100`): `CreateUser`, + `CreateAgent`, `ListAccounts`; `CreateChannelGroup`, `ListChannelGroups`, + `ListChannels`; `OpenAgentWorkspace`, `ShareAgentWorkspace`, + `UnshareAgentWorkspace` (the last two **removed** by the inversion — see + *Ratified additive contract changes*, fork f); `ListMessages`, `PostMessage`, + `RespondToAsk`, `SearchMessages`; `SubscribeComms` (the comms event + stream, mirroring `SubscribeEvents`' seq/epoch replay contract, + `comms.proto:95-99`); and — **added** by this record — `CreateChannel` and + `UpdateChannelMembers` (the membership-mutation RPC, RT-1; see *Ratified + additive contract changes*). + +Both event streams share one replay model: a server-assigned monotonic `seq` +plus a per-boot `instance_epoch`, with a terminal resync signal when a cursor +cannot be served gap-free (`comms.proto:300-324`, +`compass.proto:119-123`). Go server stubs and the TS client are generated and +CI drift-gated (`go/moon.yml:39-42` — the `drift` task "Fail[s] if +the checked-in Go stubs are stale vs the schema"; generated handlers at +`go/gen/compass/v1/compassv1connect/`, e.g. `NewCommsServiceHandler`, +`comms.connect.go:393`). A contract change means: edit the schema → `moon run +compass-go:gen` → commit the regenerated output. + +#### Ratified additive contract changes + +The plan requires contract changes that do not exist in the frozen +contract. Most are **additive or doc-intent supersessions** (new methods / two +new internal messages / new fields / narrowed-or-clarified comments — safe +under the buf-breaking gate); **three are pre-launch buf-breaking removals** +(the ACP-translation variants, the share/participant RPCs, the +`Message.container` `workspace_id`) that will **fail** a buf-breaking check and +ride the pre-launch override — justified below and safe only because the Server +on `main` is ephemeral with no live client. All are ratified by Matt at this +record's freeze gate (see *Resolved decisions*), and land in their implementing +task's PR via the gen/drift cycle. The first four are the original v0.6 set; the +rest ride the interaction-surface inversion (superseded decision 4), detailed +after them: + +- **`CreateChannel`** (T2). `CommsService` today can create a channel *group* + but not a *channel* — the full RPC set (`comms.proto:38-100`) has + `CreateChannelGroup` and no `CreateChannel`, yet the event surface already + documents "A channel was created" (`ChannelChanged`, `comms.proto:338-339`). + `rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse)` — + caller-authorized against the parent group, emitting `ChannelChanged`. (If + DMs make the MVP cut, an `OpenDirectMessage` RPC is the same class of + additive change; not required for the T1–T8 plan.) +- **`ProvisionAgentWorkspace`** (T4). No RPC launches a container: + `StartAgentSession` assumes one exists ("The launched container's stable + name", `compass.proto:198-199`), and none of `CompassService`'s seven RPCs + provisions one. `rpc ProvisionAgentWorkspace(ProvisionAgentWorkspaceRequest) + returns (ProvisionAgentWorkspaceResponse)` — agent ref + repo/workstream + spec in, `container_name` out — routing Client → Server → RunnerHub → + Runner and driving the built lifecycle façade + (`go/internal/runtime/agent.go:1-5`). Provision and start stay + separate RPCs, matching the frozen `StartAgentSession` semantics. +- **`AgentFrame` + `AgentControl`** (T5, internal-only). The agent's stdio + streams need discriminated envelopes; no frozen message covers either + direction, and neither existing response type carries both content and + status (see *T5*). Two new internal-only messages, generated only into the + agent + Runner (not the public client surface, matching the `RunnerService` + internal-gen posture): **`AgentFrame`** (stdout) — a `oneof frame` split by the + surface that owns each payload: **conversation** (`MessagePosted`/ + `MessageUpdated` content, reused without redefinition) and **session** (a + `SessionFrame` opaque OMP-native envelope + `AgentSessionState` for the board); + and **`AgentControl`** (stdin) — a + `oneof control { prompt; steer; deliver; ask_answer; config; replay; replay_complete }` + carrying the control ops + the restart replay barrier. `steer` is free text; an + `ask_answer` is structured (`ask_id` + `chosen_option_ids`, mirroring + `RespondToAskRequest`, `comms.proto:481-486`) so a late answer correlates to + the right in-flight ask across turns — one *delivery* path (stdin control + frames), two payload shapes. **Delivery timing (amended, round-three — Matt):** + a frame arriving while the agent is **idle starts a new turn**. Mid-turn, the + two paths differ: an **`@`-mention-borne `steer`** interjects into the running + session immediately (the SDK's steer/followUp queuing, see *Agent runtime* and + *Channel membership: join / subscribe / mention*), while a **plain message from a + subscribed channel** is delivered immediately as a **`deliver`** frame that the + agent queues and coalesces into a single `prompt` at its **turn end** (RT-3, + acked via `AgentFrame.delivery_ack`), not as a mid-turn + interrupt. An `ask_answer` re-wakes an idle agent that ended its turn to wait — + the wake rule that makes an async `ask` safe. Only the `@`-mention steer + interrupts a turn in progress. +- **`snapshot_seq` boundary field** (T2). To make `since_seq = 0` recovery a + *consistent* point-in-time snapshot, the subscribe response carries a + `snapshot_seq` and each read RPC (`ListMessages`/`ListChannels`/…) takes it, + so every page reads one point-in-time view under concurrent writes (see the + write-through property in *State + storage* and *Resolved decisions*). + Additive fields on the existing subscribe-response + read-request messages. +- **Unified steer path** (T5/T7, superseded decision 4). A human steer and an + ask-answer are one thing — injecting a human message into the running agent. + The frozen contract already routes ask-answers (`RespondToAsk`, + `comms.proto:86-88`); the amendment routes an `@`-mention in the agent's + channel through the *same* injection path — a free-text `@`-mention is + delivered as `AgentControl.steer`, a structured answer as + `AgentControl.ask_answer` (see the `AgentControl` oneof under *Ratified + additive contract changes*). **Authz sweep:** `RespondToAsk` is frozen-authored + to authorize "the caller … a participant of the workspace the ask belongs to" + (`comms.proto:86-88`) and `ask_id` is "resolved within the caller's authorized + workspaces" (`comms.proto:278-279`); under channel-borne asks both become + channel-membership** checks (doc-intent supersession, swept in T2). **Steer RPC + shape (OQ-B, resolved — Matt):** steer reuses `PostMessage` into the agent's + channel with server-side `@`-mention routing into the running session — no + dedicated `SteerAgent` RPC. A steer *is* a channel message; reuse adds no RPC, + unifies with ask-answer, and keeps the "everything is in the channel" + property. The `@`-mention is what distinguishes an *immediate* steer from a + turn-end delivery (see *Channel membership: join / subscribe / mention* below). +- **Channel membership: join / subscribe / mention — the delivery model** + (T2/T5/T7, superseded decision 4, round-three, Matt). Membership is **tiered**, + and the tier plus the `@`-mention decides how a message reaches an agent: + - **Join** — an account with visibility perms (D9) joins a channel and may + **read** its messages (added to `Channel.member_account_ids`, + `comms.proto:194`). Read access only; no push. + - **Subscribe** — a joined account opts in so **new messages are pushed to it**; + for an agent, a plain (non-`@`) message in a subscribed channel is delivered + **immediately as an `AgentControl.deliver`, queued by the agent and processed + at its turn end** (RT-3, see *Agent runtime*), exactly like a message in its + own channel (never a mid-turn interrupt). An agent is **always subscribed to its own channel** + (implicit, not a togglable row; "its own channel" = the agent's **home + channel**, an additive `home_channel_id` on the agent `Account`, minted at + `CreateAgent` — ratified, see *Resolved decisions*, RT-2). + **Agent vs user:** for an *agent*, subscribe + governs turn-end *delivery* (above); for a *user*, it governs only + notification/unread emphasis (the surface the user's attention tracks — see + *Agent runtime*, "Reaching the user"), since the `SubscribeComms` fan-out is + already visibility-scoped (`comms.proto:95-99`, no channel filter) and a + user's subscribe bit changes nothing on that push path. Subscription is an + additive per-member boolean on channel membership — the record fixes the + semantics; T2 fixes the exact carrier (a `subscribed` flag on a membership + row, or a parallel `subscriber_account_ids`). + - **`@`-mention → immediate steer** — an `@`-mention of an agent **always + interjects a mid-turn steer** into that agent (via `AgentControl.steer`), + for **any agent that is a member of the channel**, regardless of its + subscribe state and including **shared channels**. This is the one path that + interrupts a running turn; a plain subscribed-channel message is not. + - **Reserved group pings** — `@agents` (all agent members), `@users` (all user + members), `@everyone` (both). Each resolves server-side to the matching + channel members and steers every agent in that set. `@`-mentions work + **agent→user and user→user** as well (a user or agent can `@` a user — a + notification to that user, not a steer, since users aren't turn-driven). + This supersedes the earlier flat "any control frame arriving mid-turn queues + into the running session" wording (see the `AgentControl` bullet under + *Ratified additive contract changes* above): only an `@`-mention-borne steer interrupts + mid-turn; a plain subscribed message rides the turn-end wake. +- **`UpdateChannelMembers` — the membership-mutation RPC** (T2, RT-1, ratified). + Join (`+member_account_ids`), the subscribe opt-in toggle, DM→GROUP_DM member + additions, and fork f's "sharing = adding to the channel" are all membership + **mutations**, but the frozen `CommsService` set (`comms.proto:38-100`) has no + RPC for them and fork f removed the only ones (`Share`/`Unshare`) — the same + gap the record closes for `CreateChannel` (`ChannelChanged` is literally "A + channel was created, **or its membership changed**", `comms.proto:338`). Add + **one** additive `rpc UpdateChannelMembers(UpdateChannelMembersRequest) returns + (UpdateChannelMembersResponse)` to `CommsService` — add/remove members and flip + the per-member `subscribed` flag — caller-authorized against channel + visibility, emitting `ChannelChanged`. One RPC covers join, subscribe-toggle, + DM-expansion, and share-replacement; it is the carrier for the `subscribed` + flag T2 fixes above. Additive (buf-breaking-safe); lands in T2 via the + gen/drift cycle. +- **`Ask` becomes async** (T5/T7, superseded decision 4; doc-intent + supersession). The frozen `Ask` comment "blocks until a participant answers" + (`comms.proto:271-276`) is superseded: an ask is a normal async channel + message; the agent chooses at the turn level whether to wait. Comment-only + change (the `Ask`/`RespondToAsk` shapes are unchanged), landing via the + gen/drift cycle in T2. +- **Doc-intent comment sweep** (T2, superseded decision 4). Frozen doc-comments + the inversion falsifies, swept as comment-only supersessions (no shape change): + `PostMessage` "a human prompt into an agent's workspace" (`comms.proto:82-83`) + → into an agent's **channel**; `ChannelKind` "An agent's ACP surface is an + `AgentWorkspace`, not a channel" + "DMs are direct human conversations" + (`comms.proto:197-198`) → the agent's surface **is** a channel/DM, and + human↔agent and agent↔agent DMs both exist. **DMs are a channel kind, not a + separate type** (`ChannelKind.DM`/`GROUP_DM` already exist, `comms.proto:199-203`): + a two-party DM **expands to a multi-party conversation by adding members** + (`DM` → `GROUP_DM` as `member_account_ids` grows), so a human↔agent DM widens + into a multi-agent/multi-user working channel without a type change — one + membership model across DMs and channels. Beyond these named comments, the + sweep is a **rule, not a fixed list** — every frozen doc-comment that + references workspace-as-container, workspace *participants*, or + workspace-scoped authz is superseded to its channel-membership equivalent in + T2. Known instances: `OpenAgentWorkspace` "the caller must own the agent or + already be a participant" (`comms.proto:63-64`) → channel membership (the RPC + survives, fork e; its authz becomes membership-based); `ListMessages` "a + channel or an agent workspace the caller may see" (`comms.proto:78-79`) and + `SearchMessages` "visible channels and workspaces" (`comms.proto:90-93`) → + channels only; `AgentWorkspaceChanged` "or its participants changed (e.g. a + share)" (`comms.proto:353`) and `Message` "An agent's ACP turn is a workspace + message" (`comms.proto:225-228`) → channel-message framing. Completeness is the + rule's, not this enumeration's. +- **`MessageBlock` narrows to the durable conversation** (T2/T5, superseded + decision 4). With the execution trace delivered as opaque OMP-native session + data on a dedicated stream (not comms, not typed `SubscribeEvents` variants), + the comms `MessageBlock` oneof (`comms.proto:247-262`) narrows from + text/thought/tool_call/plan/diff/ask to the durable conversation: `text` + + `ask`. The trace variants (`thought`/`tool_call`/`plan`/`diff`) are removed — + none is reused by the observation pane (it renders OMP-native frames), so none + is kept "defined but unused"; `diff`/`plan` are additionally surfaced as PRs. + Physical removal lands in T2 via the gen/drift cycle (OQ-A, resolved). The + `Message.container` `workspace_id` is **dropped** (OQ-C, resolved — Matt): the + container becomes **channel-only** (see *Resolved decisions*, container shape). +- **Dedicated agent-session stream + drop the ACP-translation variants** (T5/T7, + superseded decision 4, round-two fork e). Add a session-tail RPC on + `CompassService` — `rpc SubscribeAgentSession(SubscribeAgentSessionRequest) returns (stream AgentSessionFrame)` + — carrying the agent's OMP-native session events as an **opaque** envelope + (bytes/JSON) plus the `AgentSessionState`, consumed by OMP's own renderer in + the observation pane and scoped to the caller's channel membership. The public + `AgentSessionFrame` is the Server→Client repackaging of the internal + agent-stdout `AgentFrame.session` `SessionFrame` (T5) — same opaque payload, + relayed verbatim. In the same change, **remove** the three ACP-translation + variants `agent_message_chunk` / `agent_tool_call` / `agent_plan` from + `SubscribeEventsResponse.payload` (`compass.proto:103-105`) — under the + first-party OMP agent they are neither the native render format nor needed; + `SubscribeEvents` keeps `ServerStatus`, `ResyncRequired`, `AgentSessionStatus`, + and the T8 board. This one removal is **buf-breaking, not additive** — + permitted here only because the Server on `main` is ephemeral (no live client) + and none of the three dropped variants was ever published (the only production + Publish sites — `ServerStatus` at `serve.go:207`, `ResyncRequired` at + `service.go:151` — are both kept variants; one **test** fixture, + `server/service_test.go`'s `chunkEvent`, constructs `AgentMessageChunk` and + migrates to a kept variant in the same change); it lands in T5/T7 via the + gen/drift cycle. +- **`parent_message_id` on `Message`** (T2, superseded decision 4, round-two + fork d). The primary surface is a threaded channel conversation, but `Message` + (`comms.proto:229-241`) has no thread/parent field. Add an additive optional + `parent_message_id` so a reply threads under its parent; a root message leaves + it unset. Additive (buf-breaking-safe); lands in T2 with the message schema. + +The first four are the original v0.6 additive set. The rest ride the +interaction-surface inversion (superseded decision 4): the async-`ask`, +doc-intent, and unified-steer changes are additive/comment-only; the +`parent_message_id` field, the session-tail RPC, and the channel-membership +carriers (the per-member `subscribed` flag + reserved-ping resolution) are +additive; the observation-pane ACL collapse (fork f) **removes** +`AgentWorkspace.participant_user_ids` and the `ShareAgentWorkspace` / +`UnshareAgentWorkspace` RPCs (`comms.proto:67-76,207-221`), the session-stream +change **removes** the three ACP-translation variants, and the container +narrowing (OQ-C) **removes** `Message.container.workspace_id` and its +co-narrowing read-request fields — **three** buf-breaking removals in all, the +only buf-breaking items, safe pre-launch (ephemeral Server, no live client). All +land in their implementing task's PR via the gen/drift cycle (see *Resolved +shape questions* and *Resolved decisions*). + +### Agent runtime: a first-party agent emitting `compass.v1` + +**Superseded here (Matt's decision, July 2026): the ACP/BYOA agent model of +SEA-1023** (`../../agents/sea-1023-acp-session.md` — Fork 1 "adopt +`agent-client-protocol`" `:39-44`, Fork 2 ACP-over-stdio `:46-52`, Fork 4 the +ACP→`compass.v1` translation seam `:64-68`, and the "BYOA over ACP … +default/reference agent" constraint `:19`). The in-container agent is a +**first-party program built on the OMP SDK**, and it emits `compass.v1` +natively — there is no ACP anywhere in the system. + +- **The agent is ours, on the OMP SDK.** It is built on + `@oh-my-pi/pi-agent-core` — the stateful agent loop ("General-purpose agent + with transport abstraction, state management, and attachment support"; MIT; + `engines.bun >=1.3.14` — `github.com/can1357/oh-my-pi`, + `packages/agent/package.json`): the `Agent` class with `prompt`/`continue`, + `steer`/`followUp` queuing, a subscribable event stream + (`agent_start`/`message_update`/`tool_execution_*`/…), and programmatic + state control — the runtime mutators `setTools`, `setSystemPrompt`, + `setModel`, plus custom message types via declaration merging (all + documented in `packages/agent/README.md`; `getToolContext` is a + construction-time `AgentOptions` field, `packages/agent/src/agent.ts` + `AgentOptions`, not a runtime mutator — the live tool/prompt surface is the + three `set*` methods). The coding tool surface composes + from `@oh-my-pi/pi-coding-agent` ("Coding agent CLI with read, bash, edit, + write tools and session management", MIT, + `packages/coding-agent/package.json`). +- **It speaks `compass.v1` natively; the translation seam is deleted.** The + agent maps the SDK's event stream to `compass.v1` payloads **inside the agent + itself**, its own testable surface. Under the inversion the mapping splits by + surface (see *Conversation-vs-trace publication* below): the durable + **conversation** maps `text`→`MessageBlock.text` and the `ask` tool→ + `MessageBlock.ask` (the two surviving block kinds); the **execution trace** — + assistant `thought`, `tool_execution_*`, `todo` (plan), `edit`/`write` (diff) + — is **not** re-typed into `compass.v1`, but wrapped **verbatim** as opaque + OMP-native session frames (`SessionFrame`) for the observation pane, with + `diff`/`plan` additionally surfaced as PRs. There is no ACP `session/update` + and no Runner-side translator (SEA-1023 Fork 4 superseded). +- **Reaching the user is a channel reply, not session output (agent-behavior + contract, round-three — Matt).** An agent's ordinary output streams to the + **session log** — the observation pane the user watches *sometimes*, not + continuously. To actually **reach** the user, the agent must post a **channel + message** (a `text` reply or an `ask`); that is the surface the user's + attention (unread/mention/thread signals) tracks. And it **should reply within + a thread** (`parent_message_id`) whenever it is responding to something, so a + channel's conversations stay logically grouped rather than flattened into one + stream. This is the behavioral half of the surface split: the session log is + *observation* (what the agent is doing); the channel is *communication* (what + the agent needs the user to see). Skills/prompt guidance for the first-party + agent encode this norm; the contract just makes the two surfaces distinct. +- **Transport across the container boundary is unchanged: the built streaming + exec.** `ExecStreaming` "starts a long-lived streaming command in a running + container, returning its live stdio pipes plus a kill/wait handle" + (`go/internal/runtime/podman.go:289-296`; implementation + `:417-422`). The pipes now carry the agent's **newline-framed `compass.v1` + stream**, not ACP JSON-RPC — a payload-schema change only; the mechanism is + schema-agnostic. (The built code's comments still say "ACP" + (`podman.go:140-144,289-296`) — pre-cutover doc-intent, updated as T5 + touches those files.) +- **The Runner launches and relays; it does not translate.** The Runner + starts the container through the built lifecycle façade ("build the image, + create and start the container, arm the egress firewall … and tear it all + down", `go/internal/runtime/agent.go:1-5`), spawns the + first-party agent via the streaming exec, and relays its `compass.v1` + stream up the Server↔Runner seam — the `PublishEvents` client-stream the + frozen platform record fixes + (`../../platform/go-toolchain-default.md:931-937`). No ACP client, no + translator on the Runner. +- **One container = one agent = one session** — carried from SEA-1023 + (`../../agents/sea-1023-acp-session.md:66`); the agent works multiple + branches via worktrees inside its clone, not via extra sessions. +- **Placement and sequencing (conforms to the frozen OQ6 ruling).** The + streaming exec originates on the Runner; the Runner relays events to the + Server over `PublishEvents`. That link stream is **Runner-sequenced** — + "seq assigned at the Runner, not at Server publish" — "so in-transit loss + is *detectable* as a gap and the Client bus's gap-free guarantee holds" + (`../../platform/go-toolchain-default.md:1390-1392`). The Server then + write-throughs each event and publishes it on its own bus, which assigns + the **bus-seq** clients see (`Publish` "stamps the next seq", + `go/events/events.go:160-165`). Link-seq (Runner→Server gap + detection) and bus-seq (Server→Client replay cursor) are distinct sequence + spaces. Lifecycle RPCs (`StartAgentSession` …) flow Client → Server → + Runner. +- **Conversation-vs-trace publication is a specified split, not a fork** + (superseded decision 4). The agent's durable **conversation** — a `text` + reply or an `ask` — becomes channel `Message`/`MessageBlock` rows and fans out + on the comms surface (`SubscribeComms` `MessagePosted`/`MessageUpdated`, + `comms.proto:326-336`); the agent's **execution trace** (assistant/thought + chunks, tool calls, plans, diffs) is OMP-native session data carried on a + **dedicated session-tail stream** (opaque, rendered by OMP's own renderer), + while its extracted lifecycle (`AgentSessionStatus`) and the board projection + ride `SubscribeEvents` alongside `ServerStatus` (`compass.proto:102,97`). + Complementary, not duplicative: a conversation event writes one Postgres row + and fans out on comms; a trace event carries live on the session stream and + persists only to the S3 session (never a comms row). This **retires** the + frozen comms spine's staging note that "the ACP-as-native-UI payloads move + onto the workspace/channel surface rather than being redefined in parallel" + (`comms.proto:15-18`): under v0.6 the trace is neither a comms `MessageBlock` + variant nor a typed `SubscribeEvents` variant — the three ACP-translation + variants `agent_message_chunk`/`agent_tool_call`/`agent_plan` are **dropped** + (they were never published, `serve.go:206`), and the durable comms surface + narrows to `text` + `ask`. +- **Richer control is the point (Matt's stated motive).** Because the agent + is ours on the SDK, server-pulled skills, config, and the tool surface + inject **programmatically** into the running agent's state — `setTools`, + `setSystemPrompt`, `setModel`, custom message types — rather than + only as an opaque file mount. Config distribution (T6) keeps the D11 + read-only mount for file-shaped config and adds this structured-injection + path for skills/tools. +- **Doc-intent supersession, not a wire change:** the frozen proto comment + "bring the agent in an already-launched container online as an ACP session" + (`proto/compass/v1/compass.proto:195-196`) now reads "…online + as a compass agent session" — the RPC shape (takes `container_name`, + returns `session_id`) is untouched; only what it drives changes. +- **BYOA is deferred, an unbuilt seam note.** A future third-party agent + could be re-admitted behind the same streaming-exec transport by emitting + `compass.v1` itself (or by re-adding a translator process); the MVP ships + exactly one first-party agent and carries no ACP/BYOA machinery. This is a + deferral note, not a plan task. + +### Per-agent container isolation on the Runner + +The container model is v0.3's, kept verbatim and hosted by the Runner +(`../compass.md:141-149`): + +- **The container is the unit of isolation**: rootless podman, "a scoped + `$HOME` for that agent's credentials, its own process namespace, a + default-deny egress allowlist at the container layer" + (`../compass.md:143`). +- **Clone-per-container**: each agent gets its own full git clone per in-scope + repo, worktree-per-workstream inside it (`../compass.md:145`). +- **Scoped credentials**: each container carries only its own credentials in + the agent's `$HOME`, belonging to the agent's own forge identity + (`../compass.md:149`); forge identity is a configurable per-agent setting + (first-class Forgejo provisioning; per-seat forges select an account, + `../compass-0.5/design.md:380-389`). + +The Go implementation of this layer exists under +`go/internal/runtime/`: the lifecycle façade "build the image, +create and start the container, arm the egress firewall as root, install +scoped credentials, clone the repo as the unprivileged agent user, and tear it +all down" (`go/internal/runtime/agent.go:1-5`), with `AgentSpec` +carrying workspace, egress policy, and read-only mounts (`agent.go:30-43`). +The Runner binary wraps this package; the Server never touches a container +engine. Because state lives in the Server, **runners and containers are +throwaway** (`../compass-0.5/design.md:309-323`, D6): stop is free, restart or +relocation replays the transcript into a fresh container. + +### State + storage: Postgres is the substrate; the ring is a cache + +**Postgres is THE durable substrate.** It is the store of record for all +structured state: accounts, channels, channel groups, agent workspaces, +**conversation** messages (channel text + asks), agent config, board/issue +state, issued-token hashes, and the transcript index/metadata. This carries +v0.5 D12's store-of-record ruling forward (`../compass-0.5/design.md:466-471`: +"The Server's datastore is **PostgreSQL**, and it is the **system of record** +for all structured state") and closes the present gap: on `main`, the Go module +has no database dependency at all and no comms persistence — the only stateful +component the serve loop constructs is the in-memory bus +(`go/server/serve.go:138-140`) — so the Postgres store is built +as a first-class early task (see *Plan*), not discovered as a late swap. No +component may treat a message bus as the store of record. + +**The execution trace is not a comms `Message` — it is observation-only** +(superseded decision 4). The agent's assistant/thought chunks, tool calls, +plans, and diffs are *not* persisted as comms `Message` rows in Postgres. They +are OMP-native session data delivered live on the **dedicated session-tail +stream** (not `SubscribeEvents`, which keeps only Compass's own projections — +`ServerStatus`, `ResyncRequired`, `AgentSessionStatus`, the T8 board) for the +observation pane, and their durable form is the **S3 session transcript** (below) +— the same object-storage posture transcript *bodies* already take, extended to +the whole trace. Postgres holds only the trace's **index/metadata** (session id, +container, timing, blob keys), never the per-chunk/per-tool-call stream. The +durable *conversation* (channel messages + +asks) and the **PR trail** are the searchable record (D1, narrowed above); +the trace is *replayed* from S3, not searched. The volume this keeps out of the +relational store is real, but the driver is **write/fan-out amplification**, not +body bytes: the frozen event contract has every streaming update "carry the full +current block set" (`comms.proto:331-333`), so under the pre-inversion model each +per-token chunk re-wrote and re-fanned an `O(turn-length)` `MessageUpdated` row. +(Per-call tool *output* was never Postgres-bound regardless — the frozen +`AgentToolCall` has no output field, only `session_id`/`tool_call_id`/`title`/ +`status`, `compass.proto:158-166`; the search the inversion gives up is thought +text and diff old/new text, since tool calls were title-only-searchable already.) +A middle option — a small `trace_events` **index** table (session id, seq, kind, +title, blob offset) with full-text search over just those title/summary fields, +bodies staying in S3 — was weighed and **rejected for the MVP**: "search what was +*said*, replay what was *done*" is the intended product stance, and the index is +an additive post-MVP capability if targeted trace search is later wanted. + +**Transcript bodies go to S3-compatible object storage behind a blob seam** — +kept exactly as v0.5 D12 fixed it (`../compass-0.5/design.md:473-494`): bodies +are large and append-heavy, so they live keyed in object storage, indexed from +Postgres; hosted deployments use R2; the self-hosted default is SeaweedFS +(Apache-2.0); any S3-compatible backend drops in behind the seam. Old +transcripts are retained, not dropped. Full transcripts are held server-side +and streamed into a container on (re)start +(`../compass-0.5/design.md:130-136`, D6). **Replay completeness:** because the +trace is the only thing that persists to S3 while the conversation persists to +Postgres, the S3 session must be written as **one ordered append log of the +whole session context** — trace frames *and* the durable conversation (channel +`text`/`ask`) *and* inbound `AgentControl` (`prompt`/`steer`/`deliver`/`ask_answer`) — so a +restart replays a complete context in one sequence. Appending only trace frames +would restore the agent's thinking without the human's prompts, steers, and +answers or the agent's own replies; the alternative (merge S3 with Postgres +`Message` rows at replay) is rejected because link-seq, bus-seq, and message +timestamps are three distinct orderings (see *The communication layer*) with no +defined interleave. The S3 log is authored in the agent's own emission order. + +**Live fan-out is the in-memory event bus, already built.** The bus is "a +monotonic-seq ring buffer plus a per-subscriber live tail, generic over the +payload it carries … one structure backs the server's SubscribeEvents and +(with the networked Server tier) the comms SubscribeComms" +(`go/events/events.go:1-7`). Its properties are exactly the +event-channel contract: + +- `ringCapacity = 1024` bounds the replay window + (`events.go:22-24`: "ringCapacity bounds replay memory; a subscriber that + falls further behind than this recovers by re-snapshotting at + sinceSeq = 0"). +- A per-boot random `instance_epoch` distinguishes restarts + (`events.go:339-346`: "epochNonce mints a per-boot instance epoch: a random + uint64 from the OS … A reconnecting client echoes it back so the server can + tell a live cursor from a prior instance's"). +- Overrun and shutdown are distinguished per subscriber + (`events.go:9-12`), so a lagged client gets a terminal `ResyncRequired` + rather than a silent gap. + +**The write path is write-through, Postgres first:** a message arrives → the +Server commits it to Postgres (durable, canonical, indexed) → publishes the +corresponding event on the in-memory bus (live fan-out to every subscribed +stream). Within the bus's retained window a reconnecting client replays from +the ring; beyond it, or across a restart (fresh `instance_epoch` ⇒ +`ResyncRequired`), recovery is a **Postgres re-snapshot through the read +RPCs**. Four properties of this shape are load-bearing: + +- **The ring is a cache; `since_seq = 0` no longer means whole state.** The + bus's own doc-comment describes "snapshot the ring at sinceSeq = 0" + (`go/events/events.go:5-7`) — in the ephemeral daemon the ring + *was* the whole state, so that snapshot was total. With Postgres in front, + the ring holds at most the last `ringCapacity = 1024` events + (`events.go:22-24`), so the frozen field comment "0: snapshot current state + as events, then tail" (`comms.proto:507-508`, `compass.proto:66-67`) is + **refined, not broken**: the state snapshot comes from the Postgres read + RPCs; the stream's `since_seq = 0` yields the ring window plus the live + tail. The client protocol produces a **consistent point-in-time snapshot** + via a snapshot-boundary token (Matt's ratified decision, see *Resolved + decisions*): subscribe (`since_seq = 0`) → the subscribe response carries a + `snapshot_seq` boundary + `instance_epoch` → snapshot state via the read RPCs + **each passing that `snapshot_seq`**, so every page reads the same + point-in-time view (no row crosses a page boundary under concurrent writes) + → then the live stream tails from exactly `snapshot_seq + 1`. Message-id + dedup (`comms.proto:229-230`) covers the boundary overlap. This also fixes + the empty-ring bootstrap: `snapshot_seq` comes from the subscribe response, + not from a first ring event that may not exist. The server's obligation: + a consistent snapshot at `snapshot_seq`, gap-free tail after it, terminal + resync otherwise. **Safe as a pre-launch change, not a break:** the Server on + `main` is fully ephemeral and no client consumes the old whole-state + semantics yet (the only `since_seq` references are generated types + shape + tests, not a live subscriber). The now-inaccurate frozen field comments + ("0: snapshot current state as events", `comms.proto:507-508`, + `compass.proto:66-67`) are corrected to the boundary-token protocol as part + of T2 — a doc-intent supersession of the comment (same class as + `StartAgentSession`'s "as an ACP session"), landing via the gen/drift cycle. +- **Crash between commit and publish is safe — because epoch resync is + total.** `Bus.Publish` cannot fail (no error return; a mutex plus + non-blocking sends, `events.go:160-204`), so the real failure mode is a + Server crash after the Postgres commit but before `Publish`: the row is + durable, no live event fired. The crash also drops every stream, and every + reconnect then sees a **fresh per-boot `instance_epoch`** + (`events.go:151-152`) ⇒ `ResyncRequired` ⇒ a full Postgres re-snapshot, + which surfaces the committed row. This recovery works **only** because the + epoch mismatch forces a *total* resync — the dependency is explicit, and + no partial-resync optimization may weaken it. +- **Bus order may differ from commit order.** Two handlers can commit A then + B but publish B then A — the bus serializes publishes, not transactions. + The message-id dedup above plus Postgres-as-truth-on-resync absorbs it; + consumers must not treat bus arrival order as commit order. +- **The ring is never a store.** No component may treat the bus as the store + of record (*Global Constraints*). + +**The one seam kept: event fan-out at Runner↔Server — executed by the +platform record.** The Runner delivers agent events to the Server through a +narrow interface: the `PublishEvents` client-stream of the internal +`RunnerService`, whose three-RPC shape the frozen platform record fixes +(`../../platform/go-toolchain-default.md:931-937`; "the Server↔Runner split … +is realized *here*, by T8 + T9", `:363-365`). v0.6 records the product-level +architecture and **defers the seam's wire shape to that frozen record** — T4 +below consumes it, it does not re-specify it. That consumption interface — +not the storage layer — is the sole place a broker could later slot in (see +*Alternatives considered* and *Resolved decisions*). Storage has no seam: +Postgres is not swappable, and multi-Runner topologies still converge on the +same database through the same Server. + +**Secrets**: the centralized config store enforces the defined +secret-handling boundary of v0.5 D14 (`../compass-0.5/design.md:528-546`): +encryption at rest, per-user/per-agent authorization, per-container isolation +of delivered secrets, rotation/revocation, and redaction from transcripts and +the audit log. + +### Config distribution: Runner-mediated pull, read-only mounts, OCI images + +Carried from v0.5 D11 (`../compass-0.5/design.md:430-459`), plus a +programmatic-injection path the first-party agent enables: + +- The Server is the config store of record, holding each agent's config + versioned (content-addressed per agent). +- The Runner pulls a hosted agent's config bundle over its existing gRPC + connection and materializes it as a **Runner-local read-only bind mount** + into the container — no cross-host network filesystem. The mount surface is + already in the Go runtime (`AgentSpec.Mounts` — "read-only host mounts", + `go/internal/runtime/agent.go:41-42`). +- Change propagation: the Server signals "config version N for agent X" over + the `Sessions` bidi stream (the Server→Runner path; agent events flow the + other way on `PublishEvents`); the Runner pulls, atomically swaps the mount, + and the agent's notification path tells the running process to re-read. +- **New under the cutover — programmatic injection:** for skills and the tool + surface, the pull is not just a file mount: the Server-pulled bundle is + injected as **structured state into the running first-party agent** over + its control stream — the SDK's `setTools`/`setSystemPrompt`/`setModel` + surface (`packages/agent/README.md`, `github.com/can1357/oh-my-pi`). The + read-only mount (D11) stays for file-shaped config; injection covers what a + mount cannot: swapping the live tool set and system prompt without an + agent restart (see T6). +- The agent binary and base image ride **versioned OCI pulls**, applied via + the throwaway-container restart: stop, restart on the new image, transcript + replayed. + +### Auth + the network door + +The Server's network door carries the v0.5 Server-tier design forward +(`../compass-0.5-server/design.md`), re-expressed on the Go stack: + +- **TLS listener, operator-provisioned certs.** The network door is TLS-only + (`--listen` + PEM cert/key paths); a bearer token over cleartext is + credential disclosure, so there is no plaintext network listener. The Go + serve loop already carries the config seam: "TLSConfig carries + operator-provisioned PEM paths for the authenticated TCP door … nil on the + socket-only shipped path" (`go/server/serve.go:34-36`). The + local Unix-socket door (0600, owner-only) remains the local trust boundary + (`serve.go:55-62`). +- **Client auth is a per-user bearer token** resolved to an account: + 32 random bytes, base64url, shown once; the server stores only the SHA-256 + hash (`proto/compass/v1/compass.proto:47-49`). Token hashes are + rows in Postgres, so tokens survive restarts and a future second Server + process shares them. `IssueToken` is admin-gated + (`compass.proto:45-46`: "only an admin may mint a token for an account; a + non-admin caller gets permission_denied"); the bootstrap admin token is + issued out-of-band at first start (`compass.proto:50-51`) and written 0600 + under the server state dir, never logged. +- **Runner auth is one provisioned token per Runner**, enrolled once — + deliberately not per-agent tokens, since the Runner, not the Server, is the + trust boundary for the containers it hosts (`../compass-0.5/design.md:407-413`: + "One durable token per Runner, enrolled once, is the model"). The mechanism + is the frozen OQ7 ruling + (`../../platform/go-toolchain-default.md:1410-1423`): a **dedicated + Runner-subject mint path** (not the Client-door `IssueToken`), reusing the + hash-only token store under a **distinct subject-prefix keyspace**; the + credential stored `0600` on the Runner host; delivery is an **operator + provisioning step, not an automated RPC**. Enforcement is at the auth + interceptor: it resolves the hash to the typed `Subject` (T1, + `ResolveTokenHash → Subject{Kind, ID}`) and **rejects on `Kind` before + projecting to an identity** — a `Kind == Runner` subject is + `CodeUnauthenticated` on `CompassService`/`CommsService`, a `Kind == Account` + subject is rejected on `RunnerService` — so the kind is never discarded into + a bare `AccountID` that would make the two token classes indistinguishable + (see T4). Both cross-door rejections are mandatory tests. +- **Transport-mode selection stays at client construction**: local vs. remote + is only which factory the caller invokes; no mode enum, no + local-assumption leaks above the transport seam + (`../compass-tauri-shell.md:107-121`, `../compass-0.5-server/design.md`, F3). + +### MVP scope + +Carried from v0.5, unchanged: + +- **Multi-agent orchestration is IN** (`../compass-0.5/design.md:257-286`, + D4): a supervisor agent account coordinating worker agent accounts over + channels, with the Bridge board as the Server-side projection of workstream + state. MVP depth per D13 (`../compass-0.5/design.md:500-526`): task + assignment ships; conflict map and auto-assignment layer on later. A single + agent flowing end to end through the tiers is the first build increment, + not the MVP ceiling. +- **Browser-first; Tauri deferred** (`../compass-0.5/design.md:330-344`, D7). + The Runner binary and its Server connection are still built. +- **Warden deferred** (`../compass-0.5/design.md:346-358`, D8); the per-agent + container remains the structural sandbox in the interim. + +### Transport: gRPC everywhere + +One transport technology on every network hop, carrying v0.5 D10 forward +(`../compass-0.5/design.md:401-426`); the container-boundary hop is the +stdio pipe pair the same events ride before they reach the network: + +- **Client↔Server**: `compass.v1` over Connect — native gRPC (HTTP/2), + gRPC-Web, and Connect off one handler (`go/server/serve.go:3-12`). + The browser client forces gRPC-Web on this hop regardless of any other + transport choice. +- **Runner↔Server**: gRPC, authenticated by the per-Runner token — an + outbound connection from the Runner (NAT-friendly, like a CI runner). The + wire shape is the frozen internal `RunnerService` + (`../../platform/go-toolchain-default.md:931-937`): container lifecycle + commands **and** Server→Runner config-version signals ride the `Sessions` + bidi stream (the only Server→Runner path); agent events ride + `PublishEvents` (Runner→Server only) (see T4). +- **Container↔Runner**: the first-party agent's newline-framed `compass.v1` + stream over the built streaming-exec stdio pipes + (`go/internal/runtime/podman.go:289-296`); the Runner relays + it up the `RunnerService` stream to the Server, which fans it out to + Clients (`SubscribeEvents`/`SubscribeComms`). + +A single transport keeps one contract-generation + CI-drift discipline +(`../compass-0.5/design.md:423-425`). Whether a broker ever augments the +Runner↔Server hop is resolved below (*Resolved decisions*): not for the MVP. + +## Alternatives considered + +### NATS as a transport (Server-internal, Runner↔Server, Client↔Server) + +NATS/JetStream was evaluated at each of the three places it could sit. Two of +the three are settled by this record; the third is resolved in *Resolved +decisions* (stay gRPC for the MVP). + +**Server-internal broker: rejected (settled).** A broker the Server publishes +to and subscribes from inside its own process boundary is a self-loop: it +adds a second durable copy of data Postgres already holds authoritatively, +plus an operational dependency every self-hoster must run, and buys nothing +the in-memory bus does not already provide. JetStream is a log, not a query +store — a comms product needs indexed, transactional queries (message pages, +full-text search scoped to a caller's visible set, account/ownership joins), +which is what Postgres is for; once Postgres holds the message durably, a +broker's copy is redundant and non-authoritative. The live-fan-out role is +already built and proven in-process (`go/events/events.go:1-13`), +with bounded replay and restart-resync semantics that match the wire +contract exactly (`comms.proto:300-324`). This supersedes the v0.5 D1/D12 +internal-broker framing by citation (see *Superseded decisions*). + +**Client↔Server: rejected (settled).** The MVP Client is a browser +(`../compass-0.5/design.md:330-336`, D7), and a browser cannot speak native +NATS — the hop is gRPC-Web/Connect over fetch no matter what +(`go/server/serve.go:3-12`). A NATS Client hop would therefore +add a second wire technology *without removing the first*. Clients stay +gRPC unconditionally. + +**Runner↔Server event transport: a real trade, deferred post-MVP (the Open +Question).** Runners are the one place a broker could earn its keep: + +*For NATS here:* + +- **Durable reconnect-replay for free.** JetStream durable consumers resume + at the last-acked sequence, so a flaky, remote Runner that comes and goes + ("like a CI runner", `../compass-0.5/design.md:89-95`) reconnects without + hand-rolled cursor bookkeeping. (Weakened by the frozen OQ6 ruling: the + link stream is Runner-sequenced with reattach reconciliation + (`../../platform/go-toolchain-default.md:1388-1396`), so the Server tracks + per-Runner link cursors anyway — the broker would remove code that must + exist regardless.) +- **Decoupled subject addressing.** Runners and Server address subjects, not + live connections — no N-connection stream matrix to manage as Runner count + grows. +- **Multi-consumer fan-out** of one event stream (e.g. a second Server + instance or an audit tap reading the same Runner events). +- **WAN backpressure** handled by the broker rather than hand-rolled on a + gRPC stream. + +*Against NATS here:* + +- **A second wire technology** with a second versioning/contract story + alongside the proto/gRPC discipline — today one schema, one codegen, one + drift gate covers every hop (`go/moon.yml:39-42`). +- **Mandatory infrastructure for every self-hoster.** Self-hostable is a hard + product constraint (`../compass-0.5/design.md:489-494`); a broker in the + Runner path puts NATS in every deployment, not just large ones. +- **A second auth story.** NATS auth (nkeys/JWT) must be mapped onto Compass + accounts, versus the one-bearer-token-per-Runner model already decided + (`../compass-0.5/design.md:407-413`). +- **It cannot unify the stack.** The Client hop is gRPC-Web regardless (see + above), so NATS would only ever be an *additional* transport, never a + replacement. + +*The cheaper multi-Server option first: Postgres LISTEN/NOTIFY.* If the +trigger that would justify a broker is a **second Server instance** (each +needing the write-through fanned onto its local bus), the store of record +already ships pub/sub: the committing Server `NOTIFY`s on commit and every +Server's listener republishes onto its local in-memory bus — zero new +infrastructure, one connection per Server, and consistent with this record's +thesis that no second system should do what Postgres already covers. +(Logical decoding is the heavier variant of the same idea.) NATS should be +weighed only after LISTEN/NOTIFY is shown insufficient (payload limits, +cross-DC fan-out, Runner-side consumption). + +*Recommendation:* **gRPC everywhere for the MVP.** The against-column costs +are paid by every deployment immediately; the for-column benefits matter only +past a concrete scale trigger — more than one Server instance, or a Runner +count high enough that per-connection cursor bookkeeping measurably hurts — +and the first of those has the LISTEN/NOTIFY answer above before it has a +broker. Committing to NATS now would also **reopen the frozen platform +record**: SEA-1243 T8 fixes the gRPC `RunnerService` shape ("This shape is +fixed here", `../../platform/go-toolchain-default.md:935-937`), so a broker at +this hop is a supersession of that record, not a v0.6-local choice. Until a +trigger fires, the narrow event-fan-out seam at the Runner↔Server hop (the +`PublishEvents` consumption interface, task T4) keeps the option open: a +JetStream-backed implementation can slot in behind it later without +re-architecting storage, contract, or auth. The decision itself is recorded +in *Resolved decisions* (stay gRPC for the MVP; re-evaluate at a trigger). + +## Global Constraints + +Every task below inherits these; task briefs do not restate them. + +- **Languages: Go on the backend tiers, TypeScript on the agent + client.** + The Server and Runner are the Go module `go/**` (module + `github.com/sealedsecurity/compass/go`), gated by the full Go battery + `moon run compass-go:ci` — gofmt, go vet, golangci-lint (exhaustiveness + on), `-race` tests, build, govulncheck, go-licenses + (`go/moon.yml:141-147`). The in-container agent is + **TypeScript on Bun** — the OMP SDK's language (`@oh-my-pi/pi-agent-core`, + `engines.bun >=1.3.14`, `packages/agent/package.json`) — joining the + already-TS SolidJS Client; it lives at `packages/compass-agent` + mirroring the existing `packages/compass-client` TS layout. This does not + reopen SEA-1243's Rust→Go ruling for Server/Runner + (`../../platform/go-toolchain-default.md:16-17`): the agent tier is TS by + necessity of the SDK — a **first-party in-container agent tier** SEA-1243's + boundary table never modeled (it placed OMP outside the boundary as an + external Rust peer, `:88` — the row this record moots, see *Superseded + decisions* #3). +- **Self-hostable is a hard product constraint.** No mandatory hosted-only or + copyleft-encumbered dependency on the default self-host path; the blob-store + self-hosted default is SeaweedFS, Apache-2.0 + (`../compass-0.5/design.md:479-494`). +- **Store of record = Postgres.** Transcript bodies = S3-compatible object + storage behind the blob seam. No component may treat a message bus as the + store of record; the in-memory bus ring is a cache, never a store. +- **Contract discipline.** `compass.v1` protos + (`proto/compass/v1/`) are generated and CI drift-gated + (`go/moon.yml:34-42`); a contract change means edit schema → + regenerate → commit output. Generated clients are the only sanctioned door + (`proto/compass/v1/compass.proto:1-4`). +- **Caller identity is connection-bound**, never a request field + (`proto/compass/v1/comms.proto:31-37`); every RPC is + authorized server-side against the authenticated account's visible set. +- **No plaintext network listener.** The network door is TLS + bearer token; + the 0600 Unix socket remains the local trust boundary + (`go/server/serve.go:34-39,55-62`). +- **Rootless podman, no rootful fallback**, for every container the Runner + starts (`go/internal/runtime/podman.go:24-25`). +- **First-party agent, `compass.v1`-native.** The in-container agent is the + first-party OMP-SDK program; it emits `compass.v1` directly. No ACP, no + BYOA machinery in the MVP (supersedes + `../../agents/sea-1023-acp-session.md:19` — see *Agent runtime*). +- **Frozen-platform conformance at the Runner seam.** The Server↔Runner wire + shape, sequencing, failure matrix, and token model are the frozen platform + record's rulings (`../../platform/go-toolchain-default.md:931-937,1378-1423`); + tasks conform to them and never re-decide them. +- **Frozen-record convention.** This record freezes on merge; later changes + supersede by citation, never rewrite + (`../compass-0.5/design.md:10-12`). +- **markdownlint-clean** under the repo config (`.markdownlint.json` / + `.markdownlint-cli2.jsonc`). + +## Plan + +Sequencing principle: **the Postgres store lands early** — it is the +durability story (the Server on `main` is fully ephemeral, see *Problem / +Intent*), and every later tier (Runner enrollment, tokens, transcripts, +config) writes into it. The comms vertical comes next so the spine is real +end to end on one machine; the Runner split, the UI pivot, and orchestration +layer on top. Each task carries its own test cycle and lands as its own PR +(or short PR stack). + +### T1 — Postgres store of record + +Stand up the Server's Postgres layer and put accounts, channel groups, +channels, agent workspaces, messages, and token hashes in it from day one. +Schema migrations are versioned and applied at Server start (embedded +migration files; refusing to serve on a failed migration). Search lands here +as Postgres full-text over message text blocks — the audit/search property is +served from the store of record (`comms.proto:90-93`), not a separate engine. + +`Interfaces:` + +- Package `go/internal/store`, consumed by the comms service and + the auth layer: + - `func Open(ctx context.Context, dsn string) (*Store, error)` — connects + (pgx pool), runs migrations, verifies schema version. + - `func (s *Store) Close()` + - Accounts: `CreateUser(ctx, u UserAccount) (Account, error)`, + `CreateAgent(ctx, a AgentAccount) (Account, error)`, + `GetAccount(ctx, id AccountID) (Account, error)`, + `ListAccounts(ctx, visibleTo AccountID) ([]Account, error)`. + - Channels/groups/workspaces: `CreateChannelGroup`, `CreateChannel`, + `UpdateChannelMembers` (RT-1: add/remove members + flip the per-member + `subscribed` flag — the one membership-mutation method behind the RT-1 RPC), + `ListChannelGroups`, `ListChannels`, `OpenAgentWorkspace` — same + `(ctx, …, visibleTo/actor AccountID)` shape, mirroring the post-fork-f + `CommsService` RPC set (`comms.proto:38-100`). `CreateAgent` sets the agent's + `home_channel_id` (RT-2) at creation. There is no `SetWorkspaceParticipants` + — `participant_user_ids` and the share/unshare RPCs are removed under + superseded decision 4, so the observation pane has no per-workspace + participant list to set; channel membership (above) is the one ACL. + - Messages: `AppendMessage(ctx, m Message) (Message, error)` (assigns the + row id + timestamp), `UpdateMessageBlocks(ctx, id MessageID, blocks + []MessageBlock) error` (streaming-turn updates), + `ListMessages(ctx, container ContainerRef, page Page) ([]Message, error)` + (newest-first, clamped page size), + `SearchMessages(ctx, actor AccountID, scope SearchScope, query string, page Page) + ([]Message, error)` (visibility-scoped full text; `scope` optionally + narrows to one channel, mirroring `SearchMessagesRequest`'s scope field, + else the actor's whole visible set). + (`ContainerRef` and the `scope` narrowing are **channel-only** — OQ-C + resolved (Matt): the workspace is not a message container and + `workspace_id` is dropped from the container + read-request scope under + superseded decision 4; see *Resolved shape questions*.) + - Tokens (subject-typed, so a Runner subject and an account subject share + the store but never collide — the OQ7 prefix-separation T4 depends on): + `PutTokenHash(ctx, hash [32]byte, subj Subject) error` where + `Subject{ Kind SubjectKind; ID string }` and + `SubjectKind ∈ {Account, Runner}`; + `ResolveTokenHash(ctx, hash [32]byte) (Subject, error)` (returns the + subject *with its kind*, so a door can reject a cross-kind token — + a Runner token on `CompassService`/`CommsService`, an account token on + `RunnerService`); `RevokeToken(ctx, hash [32]byte) error`. +- Consumes: a Postgres DSN (flag/env on `compass-server`); the domain types + mirroring the `compass.v1` messages. +- Produces: the durable substrate every later task writes into. + +Test cycle: store integration tests against a real Postgres (testcontainer or +CI service); red-first per `rule://red-green-testing`; restart test — write, +reopen the store, read back identical state. `moon run compass-go:ci` green. + +### T2 — Comms service on the store + write-through fan-out + +Implement the `CommsService` handler (the generated +`compassv1connect.CommsServiceHandler`, +`go/gen/compass/v1/compassv1connect/comms.connect.go:393`) over +the T1 store, with the D9 authorization model enforced on every RPC, and wire +`SubscribeComms` to a **second bus instance** for the comms payload type: +every mutation writes Postgres first, then publishes the corresponding event +(`MessagePosted`/`MessageUpdated`/`ChannelChanged`/…, +`comms.proto:326-356`). The built instance carries +`busPayload = *compassv1.SubscribeEventsResponse` +(`go/server/service.go:33-37`, constructed at +`server/serve.go:140`); the comms stream needs +`events.Bus[*compassv1.SubscribeCommsResponse]` — a different generic +instantiation, so a **separate instance with its own seq space and its own +per-boot `instance_epoch`** (each `NewBus` mints a fresh epoch, +`events.go:151-152`). The shared thing is the implementation, not the +instance: the two streams' seq/epoch spaces are independent. A cursor the +ring cannot serve, or a stale `instance_epoch`, yields `CommsResyncRequired` +and the client re-snapshots from Postgres via the read RPCs, deduping by +message id (the resync protocol under *State + storage*). This task also +lands the **additive `CreateChannel` RPC** (see *Ratified additive contract +changes*) via the gen/drift cycle — without it the vertical cannot create a +channel to post into (`comms.proto:38-100` has no `CreateChannel`). + +`Interfaces:` + +- `func NewComms(store *store.Store, bus *events.Bus[*compassv1.SubscribeCommsResponse]) *Comms` + implementing `compassv1connect.CommsServiceHandler`; every method reads the + authenticated `AccountID` from the request context (set by the T3 + interceptor; the local-socket door attributes the bootstrap admin). +- Contract delta: `rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse)` + on `CommsService` — additive, buf-breaking-safe; schema → `moon run + compass-go:gen` → commit, in this task's PR. +- Serve-loop wiring: construct the second bus and mount the comms handler + beside the existing `NewCompassServiceHandler` mount + (`go/server/serve.go:144-153`). +- Consumes: T1 store; the generic `events` package. +- Produces: the full comms vertical — create accounts/channels, post, list, + search, subscribe — durable across restart. + +Test cycle: handler-level BDD tests (post → subscriber sees `MessagePosted`; +restart → fresh `instance_epoch` → `CommsResyncRequired` → re-snapshot via +`ListMessages` returns the message exactly once, deduped by id; +**consistent snapshot: writes concurrent with a paginated `snapshot_seq` +re-snapshot land wholly after the boundary — no row split across a page, none +lost**; empty-store snapshot bootstraps from the subscribe `snapshot_seq`, not +a first ring event; commit-then-crash-before-publish surfaces the row after +resync; authorization rejections for non-visible channels; **membership tiers: +join grants read but no push; a subscribe toggle flips the per-member boolean; +reserved pings (`@agents`/`@users`/`@everyone`) resolve to the correct member +set**); race-detector lane +on the write-through path. + +### T3 — The network door: TLS listener + bearer-token auth + +Add the authenticated TCP door to the serve loop: TLS from the +operator-provisioned PEM paths (`TLSConfig`, +`go/server/serve.go:34-39`), an auth interceptor resolving +`authorization: Bearer ` through `store.ResolveTokenHash` to a typed +`Subject` — rejecting `Kind == Runner` before projecting to the `AccountID` +injected into the request context (the cross-door rule under *Auth + the +network door*), `IssueToken` implemented +admin-gated (`compass.proto:45-52`), and the first-start bootstrap: create +the bootstrap admin, issue its token, write it 0600 under the server state +dir — never to stdout or a log. The Unix-socket door stays token-free (the +0600 socket is the local credential) and attributes the bootstrap admin. + +`Interfaces:` + +- `compass-server` flags: `--listen `, `--tls-cert `, + `--tls-key `, `--db `; `--listen` without both TLS flags is a + startup error. +- `func authInterceptor(store *store.Store) connect.UnaryInterceptorFunc` (+ + the streaming variant) — unknown/absent token ⇒ `CodeUnauthenticated`; a + `Kind == Runner` subject ⇒ `CodeUnauthenticated` on the client doors (never + projected to an `AccountID`). +- `func AccountFromContext(ctx context.Context) (AccountID, bool)` — the one + way handlers read the caller. +- Consumes: T1 token accessors; the existing serve loop. +- Produces: the multi-user network door both services are served on. + +Test cycle: integration test dialing the TLS door with a minted token (rcgen +-equivalent self-signed pair for tests); wrong/absent token rejected; +a `Kind == Runner` token rejected on the client doors (the mandatory cross-door +test); token survives a Server restart (Postgres-backed). + +### T4 — Runner binary + the frozen `RunnerService` seam + +Split container hosting onto the Runner: a new `cmd/compass-runner` binary +wrapping the existing `internal/runtime` package (`ContainerRuntime` / +`PodmanCLI` / `AgentRuntime`, +`go/internal/runtime/podman.go:271-324`, +`internal/runtime/agent.go:1-11`), connecting **out** to the Server over gRPC +with its per-Runner token. **The seam is *specified and frozen* by the +platform record and *built* by its T8/T9** — SEA-1243's T8/T9 realize the +Server↔Runner split (`../../platform/go-toolchain-default.md:363-365`), which +are unbuilt tasks in that record's plan, not code in the tree today (no +`runner.proto`, no `RunnerService` stubs, no `AGENT_SESSION_STATE_DISCONNECTED` +enum yet). So v0.6-T4 **consumes that frozen shape and conforms to its +rulings** (it re-decides nothing) but is **sequencing-gated on SEA-1243 T8/T9 +landing `proto/compass/v1/runner.proto` + the internal stubs + the +`DISCONNECTED` enum addition first**, or co-sequencing with them. The +`internal/runtime` container layer it wraps *is* built (`AgentRuntime`, +`PodmanCLI.ExecStreaming` — verified); the `RunnerService` wire is not: + +- **Wire shape (frozen):** the internal `RunnerService` is three RPCs — + `Enroll` (unary, at connect), a `Sessions` **bidi stream** (Server→Runner + session commands / Runner→Server results, correlated by request id), and a + `PublishEvents` **client-stream** (Runner→Server agent events) — "This + shape is fixed here" (`../../platform/go-toolchain-default.md:931-937`). +- **Internal, never public:** `RunnerService` is an internal contract between + the two binaries, "generated only into the internal Go consumers, never the + module-root exported `gen/` nor the public TS client", enforced by the + SEA-1267 drift fence (`go-toolchain-default.md:330-338`). It does not join + the public `compass.v1` client surface. +- **Token model (frozen OQ7):** a dedicated Runner-subject mint path — not + `IssueToken` — under a distinct subject-prefix keyspace in the T3 store; + credential stored `0600`; delivery to the Runner host is an operator + provisioning step, not an automated RPC; a Runner token is + `CodeUnauthenticated` on `CompassService`/`CommsService` and an account + token is rejected on `RunnerService` + (`go-toolchain-default.md:1410-1423`). +- **Failure matrix (frozen OQ6):** a Runner disconnect moves its sessions to + `AGENT_SESSION_STATE_DISCONNECTED` (a new, backward-compatible enum value), + not `Errored`; a **bounded reattach window** governs recovery — reattach + within it resumes, expiry falls to `Errored`; relay-`Start` carries a + **request id** so a timeout-retry is idempotent (no duplicate container); + duplicate enrollment re-attaches the same Runner; the Runner is + authoritative for live session truth and the Server registry reconciles to + it on reattach; the link stream is Runner-sequenced + (`go-toolchain-default.md:1378-1396`). + +What is product-new in this task: the `RunnerHub` consumption side +(write-through of relayed events into store + bus) and the **additive +`ProvisionAgentWorkspace` RPC** (see *Ratified additive contract changes*) — +today no RPC launches a container (`StartAgentSession` assumes one exists, +`compass.proto:198-199`). + +`Interfaces:` + +- Consumed, not defined here: `proto/compass/v1/runner.proto` — the internal + `RunnerService` (`Enroll` + `Sessions` bidi + `PublishEvents` + client-stream), generated internal-only per the gen-fence + (`go-toolchain-default.md:330-338`); the Runner-side outbound client + (`func Dial(ctx, serverAddr string, token string) (*ServerLink, error)`, + attach loop, command dispatcher) per the platform record's T8 + (`go-toolchain-default.md:939-943`). +- Contract delta (public, additive): `rpc ProvisionAgentWorkspace(ProvisionAgentWorkspaceRequest) returns (ProvisionAgentWorkspaceResponse)` + on `CompassService` — agent ref + repo/workstream spec in, + `container_name` out; routes Client → Server → RunnerHub → Runner → + `AgentRuntime` façade (`internal/runtime/agent.go:1-5`); provision and + start stay separate RPCs. +- Runner side: `func Run(ctx context.Context, cfg RunnerConfig) error` where + `RunnerConfig{ServerAddr, Token, Engine runtime.ContainerRuntime}`. +- Server side: `type RunnerHub` — enrollment registry, command router keyed + by the session's owning Runner, and the event-fan-out seam: + `func (h *RunnerHub) Deliver(ctx context.Context, ev RunnerEvent) error` + is the sole entry point Runner events take into the Server — **fed by the + `PublishEvents` stream** — so a future brokered transport replaces the + stream feeding `Deliver`, nothing else. +- Consumes: T3 auth (plus the OQ7 Runner-subject mint), T1 store, + `internal/runtime`, the frozen platform-record stubs. +- Produces: containers hosted on any enrolled machine; the Server free of + container-engine code. + +Test cycle: end-to-end integration — provision a container through +`ProvisionAgentWorkspace`, observe lifecycle events on `SubscribeEvents` +(podman-gated skip, mirroring the runtime package's existing lifecycle +tests). The OQ6 matrix is pinned, not hand-waved: Runner disconnect ⇒ +sessions `DISCONNECTED`; reattach within the window resumes; window expiry ⇒ +`Errored`; a relayed `Start` retried after a timeout creates no duplicate +container (request-id idempotency); duplicate enrollment re-attaches the +same Runner; `GetAgentStatus` reconciles to Runner truth on reattach. OQ7's +cross-door tests: a Runner token rejected on +`CompassService`/`CommsService`, an account token rejected on +`RunnerService`. + +### T5 — First-party agent over the Runner + transcripts in the store + +Build and wire the first-party agent end to end: a new TS/Bun package +`packages/compass-agent` (mirroring the `packages/compass-client` +TS layout) built on `@oh-my-pi/pi-agent-core` (MIT, +`packages/agent/package.json`), composing tools from +`@oh-my-pi/pi-coding-agent`. The agent subscribes to its own SDK event +stream and maps it to `compass.v1` payloads in-process — the mapping is the +agent's own testable surface; there is no Runner-side translator. The Runner +starts the agent in its container over the built streaming exec +(`PodmanCLI.ExecStreaming`, +`go/internal/runtime/podman.go:417-422`), speaks the +newline-framed `compass.v1` stream on its stdio pipes, and relays events up +the `PublishEvents` stream (T4, Runner-sequenced per OQ6). The Server +write-throughs per the **conversation-vs-trace split** (superseded decision 4): +the agent's durable **conversation** — a `text` reply or an `ask` — commits to +comms `Message`/`MessageBlock` rows (T1) + `SubscribeComms` +(`MessagePosted`/`MessageUpdated`); the **execution trace** (assistant/thought +chunks, tool calls, plans, diffs) is OMP-native session data, relayed verbatim +on the **dedicated session-tail stream** for the observation pane, with its +`AgentSessionState` extracted onto `SubscribeEvents` for lifecycle/board. +Durability splits by surface: +the conversation commits to Postgres; the trace's bodies append to the blob store +behind the blob seam. The **S3 session log** is the complete restart-replay +source — trace frames, the durable conversation, and inbound `AgentControl` +(`prompt`/`steer`/`deliver`/`ask_answer`), one ordered append log in the agent's emission +order (see *State + storage*, Replay completeness) — so on (re)start the Server +streams a complete transcript into the fresh container (D6). Session lifecycle +RPCs (`StartAgentSession`/`Stop`/`Reload`/`Status`, +`compass.proto:25-43`) route Client → Server → RunnerHub → Runner; the +frozen `StartAgentSession` comment's "as an ACP session" +(`compass.proto:195-196`) is superseded as doc-intent — same RPC, it now +starts the first-party agent process. + +`Interfaces:` + +- Agent package: `class CompassAgent` wrapping `Agent` from + `@oh-my-pi/pi-agent-core` with + `constructor(opts: { stdin: ReadableStream, stdout: WritableStream, workspace: string })` + and `run(): Promise`. +- **Output frame envelope (stdout).** The agent emits two classes of payload, + routed to two surfaces with two owners. The durable **conversation** (an agent + `text` reply or an `ask`) commits to comms `Message`/`MessageBlock` rows → + `SubscribeComms`; it is Compass-owned and typed. The **execution trace** + (assistant/thought chunks, tool calls, plans, diffs) is **OMP-native session + data**, relayed **verbatim** to a dedicated session-tail stream (below) and + rendered by OMP's own renderer — Compass does not re-type it. stdout therefore + carries newline-delimited protojson frames of an **additive internal + `AgentFrame`** message — a discriminated `oneof frame` with three variants: + **conversation** — `MessagePosted conversation_posted` / `MessageUpdated + conversation_updated` (an agent `text` reply or an `ask`, streamed as block + appends while composing, reusing the frozen payloads without redefining them); + **session** — `SessionFrame session` (an opaque envelope carrying one + OMP-native session event as bytes/JSON, plus the `AgentSessionState` the board + needs); and **delivery ack** — `DeliveryAck delivery_ack` (RT-3: the agent's + receipt that an inbound `AgentControl.deliver` reached it, carrying the acked + message id so the Server advances the session's delivery cursor). The reader + classifies each line by the set `oneof` field; an unset or + unrecognized field **is** the "unknown frame" the relay logs + counts. The + Runner write-throughs each to the surface that owns it — conversation → + comms `Message` rows + `SubscribeComms`; session frames → the dedicated + **session-tail stream** (opaque OMP data) for the observation pane, plus the + extracted `AgentSessionStatus` → `SubscribeEvents` for lifecycle/board; the + delivery ack → the Server's delivery-cursor bookkeeping (not a client surface); + none is a comms `Message` (superseded decision 4). All frames, plus inbound + controls, also append to the ordered S3 session log for restart replay (see + *State + storage*). Diffs and plans are surfaced as PRs (link-out for the MVP); + they render live in the observation pane as part of the OMP session data, but + their durable home is the PR, not a comms block. +- **Control frame envelope (stdin) + replay barrier.** stdin carries + newline-delimited protojson frames of an additive internal `AgentControl` + message — a discriminated + `oneof control { PromptControl prompt; SteerControl steer; DeliverControl deliver; AskAnswerControl ask_answer; ConfigControl config; TranscriptReplay replay; ReplayComplete replay_complete }` + (additive, internal-only, generated with the agent's proto per the gen + fan-out below; same class as T4's additive deltas). The discriminator makes + restart replay unambiguous: `TranscriptReplay` frames are applied to context + (never interpreted as live input), and the Runner **holds all live + prompt/steer controls until the agent acknowledges with `ReplayComplete`** — + so a queued prompt can never execute against partially-restored context. The + ack is idempotent and the Runner re-drives replay if it is lost (the whole + session is re-established on a fresh container, so replay is replayable). The + agent applies each via `prompt`/`steer`/`setTools`/`setSystemPrompt`. +- **Turn-end delivery: deliver → queue → coalesce → ack (RT-3, ratified).** A + plain subscribed-channel message (not an `@`-mention) is delivered to the agent + **immediately** as an `AgentControl.deliver` — distinct from `prompt` so its + turn-end-queued semantics are explicit and from `steer` which interrupts. The + CompassAgent **queues** each `deliver` while a turn is running and, at turn end, + issues the queued set as a **single** `prompt` (coalescing everything that + arrived mid-turn into one new-turn input); if idle, the turn-end boundary is + immediate. On delivery the agent emits an `AgentFrame` **ack** (below); the + **Server** tracks per-session delivery from those acks — advancing a delivery + cursor and redelivering any un-acked `deliver` from Postgres on reconnect + (crash-safe: the S3 session log appends inbound `AgentControl`, and the cursor + re-derives undelivered messages by comparing the channel against it). The + agent owns the **turn-end coalescing queue**; the Server owns the **durable + delivery cursor** — two queues, two owners, one ack that links them. The + `@`-mention `steer` remains the only mid-turn interrupt. +- **`stderr` is drained, never blocked.** `ExecStreaming` exposes a separate + `Stderr` pipe (`podman.go:289-296`); the Runner drains it to the agent's + diagnostic log continuously, so a chatty agent cannot fill the OS pipe + buffer and stall `podman exec` / the frame stream. +- **Generated types (gen fan-out).** `compass.v1` TS types for the agent are + generated into `packages/compass-agent/src/gen` via a second `out:` on + `buf.gen.yaml`, its own drift-gated tree (mirrors the Go side's own gen and + §T5's "mirror `compass-client` layout"; keeps the `@compass/client` + server-door fence intact — the agent is not a server-door client). +- Blob seam: `type BlobStore interface { Put(ctx, key string, r io.Reader) error; Get(ctx, key string) (io.ReadCloser, error) }` + with an S3-compatible implementation (SeaweedFS default self-hosted, R2 + hosted) and keys indexed from Postgres transcript-metadata rows. +- Runner side: `func (r *Runner) StartAgent(ctx context.Context, id runtime.ContainerID) (*AgentStream, error)` + — spawns the agent via `ExecStreaming`, returns the framed event/control + stream the relay loop consumes; a frame whose `oneof` variant is unset or + unrecognized is logged + counted, never silently dropped. +- Consumes: T4 stream, T1/T2 write path, the OMP SDK. +- Produces: a live agent whose **conversation** appears in its channel and whose + **execution trace** renders in its `AgentWorkspace` observation pane, a + durable transcript (S3), and restart-with-context on any Runner. + +Test cycle: agent-side unit tests (Bun) — SDK event fixtures map to exact +`AgentFrame` frames, exhaustive over emitted `oneof` variants; the split is +asserted directly: a **conversation** frame (agent `text`/`ask`) routes to a +comms `Message` write and **not** to the session stream, and a **session** frame +(opaque OMP event + `AgentSessionState`) routes to the dedicated session-tail +stream + the S3 session (and its extracted `AgentSessionStatus` to +`SubscribeEvents`) and **not** to a comms `Message`; a frame with an +unset/unrecognized variant is counted as unknown, never dropped; control-frame +decode covers each `AgentControl` variant, and a restart `TranscriptReplay` is +applied to context (asserted **not** treated as live input) and ordered before +any live prompt/steer. Runner-side: `stderr` drain +under a deliberately chatty agent does not stall the frame stream. **Delivery +model (the central mid-turn-vs-turn-end distinction): a plain message from a +subscribed channel arriving mid-turn is held and delivered at the agent's turn +end (not interjected); an `@`-mention-borne steer arriving mid-turn interjects +immediately; an agent always-subscribed to its own channel receives its own +channel's messages at turn end; a joined-not-subscribed agent receives no plain +push; an agent→user `@`-mention is a notification, not a steer.** Red-first +integration driving the real `compass-agent` binary in a container (the +precedent test shape, `../../agents/sea-1023-acp-session.md:96`); stop → +restart on a second Runner → context intact. + +### T6 — Config distribution + +Implement D11 on the T4 stream, plus the programmatic-injection path the +first-party agent enables: the Server stores versioned, content-addressed +per-agent config bundles (Postgres metadata + blob bodies); "config version N +for agent X" signals ride the Runner stream; the Runner pulls and (a) +materializes file-shaped config as a read-only bind mount +(`AgentSpec.Mounts`, `go/internal/runtime/agent.go:41-42`), +atomically swapped, and (b) injects skills/tool-surface/system-prompt changes +as **structured control frames into the running agent's stdio stream**, +where the agent applies them via the SDK's +`setTools`/`setSystemPrompt`/`setModel` surface +(`packages/agent/README.md`, `github.com/can1357/oh-my-pi`) — no agent +restart for a tool-set or prompt change. Secrets in config bundles observe +the D14 boundary (encrypted at rest, per-agent scoped, redacted from +transcripts). + +`Interfaces:` + +- `func (s *Store) PutConfigBundle(ctx, agent AccountID, b Bundle) (Version, error)`, + `GetConfigBundle(ctx, agent AccountID, v Version) (Bundle, error)`. +- Runner: `func (r *Runner) SyncConfig(ctx, agent AccountID, v Version) error` + — pull, verify content address, swap mount, emit the injection control + frame on the agent's stdin (T5's control-frame channel). +- Agent: a `config` control frame handled by `CompassAgent` — applies + `setSystemPrompt`/`setTools` and acknowledges with a status frame. +- Consumes: T4 stream, T1 store, T5 container lifecycle + control channel. +- Produces: centrally-updated agent config with no cross-host filesystem. + +Test cycle: config update reaches a running agent (integration: bump a skill +file, observe the in-container mount swap + notification); injection test — +push a tool-set change, observe the agent's next turn using the new tool +surface without a restart. + +### T7 — UI: the communication layer as the primary surface + +Repivot the SolidJS Client so the **channel is the primary human↔agent +surface** and the workspace is the **observation pane** (superseded decision 4): +channel creation + listing (`CreateChannel` — T2's additive RPC — plus +`ListChannelGroups`/`ListChannels`), live conversation rendering from +`SubscribeComms` (channel `text` + `ask`, including the resync protocol: +re-snapshot via read RPCs, dedup by message id), with async **`ask`** rendered +inline in the channel and `RespondToAsk` wired, and **steer** by `@`-mentioning +the agent in its channel (the unified injection path). The **`AgentWorkspace` +view is the observation pane**: it renders the agent's live **execution trace** +from the **dedicated OMP-native session stream** (a session-tail RPC relaying the +agent's OMP session events verbatim, T5) plus the terminal and file panes — +observation-only, with a **stop** control and no message-composer. For the MVP +the trace pane **reuses OMP's own renderer** over that native stream rather than +a bespoke renderer (an explicit MVP lever — it lets T7 focus on the +channel/message/thread surface); a first-party trace renderer is a later +increment. Diffs/plans link out to their PRs. **Observation-pane access is a +projection of home-channel membership** (RT-2) — being in the agent's **home** +channel grants pane access, scoping `SubscribeAgentSession` the same way; there +is no separate workspace share (the `participant_user_ids` + +share/unshare model is removed, superseded decision 4). Transport factories carry +the bearer token as a connect interceptor; no local-assumption leaks +(`../compass-tauri-shell.md:119-121`). + +`Interfaces:` + +- Consume the **already-shipped** `createCommsClient` / `createCommsWebClient` + factories (`packages/compass-client/src/index.ts:101,110`) — not + new work; T7's genuinely-new deliverable is the SolidJS surface below. +- Consumes: the generated TS comms client, T2/T3 doors. +- Produces: the browser MVP surface. + +Test cycle: component tests for channel conversation rendering (`text` + async +`ask` flow, `RespondToAsk`) and for the observation pane rendering the +OMP-native session stream with a working stop control and no composer; steer via +`@`-mention reaches the running agent. E2E: two browser sessions whose users are +both members of the agent's channel, both watch the live trace in the +observation pane while the conversation advances in the channel; an `ask` posted +mid-run is answerable without the agent having blocked. + +### T8 — Supervisor + Bridge board + +Layer multi-agent orchestration on the running spine (D4/D13): a supervisor +agent account assigning tasks to worker agent accounts over channels, and the +Bridge board as a Server-side projection of agents' workstream state, +streamed over `SubscribeEvents`. MVP depth: assignment only; conflict map and +auto-assignment deferred (`../compass-0.5/design.md:507-517`). + +`Interfaces:` + +- Consumes: everything T1–T7. Board projection **reuses the frozen + `AgentSessionStatus` payload** (`compass.proto:102`) aggregated per + workstream — no new proto surface for the MVP; any later board-specific + variant is an additive `SubscribeEventsResponse.payload` oneof entry, + drift-gated, and would be listed here when scoped. +- Produces: the MVP orchestration loop — a supervisor assigns work to two + workers over channels and the board reflects their live state. + +Test cycle: integration/E2E — supervisor assigns to two workers; board +updates; all messages auditable via `SearchMessages`. + +### T9 — Living-spec reconciliation + +As increments ship, update `docs/specs/product/compass.md` to state built +behavior as Requirement/Scenario contracts and point its design-record +cross-reference at this record. No contracts for unbuilt behavior. + +`Interfaces:` consumes this record + shipped increments; produces the spec +edits, gated by the docs-system spec-impact check. **Test cycle:** this is the +one doc-only task with no runtime test cycle — its verification is the +spec-impact check green plus every new Requirement/Scenario citing a shipped +increment (no contract for unbuilt behavior). + +## Tasks + +- [ ] **T1 — Postgres store of record**: `internal/store` package, migrations, + accounts/channels/workspaces/messages/tokens/search accessors; + restart-durability integration test green. +- [ ] **T2 — Comms service on the store**: `CommsService` handler over T1 with + D9 authorization; second bus instance for the comms payload; additive + `CreateChannel` + `snapshot_seq` boundary; write-through + consistent + snapshot/resync/dedup protocol; restart test green. +- [ ] **T3 — Network door**: TLS listener + bearer-token interceptor + + `IssueToken` + bootstrap admin; tokens durable in Postgres. +- [ ] **T4 — Runner binary + frozen seam**: `cmd/compass-runner` consuming the + frozen internal `RunnerService` (Enroll + Sessions bidi + + PublishEvents); OQ6 failure matrix + OQ7 token model pinned; additive + `ProvisionAgentWorkspace`; end-to-end launch test green. +- [ ] **T5 — First-party agent over the Runner**: `packages/compass-agent` on + the OMP SDK emitting `compass.v1` natively; streaming-exec relay → + write-through; blob seam for transcript bodies; restart-with-context + test green. +- [ ] **T6 — Config distribution**: versioned bundles, Runner pull, atomic + mount swap + programmatic injection into the running agent; live-update + integration test green. +- [ ] **T7 — Comms-first UI**: comms client factories, channel surfaces + + observation pane (channel-membership access, stop control), ask flow; + E2E green. +- [ ] **T8 — Supervisor + Bridge board**: assignment over channels + board + projection; orchestration E2E green. +- [ ] **T9 — Living-spec reconciliation** as increments ship. +- [ ] This record lints clean (markdownlint) and merges as the frozen v0.6 + contract. + +## Resolved decisions + +The four questions this record originally raised were ratified by Matt at the +first freeze gate (July 2026). The **interaction-surface inversion** (superseded +decision 4) was ratified in a second pass (July 2026, this amendment): its core +plus three round-two forks (threading carrier, the trace's stream, the +observation-pane ACL) are decided below; the three shape questions (OQ-A +`MessageBlock` narrowing, OQ-B steer RPC, OQ-C `workspace_id`) are all resolved +(see *Resolved shape questions*). All decided items +are recorded here as the contract. + +- **Server↔Runner transport: stay gRPC for the MVP; re-evaluate at a concrete + trigger.** The full analysis is in *Alternatives considered*. The MVP is + gRPC on every hop; NATS is settled OUT for the Server-internal role (no + broker — Postgres is the substrate, the in-memory bus is the fan-out) and + OUT for the Client hop (browsers force gRPC-Web). The one defensible future + home is the Server↔Runner event transport, and even there v0.6 does **not** + adopt a broker now: committing to NATS at this hop would supersede the + frozen SEA-1243 T8 gRPC `RunnerService` shape ("This shape is fixed here", + `../../platform/go-toolchain-default.md:935-937`) — whose generated stubs + the Runner-side client already consumes — and re-sequence the work built on + it. **Decision:** stay gRPC for the MVP; keep the narrow event-fan-out seam + (`RunnerHub.Deliver` fed by `PublishEvents`, task T4) so a brokered + transport can slot in behind it later without re-architecting; re-evaluate + only when a concrete trigger fires — more than one Server instance with + Postgres LISTEN/NOTIFY (see *Alternatives considered*) shown insufficient, + or a Runner count at which per-connection stream management measurably + hurts. A future adoption is SEA-1243's amendment to make, not v0.6's. +- **`CreateChannel` (T2): ratified as an additive contract change.** See + *Ratified additive contract changes*. `CommsService` gains + `rpc CreateChannel(CreateChannelRequest) returns (CreateChannelResponse)` — + caller-authorized against the parent group, emitting `ChannelChanged` — + closing the gap where the event surface documents "A channel was created" + (`comms.proto:338-339`) but no RPC creates one. Additive (buf-breaking-safe); + lands in T2's implementation PR via the gen/drift cycle. +- **`ProvisionAgentWorkspace` (T4): ratified as an additive contract change.** + See *Ratified additive contract changes*. `CompassService` gains + `rpc ProvisionAgentWorkspace(ProvisionAgentWorkspaceRequest) returns (ProvisionAgentWorkspaceResponse)` + — agent ref + repo/workstream spec in, `container_name` out — routing + Client → Server → RunnerHub → Runner and driving the built lifecycle façade + (`go/internal/runtime/agent.go:1-5`). Provision and start stay + separate RPCs, matching the frozen `StartAgentSession` semantics. Additive + (buf-breaking-safe); lands in T4's implementation PR. +- **Consistent-snapshot boundary (T2): ratified — add a `snapshot_seq` token + to the read path.** The review found that the `since_seq = 0` refinement + (snapshot from the Postgres read RPCs) could not produce a *consistent* + point-in-time snapshot: paginated `ListMessages` without a boundary lets + concurrent writes move rows across page edges, and an empty ring gives no + first event to read the epoch/head-seq from. **Decision (Matt):** add an + additive `snapshot_seq` boundary — the subscribe response returns it, every + read RPC takes it, so the whole snapshot is one point-in-time view and the + live tail resumes at `snapshot_seq + 1`; message-id dedup covers the overlap. + Chosen over preserving whole-state stream snapshots (reintroduces unbounded + connect cost through the ring) and over deferring the boundary post-MVP + (ships a frozen contract with a known consistency hole). Additive fields + (buf-breaking-safe); land in T2's implementation PR. Safe pre-launch: no live + client depends on the old semantics (the Server on `main` is ephemeral). +- **Interaction surface: channel-primary, trace observation-only (superseded + decision 4): ratified, with the three round-two forks decided (below).** The + agent's primary human↔agent surface is its **channel** (a Slack/Discord-style + DM with threading); the `AgentWorkspace` is demoted to an **observation pane** + for the live execution trace plus terminal/file panes. The agent communicates + through exactly two *durable* surfaces — **channel messages** (`text` + async + **`ask`**) and **pull requests**. The execution trace (assistant/thought + chunks, tool calls, plans, diffs) is observation-only, its durable form the S3 + session, **not** comms `Message` rows. **Decision (Matt):** (a) `Ask` is + **async** — the "blocks until answered" clause (`comms.proto:271-276`) is + dropped; blocking is the agent's turn-level choice; (b) a human **steer** + (`@`-mention) and an ask-answer are one session-injection path; (c) + **owner-membership is transitive** — an agent's DMs and the channels it starts + always include its owning user(s). **Round-two forks (Matt, this amendment):** + (d) **threading has a contract carrier** — add an additive `parent_message_id` + to `Message` now (the primary surface is threaded, so the MVP contract carries + it, not a later retrofit); (e) **the trace is a dedicated OMP-native session + stream, not typed `SubscribeEvents` variants** — a new session-tail RPC relays + the agent's OMP-native session events verbatim (opaque envelope) for the + observation pane, rendered by OMP's own renderer; the three ACP-translation + variants `agent_message_chunk`/`agent_tool_call`/`agent_plan` are **dropped** + from `SubscribeEventsResponse`, which keeps only its Compass-owned projections + (`ServerStatus` liveness, `ResyncRequired` replay control, `AgentSessionStatus` + lifecycle, and the T8 board); (f) **one ACL** — observation-pane access is a + projection of **home-channel membership** (being in the agent's **home** + channel grants pane access — RT-2 ratified; the membership-mutation carrier is + `UpdateChannelMembers`, RT-1), so `AgentWorkspace.participant_user_ids` + and the `ShareAgentWorkspace`/`UnshareAgentWorkspace` RPCs + (`comms.proto:67-76,207-221`) are **removed** — no second, cross-service ACL. + Chosen over the frozen workspace-as-conversation model because it (i) unblocks + the agent (async ask), (ii) keeps the load-bearing human signal — questions, + blockers — in the searchable channel instead of buried in the trace, (iii) + keeps the durable conversation as the natural cross-time record while the work + stays in the session, (iv) removes the highest-volume data from Postgres + the + comms event stream, and (v) delegates the session-render surface to OMP over + its own stream while Compass owns only the communication surface + board. The + trace's durable form is the S3 session (D6/D12); Postgres holds only its + index/metadata. Contract impact: additive (`parent_message_id`, the session + RPC, the channel-membership carriers) **plus three pre-launch buf-breaking + removals** (the three trace variants; the share RPCs; the + `Message.container.workspace_id` narrowing, OQ-C) — safe because no live client + exists (the Server on `main` is ephemeral) and the dropped trace variants were + never published (the only production Publish sites are `ServerStatus` at + `serve.go:207` and `ResyncRequired` at `service.go:151` — both kept variants). + Lands via the gen/drift cycle in T1/T2/T5/T7. +- **Round-three amendment (channel-membership model): the five red-team forks + ratified (Matt, July 2026).** The round-three channel-membership model + + reserved pings + DM expansion surfaced five contract gaps, all now decided: + - **RT-1 — membership has an RPC carrier.** Add one additive + `rpc UpdateChannelMembers(UpdateChannelMembersRequest) returns + (UpdateChannelMembersResponse)` to `CommsService` (add/remove members + + flip the per-member `subscribed` flag), authz'd against channel visibility, + emitting `ChannelChanged` — covers join, subscribe-toggle, DM-expansion, and + share-replacement. See *Ratified additive contract changes*. Additive. + - **RT-2 — the agent's own channel is its home channel.** An `AgentAccount` + carries an additive `home_channel_id` (minted at `CreateAgent`); the + always-subscribed row, turn-end "own channel" delivery, and the + observation-pane ACL all mean the home channel, and `SubscribeAgentSession` + is scoped to **home-channel** membership. Matt's ruling: the trace carries + nothing more sensitive than the conversation the same members already read, + so it is the one shared ACL — no stricter trace-specific grant. Additive + field; no second ACL (fork f preserved). + - **RT-3 — turn-end delivery: agent-queued, coalesced, acked.** A subscribed + channel message is delivered to the agent **immediately** as an additive + `AgentControl.deliver` variant; the CompassAgent **queues** it and, at turn + end, issues the queued set as a single `prompt` (coalescing any messages that + arrived mid-turn); the agent returns an additive `AgentFrame` **ack** once + delivered, so the Server advances a per-session delivery cursor and redelivers + unacked messages on reconnect (`@`-mention `steer` stays the mid-turn + interrupt). See *Ratified additive contract changes*. Additive. + - **RT-4 — reserved-ping fan-out: accept unbounded for the MVP.** `@everyone`/ + `@agents` steering every agent member with no rate bound, permission gate, or + coalescing is **accepted for the MVP** (dogfood, trusted small membership); + abuse controls (rate limit / role-gate / coalesce) are post-MVP — a ratified + stance, not an omission. + - **RT-5 — DM→GROUP_DM expansion: add in place, history disclosed, + auto-subscribe.** Any current member may add a member to a DM/GROUP_DM (via + `UpdateChannelMembers`); the row's `ChannelKind` widens `DM`→`GROUP_DM` in + place with **no new-channel minting**; prior history **is** disclosed to the + added member (same deliberate cross-owner disclosure class the record already + ratifies); an agent added to a DM/GROUP_DM is **auto-subscribed**. A + fresh-channel Slack-style privacy model may be revisited post-MVP. + +## Resolved shape questions + +The three shape questions the interaction-surface inversion raised (superseded +decision 4) are all **resolved** — none remains open at freeze. Recorded here +with their decisions for the audit trail. + +- **OQ-A — `MessageBlock` oneof narrowing → RESOLVED** by the session-stream + decision. The trace is opaque OMP-native frames on a dedicated session stream, + not typed `compass.v1` variants and not rendered from `MessageBlock`, so the + comms `MessageBlock` oneof (`comms.proto:247-262`) drops **all** trace variants + (`thought`/`tool_call`/`plan`/`diff`), narrowing to `text` + `ask`. Nothing on + the observation pane consumes the typed variants, so none is kept "defined but + unused." Physical removal lands in T2 via the gen/drift cycle (buf-breaking, + pre-launch safe — no live client). +- **OQ-B — steer RPC shape → RESOLVED (Matt): reuse `PostMessage`.** A human + steer reuses `PostMessage` into the agent's channel with server-side + `@`-mention routing into the running session — no dedicated `SteerAgent` RPC. + A steer *is* a channel message; reuse adds no RPC, unifies with ask-answer, + and keeps the "everything is in the channel" property. See *Ratified additive + contract changes*, unified steer path + *Channel membership: join / subscribe / mention*. +- **OQ-C — `Message.container` `workspace_id` → RESOLVED (Matt): dropped.** With + the workspace no longer a message container, `workspace_id` is dropped from the + `Message.container` oneof (`comms.proto:233-236`), and the co-narrowing + `workspace_id` on `ListMessagesRequest` (`comms.proto:448-451`), + `PostMessageRequest` (`comms.proto:465-468`), and `SearchMessagesRequest.scope` + (`comms.proto:493-496`) drop with it — an agent's durable messages live in its + **channel**, so `Message.container` becomes **channel-only**. Buf-breaking, + pre-launch safe (no live client); lands **with T1** (the message-schema + migrations + the store's `ContainerRef` fix the container shape). This is a + third pre-launch buf-breaking removal alongside the trace variants + the share + RPCs. + +## Resolved round-three questions + +The round-three amendment (channel-membership model + reserved pings + DM +expansion) surfaced five contract gaps a red-team pass flagged. All five were +**ratified by Matt** (July 2026) and fold into the contract above — the full +rulings are recorded under *Resolved decisions* ("Round-three amendment"), and +each lands in its implementing task via the gen/drift cycle. Summary: + +- **RT-1 — membership-mutation RPC → RESOLVED.** One additive + `UpdateChannelMembers` on `CommsService` carries join, subscribe-toggle, + DM-expansion, and share-replacement (*Ratified additive contract changes*). +- **RT-2 — "the agent's own channel" → RESOLVED: home channel.** Additive + `home_channel_id` on `AgentAccount`; the always-subscribed row, turn-end + delivery, and the observation-pane ACL all scope to it. The trace carries + nothing more sensitive than the conversation the same members already read, so + it is the one shared ACL — no stricter trace-specific grant. +- **RT-3 — turn-end delivery carrier → RESOLVED: deliver → queue → coalesce → + ack.** A subscribed message arrives immediately as `AgentControl.deliver`; the + agent queues it and issues the coalesced set as one `prompt` at turn end, acking + via an `AgentFrame.delivery_ack` so the Server advances a delivery cursor and + redelivers unacked messages on reconnect (*Agent runtime*). +- **RT-4 — reserved-ping fan-out → RESOLVED: accept unbounded for the MVP.** Abuse + controls (rate limit / role-gate / coalesce) are post-MVP — a ratified stance. +- **RT-5 — DM→GROUP_DM expansion → RESOLVED: add in place.** Any current member + may add; `ChannelKind` widens in place with no new-channel minting; prior + history is disclosed to the added member; an added agent is auto-subscribed. diff --git a/docs/designs/product/compass-0.7-channel-workspace/design.md b/docs/designs/product/compass-0.7-channel-workspace/design.md new file mode 100644 index 00000000..d8a134dc --- /dev/null +++ b/docs/designs/product/compass-0.7-channel-workspace/design.md @@ -0,0 +1,760 @@ +# Compass v0.7 — comms-in-workspace: channel chat inside the board-primary shell + +Status: Historical + +> Internal design record — July 2026. Supersedes exactly **one decision** of +> the frozen v0.6 record (`../compass-0.6/design.md`, merged): the **UI-shell +> layout of its §T7** ("UI: the communication layer as the primary surface"). +> Unchanged — the comms contract (accounts, channels, membership, threads, +> asks, mentions), the agent-account model (`AgentAccount` as an owned subtype + +> `home_channel_id`, RT-2), and the T8 board projection +> `../compass-0.6/design.md:1585-1601`). A second, non-decision cleanup rides +> along — dropping the two vestigial `harness` fields (`AgentAccount.harness` +> and `CreateAgentRequest.harness`, T0), which merely executes v0.6's +> already-frozen no-BYOA/single-agent stance +> (`../compass-0.6/design.md:676-680`) to the wire. Shell/state lineage: +> `../compass-ade-shell/design.md` and `../compass-dock-in-sidebar/design.md` +> (both merged). This record is the contract PR #783 (branch +> `franklin-compass-channel-first-ui`) is reshaped against after this record's +> PR merges. Frozen records are superseded by citation only, never rewritten. + +## Problem / Intent + +PR #783 implemented v0.6 §T7 faithfully; 0.7 supersedes §T7's UI-shell layout +— the one 0.6 decision this record changes. §T7 is titled "UI: the +communication layer as the primary surface" and rules "the **channel is the +primary human↔agent surface** and the workspace is the **observation pane** … +observation-only, with a **stop** control and no message-composer" +(`../compass-0.6/design.md:1544,1546-1547,1557`). #783 realized that shell-level +decision concretely: the board became one half of a `Channels|Board` top-bar +swap (`apps/ui/src/App.tsx:16` — "The communication layer (channels +\+ DMs) is the primary surface"; `app.css:2648` — "the board is demoted to a +secondary projection") and the workspace's tab/split-pane/terminal machinery +was replaced by a fixed observation set (`store.ts:93` — +`export type ObservationPaneKind = "trace" | "terminal" | "file";`, doc'd "The +set is FIXED — no split-tree, no tabs", `store.ts:90-92`). + +0.7 **supersedes §T7's UI-shell layout** — the one 0.6 decision this record +changes — replacing those swap/deletion mechanics as a consequence. It has two +facets: + +1. **Board-primary shell.** The board returns as the top-level UI (topbar, + left sidebar, `view()`-routed center, right sidebar); the `Channels|Board` + swap is gone. §T7 gave *shell-level* primacy to the channel; 0.7 gives it + back to the board. §T7's channel-primacy is not discarded — it is + **re-homed one level down**: the board is the shell, and the channel is + the primary surface *once you are inside an agent workspace*. The two + compose at different levels of the hierarchy. +2. **Channel chat inside the workspace.** §T7 kept the channel and the + workspace as separate surfaces (channel = conversation, workspace = + observation-only, no composer). 0.7 merges them: the workspace's PRIMARY + pane becomes the agent's channel chat (composer + threads + ask — replacing + the pre-branch ACP pane), the execution trace/log is demoted to a + minimizable observation companion beside it, and the pre-branch + tab/split-pane/terminal machinery is restored around it. + +Standalone channels (not tied to an agent workspace) move into a collapsible +**Channels** section of the left sidebar, above a collapsible **Agent +workspaces** section. The comms *model* v0.6 froze is untouched — the branch's +comms layer (`comms.ts`, `comms-stub.ts`, `ChannelView.tsx`) carries it +faithfully and is re-homed, not reverted. What changes is where that layer +mounts: inside the board-primary shell, not instead of it. + +### Superseded decisions (by citation) + +Modeled on 0.6's own section (`../compass-0.6/design.md:60`). Exactly one: + +1. **0.6 §T7's UI-shell layout** (`../compass-0.6/design.md:1544-1567`): + "channel is the primary human↔agent surface", workspace = observation pane + with "no message-composer". Superseded by: the board-primary shell with + the channel chat AS the workspace's primary pane (composer included) and + the trace as a minimizable log companion. **Not superseded**: T7's comms + consumption contract (SubscribeComms rendering, resync/dedup, async ask + + RespondToAsk, steer via @-mention, OMP's renderer over the opaque session + stream, stop control, home-channel-membership ACL) — all carried forward + into the new mount points. + +## Approach + +### The shape, in one pass + +Restore the `origin/main` board-primary shell and workspace machinery, then +re-home the branch's comms layer inside it: + +- **Shell**: back to `origin/main:App.tsx`'s layout — topbar, left sidebar, + `view()`-routed center (`}>` over + `bridge`/`backlog`/`done`/`settings`), right sidebar, usage bar. The + `Channels|Board` top-bar swap and the full-width `ChannelSidebar` rail are + removed. One new view is added: `channel` — a standalone channel opened from + the sidebar renders `ChannelView` in the center, board chrome intact. +- **Agent workspace**: the pre-branch `AgentTab`/`SplitNode` machinery is + restored verbatim from `origin/main:store.ts` (types at lines 56-171, actions + at 548-622: `openTab`, `closeTab`, `splitActivePane`, `setFocusedPane`, + `closePane`). The only semantic change: the permanent first pane's kind is + renamed `session` → `chat`, and its body renders the agent's home DM channel + through `ChannelView` (threads + ask + composer) instead of + `AcpConversation` (`origin/main:components/AgentView.tsx:198` — `export const + AcpConversation: Component<{ agent: Agent }>`; `:318-320` — ``). +- **Log panel**: the branch's OMP-native trace (`AgentView.tsx` `FrameRow` / + `TracePane` over `SessionFrame`, `session-stub.ts:33-43`) is demoted from + primary surface to a dedicated minimizable side panel docked at the + workspace's right edge — outside the tab/split tree, with the running dot + + Stop control in its header. +- **Left sidebar**: two collapsible sections — **Channels** (standalone + channels: the non-DM group channels like announcements / coordination / + svc.* / random, plus group DMs) above **Agent workspaces** (the agent + roster/folder tree, restored from `origin/main:LeftSidebar.tsx`, which today + renders `

` + + `STUB_TREE` non-collapsibly, branch `LeftSidebar.tsx:175-177`). +- **Identity**: one account-id space with separate co-addressed types (Decision + D1 below) so a board agent always resolves its home channel off its account. + +### Decisions (ruled by Matt — recorded, not reopened) + +**D1 — Identity: separate, co-addressed UI types (one id space, composed at the +seam) — fixture conformance to 0.6's model, not a contract change.** The frozen +0.6 contract models an agent as one `AgentAccount` carrying an additive +`home_channel_id` ("the agent's named channel/DM, minted at `CreateAgent`", +`../compass-0.6/design.md:222-223,1760-1764`, RT-2 — ratified, not yet on the +wire), with its live lifecycle a projection of `AgentSessionStatus` over +`SubscribeEvents` (`../compass-0.6/design.md:1595-1596`). The UI's two disjoint +id spaces are a **fixture artifact** that predates that model: comms accounts +(`comms-stub.ts:192` `STUB_ACCOUNTS` — `acc-matt` + agent accounts +`acc-mercator/compass/cook/xenophon/franklin`) vs the board roster +(`stub-data.ts:303+` — ten `agent-*` ids: supervisor/warden/cook/livingstone/ +cousteau/ross/shackleton/erikson/drake/magellan). Only `cook` overlaps by +handle, and the store itself documents the split (`store.ts:378-379` — "the +observed agent is the channel's other party, NOT the board's `selectedAgentId`, +which is a separate `agent-*` roster concept"). Under this design the +workspace's primary pane is the agent's channel chat, so every board agent MUST +resolve a home channel — today 0 of the 10 do (the only 1:1 DMs are compass's +and franklin's, `comms-stub.ts:307-321`, and neither is a board agent; `cook` +overlaps by handle but has only a group DM). + +D1 collapses the two id spaces onto the ONE account id space (`acc-`) +but keeps **separate types** for what the Server streams separately (next +section): a durable `Account` (comms) and an ephemeral `AgentLifecycle` enum +(`SubscribeEvents`), composed at read by the store's `agentView(id)` into the +`Agent` view-model — the id is the join key, so no bridge field and no second id +space. The account arm carries handle, displayName, ownerUserId, and (fork 3) +an additive `homeChannelId` mirroring RT-2 — so the chat pane reads the home +channel O(1) off the account, not via a per-render `agentDmChannel` search. In +the fixture era T1 caches that id (equal to `agentDmChannel(...)`); when the +client consumes the real contract the field is fed by the account's +`home_channel_id` — same field, new source (the store seam; the proto landing of +`home_channel_id` is the comms-server lane, SEA-1195). Humans stay a `user`-kind +`Account`. Rationale: the fixtures conform to the contract they claim to mirror; +separate co-addressed types keep the durable/ephemeral split honest (a +created-but-unstarted agent has no lifecycle) without a bridge field the +contract never had. + +**D2 — Log pane: a dedicated minimizable side panel, not a tab-pane.** The raw +agent output (OMP-native `SessionFrame` stream, opaque to Compass — +`session-stub.ts:14-16`: "SEAM: the exact session-frame envelope is OMP's, not +Compass's contract") is a *companion* you glance at while chatting, not a +destination you navigate to. Making it a tab would hide it behind a click; +making it a split-pane leaf would let users close it irrecoverably. A fixed +side panel with a minimize toggle keeps it one keystroke away and structurally +distinct from the user-managed tab/split tree, which stays reserved for +terminals and files. + +**D3 — Ask: kept in the 1:1 agent-workspace chat; deferred only in standalone +multiplayer channels.** The workspace chat is single-player (the owner +answering their agent) — semantically the old ACP ask, so the branch's +`AskBlock` (`ChannelView.tsx:84-121`, `store.answerAsk`, `store.ts:491`) +renders live there. Standalone shared channels are multiplayer; who may answer +an ask there is an unsolved design (attribution, races, revocation), so v1 +renders text + threads only — an ask block in a standalone channel shows +read-only (options disabled, pointing at the owning workspace). + +### The three streams behind an agent, and the store seam + +D1 keeps the types separate because the Server streams three distinct things +about an agent, from two services and different stores, on different +lifecycles — and only ONE of them is a field on the agent object: + +1. **`Account`** (durable) — `CommsService.SubscribeComms`, Postgres store of + record. Identity: handle, displayName, ownerUserId, and (agent kind) the + `home_channel_id` (RT-2). Exists from `CreateAgent` onward, independent of + any session. +2. **lifecycle `state`** (ephemeral) — `CompassService.SubscribeEvents` + `AgentSessionStatus.state` (`compass.proto:126-129,135-142`), in-memory bus. + The `STARTING/READY/WORKING/STOPPED/ERRORED` enum that drives `StateDot` and + the board. Exists only while a session runs. **This is the only thing + `SubscribeEvents` contributes to the agent object.** +3. **the OMP session trace** (ephemeral, **opaque**) — the session-tail stream + of OMP-native `SessionFrame`s (`session-stub.ts:14-16`: "the exact + session-frame envelope is OMP's, not Compass's contract"). Compass does not + interpret it; it hands frames to OMP's own renderer. This is "the OMP session + streaming to the OMP UI" — and it is **already a separate type today** + (`AgentSession`, read via `store.agentSession()`, keyed by account id), never + part of the agent object. + +So there is no fat "session projection" to merge — the ephemeral arm is just a +lifecycle enum (source 2) plus an opaque trace that was never merged (source 3). +A single merged object would fuse the durable source 1 with the ephemeral source +2 and imply every agent always has a session. D1 keeps them separate: + +- **`Account`** and **`AgentLifecycle`** are distinct, both keyed by the ONE + account id (the id IS the join key — no bridge field). The store's + `agentView(id)` composes them into the `Agent` view-model at read; `lifecycle` + is optional, so a created-but-unstarted agent is representable. +- The `Agent` view-model is **assembled at the store seam — never a wire shape** + (`store.ts:10-13`: "the accessors below … swap the fixture for the generated + @compass/client — the AppStore contract is the seam"). +- Today: `agentView` reads co-addressed fixture arrays. Later: it becomes a memo + joining the `SubscribeComms` accounts with the `SubscribeEvents` lifecycle by + account id — the pure `joinAgents(accounts, lifecycles)` function beside the + store, unit-testable like `comms.ts`. The lifecycle's own wire key is + `session_id`, not the account; `session_id → account` is resolved client-side + via the `session_id ↔ container_name` binding from `StartAgentSession` + (`compass.proto:197-209`) — the one open seam, not a new proto field. + +**Dropped, no wire source (fork 2 + the v0.6 stream trim):** the old merged type +carried `harness` and a `feed: AgentEvent[]`. `harness` is gone (fork 2 — single +first-party agent). `feed` was "derived from `AgentMessageChunk`/`AgentToolCall`/ +`AgentPlan`", but v0.6 **removed those three variants from `SubscribeEvents`** +(`../compass-0.6/design.md:531-535` — "neither the native render format nor +needed" under the first-party agent), so the RightSidebar activity feed has no +real-model source and is **dropped from v0.7** entirely. `role`/`model`/`cwd` stay +UI-only roster config (a later additive `SubscribeEvents` board variant, 0.6 T8); +`terminals` is fixture-only (no terminal stream in the MVP). + +### Alternatives considered + +- **A single merged `Agent` object** (one interface fusing the account + a + materialized session projection): rejected. It implies every agent always has + a session (false for a created-but-unstarted agent), and fuses a durable + comms row with an ephemeral bus projection into one mutable shape. D1's + separate co-addressed types keep the optionality honest. +- **A2 — two UI types + a bridge field** (`Agent.accountId` pointing into a + *separate* `agent-*` roster id space): rejected by Matt. Separation is right; + the bridge field is not. A2 keeps a fixture id space the contract doesn't + have and a two-step lookup on every render path. D1 separates the types but + co-addresses them on the ONE account id — no bridge field, no second id space. +- **Log as a pane kind in the split tree** (`PaneKind = "chat" | "log" | + "terminal" | "file"`): rejected (D2). It would let the log be closed/split + like any leaf; the log is a fixed companion with different lifecycle + (minimize, never close) and different data source (opaque OMP frames vs + user-openable resources). +- **Full revert of #783 then re-add comms**: rejected. The comms layer + (`comms.ts` pure core + 774-line `comms.test.ts`, `comms-stub.ts`, + `ChannelView.tsx`) is exactly what the corrected design needs; the reshape + keeps it and moves its mount points. + +## Global Constraints + +Every task below inherits these; task briefs do not restate them. + +- **Stack: SolidJS + Vite**, UI at `apps/ui/src/`. No new + framework or state library; all cross-component state lives in the one + `AppStore` (`store.ts`) read through context (`context.ts`). +- **Walking skeleton, no daemon.** Every surface renders from in-memory + fixtures; the store accessors are the seam that later swaps to the + generated `@compass/client` (`store.ts:10-13`). No task may read a fixture + module directly from a component when a store accessor exists. +- **The comms model is 0.6's, frozen.** `comms.ts` / `comms-stub.ts` mirror + the `compass.v1` comms contract (`comms-stub.ts:10-14`); tasks re-mount + them, never re-derive or fork their shapes. The v0.6 seam annotations + (membership carrier, `parentMessageId`, channel-only container) stay. +- **Tests: `moon run compass-ui:test` = `bun test --conditions browser`** — + the browser condition is load-bearing (Bun's default `node` condition pulls + solid-js's SSR build where `createMemo` is inert; `apps/ui/moon.yml:24-31`). + Red→green per `rule://red-green-testing`: BDD/unit tests first, watch them + fail, then implement. +- **Lint/format: biome** (repo-standard for TS). Markdown records are + markdownlint-clean. +- **Stop/Send render enabled** over documented no-op RPC stubs (Matt's prior + ruling; walking-skeleton fidelity — the control's enablement mirrors the + real contract, the body is a stub until `StopAgentSession`/`PostMessage` + land). +- **Frozen-record convention**: this record freezes on merge; later changes + supersede by citation, never rewrite. + +## Plan + +Sequencing principle: **identity first** (T1 unblocks every workspace join), +then the store restoration (T2 — the machinery every surface mounts on), then +the surfaces (T3-T6), then the shell sweep + fixture/test reconciliation +(T7-T8). Each task carries its own test cycle and is a reviewable unit; +together they are the #783 reshape execution plan. + +### T1 — Agent identity: separate co-addressed types (fixture conformance to 0.6) + +Collapse the two id spaces onto one account-id space (`acc-`) and +reconcile the comms fixture onto it. **Surviving roster** = the ten board +agents (`stub-data.ts:303+`: supervisor/warden/cook/livingstone/cousteau/ +ross/shackleton/erikson/drake/magellan), re-keyed to `acc-` and carrying +an agent-kind `Account`, plus `matt` (user). The four comms-only +agent accounts — `acc-mercator`, `acc-compass`, `acc-xenophon`, `acc-franklin` +(`comms-stub.ts:200-239`; `cook` already overlaps) — do NOT survive as +separate identities; the fixture content they author is re-homed onto the +surviving roster. Concretely T1 owns: + +- **`STUB_AGENTS`** is the roster source of truth (the ten, re-keyed, each an + `Agent` = `{account, lifecycle?, role, model, cwd, terminals}`). `STUB_ACCOUNTS` + is derived — `[MATT_ACCOUNT, ...STUB_AGENTS.map((a) => a.account)]`. +- **Home DM per agent**: one `dm-` channel per board agent added to + `STUB_CHANNELS`, its id cached on `account.homeChannelId` so it resolves for + 10/10 (today 0/10, above) without a per-render `agentDmChannel` search. +- **`STUB_SESSIONS` re-keyed** onto board agents (today keyed `acc-franklin`/ + `acc-compass`, `session-stub.ts:60,97` — neither survives, so without a + re-key the T4 log panel would have nothing to show): the running trace → one + board agent, the idle trace → another. +- **`STUB_MESSAGES` authors + `STUB_CHANNELS` memberships + the group-DM + members** (`comms-stub.ts:341-461,282-329`) re-authored onto surviving ids, + so no `acc-mercator/compass/xenophon/franklin` reference dangles. +- **The dead `AcpMessage`/`AcpBlock` conversation arm** (`stub-data.ts:179-210`) + — both the `conversation` field AND its fixture data — is removed outright + here (T8 keeps only the now-orphaned type-alias deletion + grep gate). The + chat pane reads comms messages, not ACP fixtures; the branch already + stripped `conversation` rendering from the board (`RightSidebar.tsx:371-373` + — "The agent conversation moved to the channel surface"). +- **The pre-existing 1:1 DMs `dm-compass`/`dm-franklin`** + (`comms-stub.ts:307-321`, members `[matt, acc-compass]` / `[matt, + acc-franklin]`) — whose non-`matt` party does not survive — are dropped; + the surviving roster's 1:1 DMs are the new `dm-` set above. +- **Workstream `assignee`** values move to the new ids. + +The surviving-roster composition is a walking-skeleton **fixture** choice +(which demo agents populate the board), not a contract matter — surfaced here +for the record review; D1's one-id-space model is what's ruled. + +`Interfaces:` + +- Produces (in `stub-data.ts`, replacing the current `Agent` at + `stub-data.ts:221`): + + ```ts + /** Durable comms identity (SubscribeComms · Postgres). The agent-kind arm + * gains an additive homeChannelId mirroring ratified 0.6 RT-2 + * (`../compass-0.6/design.md:1760-1764`); the proto landing of + * `home_channel_id` on AgentAccount is the comms-server lane (SEA-1195). */ + export interface Account { + id: string; // account id, e.g. "acc-cook" — the one id space + handle: string; // unique, e.g. "cook" + displayName: string; + kind: "user" | "agent"; + ownerUserId?: string; // agent kind: owning user's account id + homeChannelId?: string; // agent kind: the agent's home DM (RT-2) + } // NOTE: no `harness` — dropped (fork 2) + + /** The agent's ephemeral lifecycle — SubscribeEvents.AgentSessionStatus.state + * (`compass.proto:126-129`), keyed by account id. Absent = created but no + * session has run. This is the ONLY agent-object field SubscribeEvents feeds. */ + type AgentLifecycle = AgentState; + + /** The composed roster view-model the store assembles at the seam — NEVER a + * wire shape. `account` is durable; `lifecycle` is optional (honest for an + * unstarted agent); role/model/cwd are UI-only roster config (carried later + * by an additive SubscribeEvents board variant, 0.6 T8), terminals is pure + * fixture (no terminal stream in the MVP). The opaque OMP session trace is + * NOT here — it is a separate type (`AgentSession`, session-stub.ts) read by + * account id via `store.agentSession()`, handed to OMP's own renderer. */ + export interface Agent { + account: Account; + lifecycle?: AgentLifecycle; + role: AgentRole; // UI-only roster config + model: string; // UI-only (the model the OMP SDK is set with) + cwd: string; // UI-only + terminals: Terminal[]; // fixture-only + } + export const STUB_AGENTS: Agent[]; + ``` + +- Produces (in `comms-stub.ts`): `STUB_ACCOUNTS` derived — + `export const STUB_ACCOUNTS: Account[] = [MATT_ACCOUNT, + ...STUB_AGENTS.map((a) => a.account)]`; one `dm-` channel per agent + added to `STUB_CHANNELS`, its id stored back on the agent's + `account.homeChannelId` so the chat pane reads it O(1) (no per-render + `agentDmChannel` search). `agentView(id): Agent | undefined` on the store + composes account + optional lifecycle by shared account id — the pure + seam function (`joinAgents` in the real era) that stays a UI concern. +- Consumes: `Account` (`comms-stub.ts:41`), `agentDmChannel` + (`comms.ts:126-133`) — unchanged. +- Callers to migrate: `store.ts` (`agents`, `selectedAgent`, `agentRepos`, + `observedAgentId` unification, the `agentView(id)` composition); `constants.ts:87,94` + (hard-coded `agent-supervisor`/`agent-warden` in `RIGHT_SIDEBAR_TAB_BY_ID` — else + `RightSidebar.test.ts:173` "every fleet agentId resolves a real stub agent"). +- Callers **deleted, not migrated** (fork 2, harness drop): `AgentKind` + + `KIND_LABEL` (`constants.ts:5,42`) and every `harness`/`kind` render site — + the `kind-tag` pip (`LeftSidebar.tsx:47`, import at `:3`), `Bridge.tsx:113`, + `RightSidebar.tsx:383`. The `board.test.ts:59,64` `kind`/`conversation: []` + fixture fields go with them (the latter under the ACP conversation-removal + above). `LeftSidebar.tsx:39-46`'s `sharedWith` share pip is likewise removed + (0.6 abolished the share model — `AgentWorkspace.participant_user_ids` + + Share/Unshare RPCs removed, `../compass-0.6/design.md:1732-1734`; workspace + access IS home-channel membership, RT-2). Also: `LeftSidebar.tsx` `STUB_TREE` + agent ids; `session-stub.ts` `STUB_SESSIONS` keys (re-keyed onto board agents, + above); board components reading `assignee`. + +Test cycle (red first): unit tests asserting every agent's `account.homeChannelId` +resolves a real DM channel (and equals `agentDmChannel(...)` — the id is cached, +not stale); `agentView(id)` composes account + lifecycle for each of the ten and +returns undefined for an unknown id; a fixture agent with no `STUB_SESSIONS` +entry yields `lifecycle` present but `agentSession()` empty (honest optionality); +`STUB_ACCOUNTS` contains exactly one account per agent + the caller; no `agent-*` +id and no `harness`/`kind` field remains in any fixture; every `STUB_SESSIONS` +key resolves to a surviving agent id (the log panel has content) and no message +author / channel member / group-DM member references a dropped `acc-*` id; every +workstream `assignee` resolves to a surviving agent id. + +### T2 — Restore the tab/split-pane store machinery, `session` → `chat` + +Restore from `origin/main:store.ts` the pane/tab/split types (lines 56-171) +and actions (548-622) that #783 deleted, with one rename: the permanent +first pane kind is `chat` (was `session`), and `SESSION_TAB_ID` becomes +`CHAT_TAB_ID = "chat"`. Remove `ObservationPaneKind` / +`DEFAULT_OBSERVATION_PANE` / `activeObservationPane` / +`setActiveObservationPane` (`store.ts:93-97,206-210`). Add the log-panel +minimize state (D2) and the two sidebar-section collapse states (reusing the +existing `collapsed` set mechanism, `store.ts` `isFolderCollapsed`/ +`toggleFolder`). `openAgent(agentId)` (account id now) resets tabs to the +chat tab, keyed on the workspace-init guard the branch already has +(`agentViewAgentId`, `store.ts:320-323`); it also selects the agent's home +DM channel so the chat pane and `selectedChannel` agree. In the fixture era +T1 guarantees a home DM for all 10 agents, so this resolves synchronously; for +the real-daemon era, when `agentDmChannel` finds no channel yet (account arm +ahead of its home-channel projection), `openAgent` sets `selectedChannelId` +to a connecting/empty state rather than leaving the prior selection stale — +the store seam owns this partial-join policy in one place (D1's composition +seam). `view()` keeps `"channel"` for standalone channels but the default view +becomes `"bridge"`. + +`Interfaces:` + +- Produces (restored verbatim from `origin/main:store.ts:56-171` modulo the + rename): + + ```ts + export type PaneKind = "chat" | "terminal" | "file"; + export interface Pane { + id: string; + kind: PaneKind; + title: string; + terminalId?: string; + filePath?: string; + } + export type SplitNode = + | { kind: "leaf"; pane: Pane } + | { kind: "split"; direction: "row" | "column"; + left: SplitNode; right: SplitNode }; + export interface AgentTab { + id: string; + title: string; + layout: SplitNode; + focusedPaneId: string; + } + export const CHAT_TAB_ID = "chat"; + export function splitPaneIds(node: SplitNode): string[]; + export function splitPanes(node: SplitNode): Pane[]; + export function splitPaneOnce( + node: SplitNode, targetPaneId: string, newPane: Pane, + direction: "row" | "column", + ): [SplitNode, boolean]; + ``` + +- Produces (on `AppStore`): + + ```ts + // tabs + splits (restored, origin/main:store.ts:245-270) + agentTabs: Accessor; + activeAgentTabId: Accessor; + activeAgentTab: Accessor; + setActiveAgentTab: (tabId: string) => void; + openTab: (pane: Pane) => void; + closeTab: (tabId: string) => void; + splitActivePane: (pane: Pane, direction: "row" | "column") => void; + setFocusedPane: (paneId: string) => void; + closePane: (paneId: string) => void; + // log panel (new, D2) + logOpen: Accessor; // default true; per-workspace-entry reset + toggleLog: () => void; + // sidebar sections (new) + isSectionCollapsed: (section: "channels" | "agents") => boolean; + toggleSection: (section: "channels" | "agents") => void; + ``` + +- Removes from `AppStore`: `activeObservationPane`, + `setActiveObservationPane`, `observedAgentId` (unified with + `selectedAgentId` — one id space after T1 makes the distinction moot; + `agentSession` re-keys off `selectedAgentId`); `selectChannel` + (`store.ts:445`) and `showChannel` (`store.ts:438`), whose routing is + subsumed by T5's `openChannel`. `selectChannel`'s body resets the + observation pane via `setActiveObservationPaneSignal(DEFAULT_OBSERVATION_PANE)` + (`store.ts:453-455`); that reset is deleted alongside `DEFAULT_OBSERVATION_PANE` + here, so no accessor references the removed symbol mid-plan. Sole non-test + callers go dead by plan: `ChannelSidebar.tsx:45` (T5 deletes the file) and + `App.tsx:58`'s `showChannel()` tab (T7 removes the `Channels|Board` swap). +- Consumes: T1's `Agent`/ids; `STUB_SESSIONS` (`session-stub.ts`) for + `agentSession`; `agentDmChannel` for the home-channel select in + `openAgent`. + +Test cycle (red first): restore the deleted `origin/main:store.test.ts` +tab/split suites (openTab dedup, closeTab session-guard → chat-guard, +splitActivePane focus chaining, closePane collapse + focus fallback) against +the restored surface; **delete the branch's observation-pane + `selectChannel` +suites** (`store.test.ts:161-260` openAgent pane-reset asserts, `:644-728` +selectChannel, `:730-778` observation pane) and replace their coverage with +log-panel (`toggleLog`) + section-collapse tests, so T2 lands green rather +than leaving a red suite for T8; new tests — `openAgent` resets tabs to the +chat tab and selects the home DM; `toggleLog` flips; re-opening the same agent +preserves tabs (init-guard). + +### T3 — Agent workspace: chat-primary pane + tab/split rendering + +Rebuild `AgentView.tsx` on the restored machinery: the tab strip +(chat tab permanent, + terminal button), each tab rendering its `SplitNode` +tree recursively (restore `SplitView`/`PaneView` from +`origin/main:components/AgentView.tsx:339,256`), with the `chat` pane body +rendering the agent's home DM channel through `ChannelView` (composer + +threads + ask — D3: ask fully interactive here) instead of the deleted +`AcpConversation`. Terminal panes restore `TerminalBody` (fake scrollback +from `Agent.terminals`); file panes restore `FileViewer`. The `+` terminal +affordance restores `nextFreeTerminalPane` +(`origin/main:AgentView.tsx:376-379`). + +`Interfaces:` + +- Produces (components in `AgentView.tsx`): + + ```ts + export const AgentView: Component; // the workspace shell + const SplitView: Component<{ node: SplitNode; agent: Agent; + focusedPaneId: string }>; + const PaneView: Component<{ pane: Pane; agent: Agent; focused: boolean }>; + const ChatPane: Component<{ agent: Agent }>; // chat-kind pane body + const nextFreeTerminalPane: (agent: Agent, tabs: AgentTab[]) + => Pane | undefined; + ``` + +- `ChatPane` feed: `ChannelView` already renders from + `store.selectedChannel()` + `threadsOf(store.messages(), channel.id)` + (`ChannelView.tsx:275-278`); `openAgent` (T2) guarantees + `selectedChannelId` is the agent's home DM, so `ChatPane` mounts + `` unmodified. Consumes `threadsOf(messages, channelId): + Thread[]` (`comms.ts:182`), `agentDmChannel` (`comms.ts:126`), + `store.answerAsk(messageId, askId, optionId)`. +- Consumes: T2 store surface (`agentTabs`, `activeAgentTab`, `openTab`, + `splitActivePane`, `setFocusedPane`, `closePane`, `setActiveAgentTab`). + +Test cycle (red first): restore + adapt the deleted +`origin/main:components/AgentView.test.ts` suite; assert the chat tab is +permanent (closeTab no-op), a terminal opens as a tab and as a split, and the +chat pane renders the home DM's threads (fixture message visible) with a +working ask (answer records via `answerAsk`). + +### T4 — The log side panel (minimizable observation companion) + +A new `LogPanel.tsx`: a fixed-width companion docked at the workspace's +right edge, outside the tab/split tree — header (agent handle, running dot, +Stop control, minimize toggle), body = the OMP-native trace (move +`FrameRow` and `TracePane` out of the branch's `AgentView.tsx:43-92`). +Minimized, it +collapses to a slim vertical rail with an expand affordance and the running +dot still visible (liveness at a glance). Stop stays enabled while +`running`, over the documented no-op `stopAgent` (`store.ts:214` — "A no-op +stub until the daemon's StopAgentSession lands"). + +`Interfaces:` + +- Produces: + + ```ts + export const LogPanel: Component<{ agent: Agent }>; + const FrameRow: Component<{ frame: SessionFrame }>; // moved, unchanged + ``` + +- Consumes: `store.agentSession(): AgentSession | undefined` + (`SessionFrame`/`AgentSession`, `session-stub.ts:33-56`), `store.logOpen()` + / `store.toggleLog()` (T2), `store.stopAgent()`. +- Mounted by `AgentView` beside the tab area (CSS grid column; the split + tree never contains it). + +Test cycle (red first): renders frames for an agent with a session; empty +state for one without; minimize hides the body but keeps the running dot; +Stop disabled when idle, enabled when running. + +### T5 — Left sidebar: two collapsible sections + +Extend `LeftSidebar.tsx`: keep the Bridge/Backlog/Done/Settings links, then +a collapsible **Channels** section (standalone channels: non-DM group +channels + group DMs — the caller's member channels via +`railChannels`/`channelSections`, browse/join via `browsableChannels`, +moved from `ChannelSidebar.tsx`) ABOVE a collapsible **Agent workspaces** +section (the existing folder tree, `STUB_TREE` + `AgentLeaf`, +`LeftSidebar.tsx:16-52,175-177`). Clicking a channel calls a new +`openChannel(channelId)` → `view() === "channel"` with `ChannelView` in the +center (T6). Clicking an agent calls `openAgent` → the workspace. 1:1 agent +DMs do NOT list under Channels — the agent workspace is their surface; group +DMs do. `ChannelSidebar.tsx` is deleted. + +`Interfaces:` + +- Produces (in `LeftSidebar.tsx`): + + ```ts + export const LeftSidebar: Component; + const ChannelsSection: Component; // rail rows + unread + browse/join + const AgentsSection: Component; // folder tree (existing Node/AgentLeaf) + ``` + +- Produces (on `AppStore`): `openChannel: (channelId: string) => void` — + selects the channel and sets `view` to `"channel"`; an agent-DM id routes + to `openAgent` instead (one entry point, no dead-end DM view). +- Consumes: `channelSections(channels, groups): ChannelSection[]`, + `railChannels`, `browsableChannels`, `dmChannels`, `isDm`, `totalUnread` + (`comms.ts:42-133,211-213`); `store.joinChannel` / `store.toggleSubscribe`; + T2's `isSectionCollapsed`/`toggleSection`. + +Test cycle (red first): both sections collapse/expand independently; the +Channels section lists exactly the standalone set (no 1:1 agent DMs); a +channel click routes to `"channel"`, an agent-DM id routes to the workspace; +join/subscribe still mutate through the store. + +### T6 — Standalone channel view (Slack/Discord style, no ask) + +Mount `ChannelView` as the center surface for `view() === "channel"`, with +D3 enforced: in a standalone (non-agent-DM) channel, ask blocks render +**read-only** — options disabled, with a hint pointing at the owning agent's +workspace. `ChannelView` gains one prop; the interactive path (agent +workspace chat pane) passes nothing and behaves as today. + +`Interfaces:` + +- Changes: + + ```ts + export const ChannelView: Component<{ readonlyAsks?: boolean }>; + // AskBlock gains: disabled?: boolean — renders options inert + the + // "answer in @'s workspace" hint when set. + ``` + +- `App.tsx` mount: `
` inside the + board shell (left sidebar + right sidebar stay). +- Consumes: T5's `openChannel` routing; `threadsOf`; `Composer` (unchanged — + Send enabled over the documented no-op). + +Test cycle (red first): an ask in a standalone channel renders disabled and +does not mutate on click; the same message in the workspace chat pane stays +answerable; threads render identically in both mounts. + +### T7 — Shell restoration (board-primary App) + +Restore `App.tsx` to the `origin/main` layout: single view-tab strip +(Bridge + the selected agent's tab, `origin/main:App.tsx` `view-tabs` nav), +left sidebar always available (toggle), center `}>` over `bridge`/`backlog`/`done`/`settings` plus +the new `channel` match (T6), right sidebar, usage bar. Remove the +`Channels|Board` swap and `onChannelSurface`. Default view: `bridge`. +`RightSidebar`'s `FleetPane` placeholder ("conversation lives on the +Channels surface now", `RightSidebar.tsx:371-384`) re-points to the agent +workspace (opens the agent via `openAgent`). + +`Interfaces:` + +- `View` stays `"channel" | "agent" | "bridge" | "backlog" | "done" | + "settings"` (`store.ts:56-62`); `createAppStore` boots with + `createSignal("bridge")`. +- Consumes: T2-T6 surfaces. Produces: the assembled shell. + +Test cycle (red first): store-level routing tests — boot lands on `bridge`; +`openAgent` → `agent`; `openChannel` → `channel`; board views keep both +sidebars; snapshot-free (behavioral asserts only). + +### T8 — Fixture/test reconciliation + cleanup sweep + +Delete the dead branch surface: `ObservationPaneKind` remnants, the +`ChannelSidebar.tsx` file (T5), the orphaned `AcpMessage`/`AcpBlock` **type +aliases** in `stub-data.ts` (T1 already removed the `conversation` field + its +fixture data; only the bare types can remain — verify nothing consumes them +with a grep gate), and the branch's `observedAgentId` plumbing (T2). Reconcile +the wholesale-rewritten `app.css` (`app.css:2646+` "Channel-first surface … the +board is demoted to a secondary projection") back to the board-primary layout, +folding each surface task's CSS as it lands. Reconcile `store.test.ts` / +`comms.test.ts` suites to the final surface; run the full battery. + +`Interfaces:` + +- Consumes: everything T1-T7. Produces: a green `moon run compass-ui:test`, + biome-clean tree, no orphaned exports (grep gate: `ObservationPaneKind`, + `DEFAULT_OBSERVATION_PANE`, `AcpConversation`, `ChannelSidebar`, + `selectChannel`, `showChannel`, `agent-supervisor`, `AgentKind`, `KIND_LABEL`, + `harness`, `AgentEvent`, `kind-tag` → zero hits). + +Test cycle: the full suite green under `--conditions browser`; the deleted +suites' coverage demonstrably re-homed (T2/T3 restored suites). + +## Tasks + +- [ ] **T0 — Proto: drop both `harness` fields (stacked PR, out of the UI + reshape)**: remove `AgentAccount.harness` (field 2, `comms.proto:140-142`) + AND `CreateAgentRequest.harness` (field 3, `comms.proto:377-379`), each + with `reserved` on its number + name, then regenerate — buf-breaking, + permitted pre-launch (Server on main is ephemeral, no live client). + Commits the no-BYOA v0.6 stance to the wire (`../compass-0.6/design.md:676-680`): + with both gone, nothing writes or reads `harness` on the wire, so the + store's `agent_accounts.harness` column + `NewAgent`/`AgentAccount` harness + types are fully orphaned (cleaned up in the SEA-1243 comms-server lane's + store-harness task, not this record's T2). A small separate PR + in the comms-server lane; the UI (T1) drops its `harness` consumers + regardless of when T0 lands. +- [ ] **T1 — Agent identity (separate co-addressed types) + comms-fixture + reconciliation**: one account-id space, `Account` (+`homeChannelId`) and + `AgentLifecycle` composed by `agentView`, surviving roster = the ten board + agents + matt, derived `STUB_ACCOUNTS`, home DM per agent (id cached), + `STUB_SESSIONS`/messages/memberships re-homed, `assignee` migration, + `harness`/`AgentKind`/`feed` dropped, ACP field + fixture data dropped. +- [ ] **T2 — Store machinery restored**: `PaneKind`/`Pane`/`SplitNode`/ + `AgentTab`/`CHAT_TAB_ID` + `openTab`/`closeTab`/`splitActivePane`/ + `setFocusedPane`/`closePane`/`agentTabs`/`activeAgentTab`; log + + section state; observation-pane surface removed. +- [ ] **T3 — Workspace chat-primary**: tab strip + split rendering restored; + `chat` pane = `ChannelView` on the home DM; terminals/files as panes. +- [ ] **T4 — Log panel**: minimizable companion with trace, running dot, + Stop. +- [ ] **T5 — Left sidebar**: collapsible Channels above collapsible Agent + workspaces; `openChannel`; `ChannelSidebar.tsx` deleted. +- [ ] **T6 — Standalone channel view**: `ChannelView` center mount, + `readonlyAsks` in standalone channels. +- [ ] **T7 — Shell restoration**: board-primary `App.tsx`, default `bridge`, + `Channels|Board` swap removed, `FleetPane` re-pointed. +- [ ] **T8 — Reconciliation sweep**: dead code deleted, suites reconciled, + full battery green. + +## Open Questions + +The three Matt-ruled forks (D1 identity, D2 log panel, D3 ask scope) are +**Decisions above, not questions**. What remains: + +1. **[RESOLVED — Matt + compass service-owner] Agent identity model.** D1 models + identity as **separate, co-addressed types** — a durable `Account` (comms), + an ephemeral `AgentLifecycle` enum (`AgentSessionStatus.state`), and the + opaque OMP session trace (`AgentSession`, already separate) — composed at the + store seam into the `Agent` view-model, not one merged fixture object. The + compass service-owner's field-mapping review against `compass.proto` @ main + still holds and is folded into D1: (a) of the ephemeral fields only `state` + (`AgentSessionStatus.state`, `compass.proto:126-129`) streams as an + agent-object field today; (b) the `session_id → account` attribution is a + client-side binding via `container_name` from `StartAgentSession` + (`compass.proto:197-209`), the one open seam, not a new proto surface; (c) no + agent:session 1:1 contract is baked into any type. Separating the types makes + the lifecycle's optionality (a created-but-unstarted agent has none) honest, + which the merged shape could not. No T-task shape change beyond T1's type + definitions. +2. **[non-load-bearing, deferred] Group-DM asks.** D3 defers asks in + *standalone multiplayer channels*; a `group_dm` (e.g. `dm-cook-xenophon`, + `comms-stub.ts:322-329` — its membership re-homes onto surviving ids in T1, + but any group DM is multiplayer regardless), so T6 treats group DMs as + standalone (read-only asks). If Matt intends group DMs to behave like the + 1:1 workspace chat, that is a one-line predicate change in T6. + **Recommendation**: read-only asks in group DMs (consistent with the + multiplayer rationale). +3. **[non-load-bearing, deferred] Log-panel state scope.** T2 models + `logOpen` as a single global signal reset on workspace entry. Per-agent + persistence (remembering each workspace's minimize state) is a trivial + later upgrade (a `Set` keyed by agent id). + **Recommendation**: global signal for v1. diff --git a/docs/designs/product/compass-0.8-threading-and-session-renderer/design.md b/docs/designs/product/compass-0.8-threading-and-session-renderer/design.md new file mode 100644 index 00000000..08e48f7d --- /dev/null +++ b/docs/designs/product/compass-0.8-threading-and-session-renderer/design.md @@ -0,0 +1,819 @@ +# Compass v0.8 — Threaded replies + first-party typed session renderer + +Status: Historical + +## Problem / Intent + +Two UI gaps Matt called out after the v0.7 channel workspace merged +(PRs #810/#816/#821/#822): + +1. **Replies can't be created.** The threading model is fully built and tested + — messages carry `parentMessageId`, `threadsOf()` groups them, `ThreadView` + renders them — but there is no interaction to *create* a reply: the composer + only posts flat top-level messages and no message carries a reply + affordance. +2. **The session trace is ugly and opaque.** The agent observation panel + renders coarse opaque frames (a kind tag + one preformatted line) under the + frozen v0.6 decision that the trace is OMP-native and rendered by OMP's own + renderer. Matt has now ruled the opposite: Compass builds a **first-party + typed session renderer**, with session events crossing a **typed gRPC + stream** — not opaque bytes, and explicitly **not ACP**. + +This record designs both: (1) a Slack/Discord-style **reply panel / +side-thread** for creating replies, and (2) the typed session-event contract + +Compass-side per-kind renderer, superseding the opaque-trace decisions of +v0.6 and v0.7 by citation. + +## Approach + +### Matt's rulings (verbatim) + +On the reply UX: + +> "i think discord also uses a reply panel like slack? at least from my quick +> test" + +On the session renderer: + +> "terminal surface for just the session stream seems ugly. maybe we should +> just build out a renderer, and accept we need to implement all the various +> tool calls, edits, etc? would need to investigate the best way to pass all of +> those events out of the Compass agent though. I'm hesitant about tying +> ourselves to ACP because we're doing a first party agent intentionally to +> have a better integrated experience. can likely do a typed gRPC stream?" + +And the trace surface stays observation-only: "just without an input box" — +the Stop control stays, no composer. + +### Superseded frozen decisions + +Superseded by citation — the frozen records themselves are never rewritten +(sealed `AGENTS.md`). + +This record supersedes the *opaque-trace* arm of two frozen records and the +proto shape that encodes it: + +- **compass-0.6, round-two fork (e)** — "**the trace is a dedicated OMP-native + session stream** (`SubscribeAgentSession`), not typed `SubscribeEvents` + variants" (`docs/designs/product/compass-0.6/design.md:139-143`); "streamed + from the **dedicated OMP-native session-tail stream** (opaque frames rendered + by OMP's own renderer …)" (`compass-0.6/design.md:261-264`); and the T5/T7 + plan item "carrying the agent's OMP-native session events as an **opaque** + envelope (bytes/JSON) … consumed by OMP's own renderer" + (`compass-0.6/design.md:521-528`). The *dedicated session stream* part + survives — only the *opaque, OMP-rendered* payload is superseded: the same + stream now carries typed events Compass renders itself. +- **compass-0.7 D2's opacity premise** — "The raw agent output (OMP-native + `SessionFrame` stream, opaque to Compass …) is a *companion* you glance at" + (`docs/designs/product/compass-0.7-channel-workspace/design.md:151-159`); + "**the OMP session trace** (ephemeral, **opaque**) … Compass does not + interpret it; it hands frames to OMP's own renderer" + (`compass-0.7-channel-workspace/design.md:185-191`); and the D2 alternative + rationale "different data source (opaque OMP frames vs user-openable + resources)" (`compass-0.7-channel-workspace/design.md:235-239`). D2's + *panel shape* (fixed minimizable side panel, not a tab/split leaf) is NOT + superseded — only what renders inside it. +- **The proto shape encoding the opacity** — the internal + `SessionFrame { bytes event = 1; AgentSessionState state = 2 }` + (`proto/compass/v1/agent.proto:68-71`), doc'd "under the + first-party OMP agent the trace is not re-typed by Compass — OMP's own + renderer inflates it" (`agent.proto:48-53`). The `bytes event` field is + superseded by a typed event oneof (T-P1 below); the `state` lifecycle arm + and the AgentFrame conversation variants are untouched. + +**Why the v0.6 typed-variant removal doesn't block this.** v0.6 removed the +three typed variants `AgentMessageChunk`/`AgentToolCall`/`AgentPlan` from +`SubscribeEvents` as "neither the native render format nor needed" +(`compass-0.6/design.md:531-535`). That removal was about the **board stream** +— `SubscribeEvents` keeps Compass's own projections (liveness, lifecycle, +board), and it still does under this record. This record reintroduces typed +session events on the **dedicated observation-trace stream** (v0.6 fork (e)'s +own stream), richer than the removed set (thought, tool_call + update, diff, +plan), because Matt now wants Compass-side rendering. The messages still exist +in the tree today (`compass.proto:169-203` defines +`AgentMessageChunk`/`AgentToolCall`/`AgentPlan`/`AgentPlanEntry`) and inform, +but do not constrain, the new event shapes. + +### Change 1 — Reply panel / side-thread + +**What exists (all tested, frozen v0.6 comms — reused, never forked):** + +- The carrier: `Message.parentMessageId?` + (`apps/ui/src/comms-stub.ts:163-165` — "SEAM (channel-model + amendment): the message this one replies to, forming a thread; absent for a + top-level message"). +- The grouping: `threadsOf(messages, channelId): Thread[]` + (`comms.ts:195-219`), over + `Thread { root: Message; replies: Message[] }` (`comms.ts:153-156`), one + level deep with orphan + cycle guards; exercised by + `comms.test.ts:80-221` (ordering, orphan, cross-channel, cycle). +- The rendering: `ThreadView` (`components/ChannelView.tsx:203-228`) renders + root + replies under `.thread-replies` (`app.css:2956-2959` — + `margin-left: 20px; … border-left: 2px solid var(--border)`). + +**What's missing:** the CREATE interaction. `Composer` +(`ChannelView.tsx:262-291`) posts only flat messages (and is itself a +documented no-op stub — the send button has no onClick); there is no reply +affordance, no reply-target state, and no `postMessage`/`postReply` store +action (`store.ts` has `answerAsk` at `store.ts:316` but no post action). + +**Chosen shape (Matt's ruling): a reply panel / side-thread**, Slack/Discord +style. A per-thread "reply" affordance on the thread's ROOT row (the interface +adds only `onOpenThread(rootMessageId)`, matching the one-level-deep model) +opens a dedicated thread panel: the root message, its replies, and a +thread-scoped composer. No inline reply-under-message affordance. + +Design decisions: + +- **Store state, not component state.** The open thread is cross-component + state (the stream opens it, the panel closes it), so it lives in `AppStore` + as `openThreadRootId: Accessor` + `openThread(rootMessageId)` + / `closeThread()`, per the v0.7 Global Constraint "all cross-component state + lives in the one `AppStore`" + (`compass-0.7-channel-workspace/design.md:249-251`). +- **The panel renders one `Thread`** — resolved by `threadsOf()` output for + the current channel, filtered to the open root id. No parallel derivation. +- **Split beside the stream, not overlay** (recommended; OQ-1). The + conversation `
` gains a sibling + `