9f6ad3fe
Add server-authoritative collab design and roadmap
a73x 2026-09-05 19:27
Commit message
docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,278 @@ | |||
| 1 | # Server-Authoritative Collaboration — Design & Roadmap | ||
| 2 | |||
| 3 | Date: 2026-09-05 | ||
| 4 | Status: Draft | ||
| 5 | |||
| 6 | ## Purpose | ||
| 7 | |||
| 8 | Retire the distributed collab-over-refs machinery and make the git-collab | ||
| 9 | server the single authority for live collaboration state: issues, comments, | ||
| 10 | patches, reviews, and a new primitive, work leases. Git remains the store for | ||
| 11 | code and for patch revision commits; SQLite on the server becomes the store | ||
| 12 | for everything that is *state* rather than *content*. | ||
| 13 | |||
| 14 | The driving use case: agents in ephemeral VMs (provisioned by eitri, reachable | ||
| 15 | via mux) that claim work, do it, and participate in review — plus humans doing | ||
| 16 | the same over the web UI, CLI, and IRC. The current substrate cannot serve it: | ||
| 17 | |||
| 18 | - **Claiming work needs arbitration.** Git's only compare-and-swap is the ref | ||
| 19 | update on push. A claim today is fetch → append event → push → maybe | ||
| 20 | rejected → refetch → replay → discover you lost. Seconds of latency and a | ||
| 21 | lottery, not a lock. | ||
| 22 | - **Review back-and-forth needs subscription.** Git has no watch primitive; | ||
| 23 | the latency floor for a conversation is the polling interval plus a full | ||
| 24 | fetch/reconcile/push cycle per utterance. | ||
| 25 | - **Ephemeral workers can't afford the bootstrap.** Every fresh VM pays | ||
| 26 | clone + `init-key` + trust-allowlist distribution + refspec setup before it | ||
| 27 | can say one word. | ||
| 28 | - **The consistency model is design debt by our own admission.** Concurrent | ||
| 29 | status events resolve by lexicographic timestamp comparison | ||
| 30 | (`docs/design-debt.md`): forgeable, clock-skewed, non-deterministic on ties. | ||
| 31 | - **Signed events freeze the schema forever.** Every historical `event.json` | ||
| 32 | shape is load-bearing permanently (see the `PatchCreate.commit` history in | ||
| 33 | `src/event.rs`). A server-owned store migrates; a signed DAG cannot. | ||
| 34 | - **Per-repo ref islands cannot cross-reference.** An issue about mux filed | ||
| 35 | from inside eitri's repo has no home. One forge, one database makes a | ||
| 36 | cross-repo link a row with two foreign keys. | ||
| 37 | |||
| 38 | All five problems share one cause: the refs substrate makes git act as a | ||
| 39 | replicated multi-writer database. Roughly 7.3k lines (`sync.rs`, `state.rs`'s | ||
| 40 | DAG replay, `trust.rs`, `dag.rs`, `sync_lock.rs`, `merge_scan.rs`'s sync-time | ||
| 41 | half, the signed-event plumbing) exist to prop up that role. With a server we | ||
| 42 | already run, every one of those problems has a boring, standard answer. | ||
| 43 | |||
| 44 | ## The design test, inverted | ||
| 45 | |||
| 46 | The 2026-08-09 patch-revision-refs design stated: | ||
| 47 | |||
| 48 | > If a feature only works when the server is `git-collab-server`, it is not a | ||
| 49 | > git-collab feature. | ||
| 50 | |||
| 51 | That test is hereby retired. It was the honest formulation of the original | ||
| 52 | bet — collaboration between any two clones with no server — and the bet's | ||
| 53 | value did not survive contact with the actual use case. The replacement test: | ||
| 54 | |||
| 55 | > **Contributing code must require nothing but plain git. Everything else may | ||
| 56 | > require the server.** | ||
| 57 | |||
| 58 | Gerrit is the precedent for the whole shape: it stores review data in git | ||
| 59 | (NoteDb, `refs/changes/*`) but behind a single server that is the only writer | ||
| 60 | and the arbiter of all state. Git as *storage* was never the problem; git as | ||
| 61 | the *multi-writer sync protocol* was. This design keeps the Gerrit-style data | ||
| 62 | model already built (numbered revisions, revision-anchored comments, | ||
| 63 | interdiffs, timelines, trailer-recorded merges) and relocates authority. | ||
| 64 | |||
| 65 | ## Decision ledger | ||
| 66 | |||
| 67 | | Concern | Old home | New home | | ||
| 68 | |---|---|---| | ||
| 69 | | Code, branches, tags | git | git (unchanged) | | ||
| 70 | | Patch revision commits | git, refs written by clients + sync | git, refs written **by the server** on push | | ||
| 71 | | Issues, comments, reviews, status | signed event DAG in `refs/collab/*` | SQLite on the server | | ||
| 72 | | Work claims | (impossible) | SQLite lease rows, atomic HTTP endpoint | | ||
| 73 | | Merge recording | sync-time scan on every clone | push-time scan on the server | | ||
| 74 | | Auth / identity | trust allowlist file, distributed by hand | server-side authorized keys (SSH + HTTP); agents are keypairs authorized once | | ||
| 75 | | Conflict resolution | timestamp-wins DAG replay | none needed — the DB serializes writes | | ||
| 76 | | Event schema | frozen forever (signed) | migratable (DB), versioned (webhooks) | | ||
| 77 | | Portability of the record | live sync of `refs/collab/*` | optional one-way `export` to `refs/collab/*` as an archival artifact | | ||
| 78 | | Notifications | none (poll `fetch`) | webhooks (machines) + IRC bridge (humans) | | ||
| 79 | |||
| 80 | ## Architecture | ||
| 81 | |||
| 82 | ### One event bus, three projections | ||
| 83 | |||
| 84 | Every mutation on the forge emits one internal event — `issue.filed`, | ||
| 85 | `issue.claimed`, `patch.created`, `revision.pushed`, `review.posted`, | ||
| 86 | `lease.expired`, `merge.recorded` — with three consumers: | ||
| 87 | |||
| 88 | 1. **SQLite** — the authoritative record. | ||
| 89 | 2. **Webhooks** — the machine feed. `POST` to subscriber URLs (triage bot, | ||
| 90 | foreman, CI). Payloads reuse the `event.rs` vocabulary, versioned and | ||
| 91 | evolvable now that nothing signs them into permanence. Payloads are signed | ||
| 92 | with the forge's Ed25519 key (`signing.rs`, relocated) so subscribers can | ||
| 93 | verify origin — GitHub's HMAC pattern with the keys we already believe in. | ||
| 94 | 3. **IRC bridge** — the human feed. Announcements mirrored into channels; | ||
| 95 | scrollback becomes an ambient audit trail. | ||
| 96 | |||
| 97 | Rule that keeps the seams clean: **machines talk HTTP, humans talk IRC, and | ||
| 98 | the bridge translates only at the edges.** IRC carries intent only when a | ||
| 99 | human is speaking (DM intake, explicit commands like `claim`). No | ||
| 100 | machine-originated action ever rides the channel; automation consumes | ||
| 101 | webhooks. IRC is intake and awareness, never correctness, transport, or | ||
| 102 | record. | ||
| 103 | |||
| 104 | ### Leases | ||
| 105 | |||
| 106 | A claim is one conditional update: | ||
| 107 | |||
| 108 | ```sql | ||
| 109 | UPDATE tasks | ||
| 110 | SET lease_holder = :agent, lease_token = lease_token + 1, | ||
| 111 | lease_expires = :now + :ttl | ||
| 112 | WHERE id = :task | ||
| 113 | AND (lease_holder IS NULL OR lease_expires < :now); | ||
| 114 | ``` | ||
| 115 | |||
| 116 | One row affected = claimed (HTTP 200); zero = lost the race (HTTP 409). The | ||
| 117 | database's write serialization is the arbiter; no lock service. | ||
| 118 | |||
| 119 | - **TTL + heartbeat.** Agents die mid-task (VM reaped, OOM, wedged). Leases | ||
| 120 | expire on their own; live holders renew via `PUT /tasks/:id/lease`. Humans | ||
| 121 | get an open-ended lease that renders as "assigned" — one mechanism, two | ||
| 122 | tenure policies. | ||
| 123 | - **Fencing tokens.** `lease_token` increments on every acquire. Every write | ||
| 124 | a holder makes (revision push, status change, completion) carries its | ||
| 125 | token; the forge rejects stale tokens. Expiry alone protects liveness; | ||
| 126 | fencing protects correctness against zombie workers waking up post-expiry. | ||
| 127 | - **Idempotent acquire.** Re-claiming a lease you already hold succeeds, so a | ||
| 128 | client retrying a lost HTTP response does not deadlock against itself. | ||
| 129 | |||
| 130 | Scale is a non-issue by construction: a lease op costs ~1ms of forge time | ||
| 131 | against tasks costing minutes of VM work, and contention is per-task (N racers | ||
| 132 | = one winner, N−1 cheap 409s). The pattern is what GitHub Actions runners, | ||
| 133 | Kubernetes `Lease` objects, and Buildkite agents all do. The thing that | ||
| 134 | actually fails to scale is polling, and the architecture is push-based | ||
| 135 | (webhooks/IRC announce; agents claim on signal, with jitter). | ||
| 136 | |||
| 137 | ### Patch flow (Gerrit-style, magic ref) | ||
| 138 | |||
| 139 | - `git push origin HEAD:refs/for/<base>` — the server's receive hook creates | ||
| 140 | a patch (or matches an existing one by the `Patch:` trailer), pins the | ||
| 141 | commit as the next revision under `refs/collab/patches/<id>/rev/<oid>`, | ||
| 142 | links `--fixes` issues, and prints the patch URL in the push response. | ||
| 143 | Plain git is the only client requirement — this is the new design test | ||
| 144 | holding. | ||
| 145 | - The existing `commit-msg` hook (`hooks.rs`) is our Change-Id equivalent: | ||
| 146 | it stamps the `Patch:` trailer at commit time, and amends carry it. It | ||
| 147 | survives verbatim. | ||
| 148 | - `git-collab patch create/revise` remain as CLI conveniences over the same | ||
| 149 | server operations (set title/body without the web UI). | ||
| 150 | - Local branches are private and never leave the machine. The patch is the | ||
| 151 | unit; revisions are immutable commits; comments anchor to the revision they | ||
| 152 | were written on; interdiff works exactly as today. | ||
| 153 | - Merge: land the commit with its `Patch:` trailer however you merge | ||
| 154 | (including squash). The server scans at push time (`merge_scan.rs` | ||
| 155 | relocated from sync time) and records the merge. `patch merge` stays as | ||
| 156 | the manual fallback. | ||
| 157 | |||
| 158 | ### Identity | ||
| 159 | |||
| 160 | One keypair per principal, human or agent. Humans: the SSH keys the server | ||
| 161 | already authorizes. Agents: a keypair minted (or injected) at VM boot, | ||
| 162 | authorized once on the forge; every clone, push, comment, and claim it makes | ||
| 163 | is attributed to that key. Provenance chains are recorded where actions are | ||
| 164 | brokered: an issue filed by the triage bot from a DM reads *filed by triage, | ||
| 165 | on behalf of alex, via DM*. | ||
| 166 | |||
| 167 | ### Cross-repo | ||
| 168 | |||
| 169 | Issues belong to a repo; references are qualified (`mux#4f2a91`). `relates_to` | ||
| 170 | generalizes to cross-repo foreign keys in the one database. A worker that | ||
| 171 | finds a side-issue in another repo files it (`POST /repos/<repo>/issues`), | ||
| 172 | links it, and stays on task — the default policy is **file, link, continue**; | ||
| 173 | the lease system keeps workers honest about what they claimed. | ||
| 174 | |||
| 175 | ## Companion pieces (out of this repo's scope) | ||
| 176 | |||
| 177 | Per the standalone-tools philosophy, each seam lands as a feature of the tool | ||
| 178 | it belongs to, and the "platform" remains a personal composition: | ||
| 179 | |||
| 180 | - **eitri: job mode.** Boot a VM, inject key + task context, run, report, | ||
| 181 | tear down. Useful to anyone doing CI/sandboxed builds on their own | ||
| 182 | hardware, independent of this forge. | ||
| 183 | - **Bridge bot + foreman: personal glue, unshipped.** The bot mirrors | ||
| 184 | webhook events into IRC and translates human commands/DMs into API calls. | ||
| 185 | The foreman is a dumb elasticity loop: unclaimed tasks > idle workers → | ||
| 186 | ask eitri for a VM (up to a cap); idle worker > N minutes → reap. Lease | ||
| 187 | TTLs already handle worker death, so recruitment can afford to be naive. | ||
| 188 | - **Triage: a webhook subscriber**, not a forge feature. Dedup, labels, | ||
| 189 | severity, clarifying questions — all ordinary API calls. | ||
| 190 | |||
| 191 | Nothing in this repo may depend on any of these existing. | ||
| 192 | |||
| 193 | ## Codebase impact | ||
| 194 | |||
| 195 | **Survives, largely intact** — the parts that were never the problem: | ||
| 196 | |||
| 197 | - `src/server/` — SSH, HTTP, repos, releases, governance, web. Promoted from | ||
| 198 | "optional" to the center of the product. | ||
| 199 | - The Gerrit reconstruction: `patch.rs` diff/interdiff, `timeline.rs`, | ||
| 200 | revision refs (now server-written), TUI and web rendering. | ||
| 201 | - `merge_scan.rs` (relocated to push time), `hooks.rs` (unchanged role). | ||
| 202 | - The CLI: same verbs, thin client over HTTP instead of a local ref-writer. | ||
| 203 | - `event.rs` vocabulary: becomes the webhook payload schema. | ||
| 204 | - `signing.rs`: signs webhook payloads; agent keys remain Ed25519. | ||
| 205 | |||
| 206 | **Dies** — the replicated-database tax (~6k lines): | ||
| 207 | |||
| 208 | - `sync.rs`, `sync_lock.rs`, `dag.rs`, `trust.rs`. | ||
| 209 | - The DAG-replay materializers in `state.rs` (become SQL queries). | ||
| 210 | - Timestamp-wins conflict resolution and its design-debt entry. | ||
| 211 | - Signed event-trees as the live storage format, and with them the | ||
| 212 | frozen-schema archaeology. | ||
| 213 | |||
| 214 | **New, all small:** | ||
| 215 | |||
| 216 | - SQLite schema: issues, comments, patches, revisions, reviews, leases, | ||
| 217 | webhook subscriptions. | ||
| 218 | - The lease endpoints (~50 lines + fencing checks on write paths). | ||
| 219 | - The HTTP API surface for issues/comments/reviews. | ||
| 220 | - `refs/for/<base>` receive-hook handling. | ||
| 221 | - The event bus + webhook dispatcher. | ||
| 222 | - `git-collab export`: one-way materialization of the record into | ||
| 223 | `refs/collab/*` so "clone carries the conversation" survives as an | ||
| 224 | archival feature rather than as the transport. | ||
| 225 | |||
| 226 | ## Roadmap | ||
| 227 | |||
| 228 | Each phase is independently shippable and gets its own implementation plan | ||
| 229 | (in `docs/superpowers/plans/`) when picked up. Order matters: every phase is | ||
| 230 | useful the day it lands, and none blocks on the companions. | ||
| 231 | |||
| 232 | 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks. | ||
| 233 | The agent loop works against the forge as it exists today; humans get | ||
| 234 | `issue claim`. (The seam the whole agent story hangs on, and the one | ||
| 235 | thing git structurally cannot express.) | ||
| 236 | 2. **Issues and comments to SQLite.** The API + web UI read/write the DB; | ||
| 237 | a comment becomes one `POST`. Kills the review round-trip burden. Includes | ||
| 238 | a one-time importer that replays existing `refs/collab/*` DAGs into the | ||
| 239 | DB (the current `state.rs` materializer, run once, then retired). | ||
| 240 | 3. **Server-maintained revision refs + `refs/for/<base>`.** The receive hook | ||
| 241 | creates/updates patches; push-time merge scanning. Kills the last | ||
| 242 | client-side collab-ref writes. CLI verbs become API calls. | ||
| 243 | 4. **Event bus + webhooks + IRC bridge hook points.** Signed payloads, | ||
| 244 | subscription management. Unblocks triage/foreman/CI as external | ||
| 245 | subscribers. | ||
| 246 | 5. **Export and burial.** `git-collab export`; delete `sync.rs`, | ||
| 247 | `sync_lock.rs`, `dag.rs`, `trust.rs`, the `state.rs` replay, and the | ||
| 248 | design-debt entry. Update README: the pitch becomes *the lightest forge | ||
| 249 | with real code review*. | ||
| 250 | |||
| 251 | ## Non-goals | ||
| 252 | |||
| 253 | - **Offline multi-writer collaboration.** The property this design | ||
| 254 | deliberately gives up. The record stays portable (export); the *transport* | ||
| 255 | stops being git. | ||
| 256 | - **Nostr / NIP-34 / federation.** The relay-authoritative model here is | ||
| 257 | compatible in spirit (Buzz reaches the same conclusions), and the event | ||
| 258 | vocabulary could become kinds later. Not now; the forge API is smaller and | ||
| 259 | we own both ends. | ||
| 260 | - **Multi-node forge.** SQLite and one process outlive any realistic personal | ||
| 261 | fleet by orders of magnitude. Revisit at ~10⁴ concurrent claimants, i.e. | ||
| 262 | never. | ||
| 263 | - **IRC as a dependency.** The forge must be fully usable with no bridge and | ||
| 264 | no bot. | ||
| 265 | |||
| 266 | ## Open questions | ||
| 267 | |||
| 268 | - **Human lease semantics.** Open-ended lease vs. long TTL with soft nag? | ||
| 269 | Leaning open-ended; an unassign is a manual act either way. | ||
| 270 | - **Agent key authorization flow.** Foreman-signed enrollment vs. manual | ||
| 271 | authorize-once per agent identity. Start manual; automate when it hurts. | ||
| 272 | - **Export format.** Reuse today's signed event-tree layout (readable by | ||
| 273 | existing tooling) vs. a simpler unsigned JSON log. Leaning: keep the tree | ||
| 274 | layout, sign with the forge key — provenance without frozen schemas. | ||
| 275 | - **What happens to `refs/collab/*` data in repos that never import?** The | ||
| 276 | importer is per-repo and opt-in; old refs stay readable by old binaries. | ||
| 277 | - **TUI scope.** Point the dashboard at the API. Cheap if the API mirrors | ||
| 278 | today's read model; decide during phase 2. | ||