a73x

99c71094

Plan phase 1: issue leases over a collab-lease SSH verb

a73x   2026-09-05 19:37

Commit message
Plan phase 1: issue leases over a collab-lease SSH verb

docs/superpowers/plans/2026-09-05-issue-leases.md
Old New
@@ -0,0 +1,464 @@
1 # Issue Leases Implementation Plan
2
3 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5 **Goal:** Atomic, TTL-based work claims ("leases") on issues, held server-side in SQLite and operated over an authenticated `collab-lease` SSH exec verb. Phase 1 of `docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md`.
6
7 **Architecture:** A new `collab-lease` SSH exec verb (alongside `git-upload-pack`/`git-receive-pack`/`collab-release`) with subverbs `acquire`/`renew`/`release`/`list`, gated by the existing per-repo policies (mutations need write, list needs read). Leases live in one SQLite database at `{repos_dir}/collab.db` (config-overridable), keyed `(repo, issue_id)`. The holder is the connection's SSH principal — governed name when governance is active, key fingerprint otherwise — so **the SSH key is the credential; there are no API tokens**. The CLI gains `git-collab issue claim/unclaim/renew/claims`, which shell out to `ssh` exactly like `git-collab release` does. The web UI shows a live claim on the issues list and detail pages.
8
9 **Semantics (from the design doc):** acquire is one conditional write — the DB's serialization is the arbiter. TTL leases expire lazily; a missing `--ttl` means open-ended (a human "assignment"). Re-acquire/renew by the current holder is idempotent. `lease_token` increments per tenure change and is *stored and reported* now; token *enforcement* on write paths (fencing) belongs to later phases — there are no server-mediated writes to fence yet.
10
11 **Tech Stack:** Rust 2021, russh (SSH server), rusqlite (new dep, `bundled` feature), git2, serde_json, chrono.
12
13 **Spec:** `docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md`
14
15 **Codebase facts the implementer needs:**
16
17 - TWO crate roots: the lib (`src/lib.rs`, used by the `git-collab` CLI bin) and the server bin (`src/server/main.rs`, which declares `mod config; mod governance; mod http; mod refs; mod releases; mod repos; mod setup; mod ssh;`). Server modules reference each other as `crate::…` within the server bin and use the lib as `git_collab::…`.
18 - SSH exec requests are parsed by `src/server/ssh/session.rs::parse_exec_command` into `ExecCommand` (`Git` | `Release`); `collab-release` shows the token-parsing pattern (`shell_tokens`, empty-token rejection). Follow it exactly.
19 - Dispatch + authorization pattern: `handle_release_command` in `session.rs`. Unknown repo and unauthorized get the SAME `error: repository not found\n` reply (exit 1) so errors can't probe private repos — leases must mirror this. Authorization switches on `Regime` (`Closed` → reject; `Ungoverned` → `entry.policy.allows_read/allows_write(principal)`; `Governed` → `governance.conf.allows_repo(&key, &subject, needed)` with `governance::conf::Access`). `Access::Write` is "fast-forward a ref, create a ref" — the right level for lease mutations (they are routine collaboration, not history rewrites; releases use `Rewind` because they are administrative).
20 - Replies go through `reply_and_close(session, channel, message, exit_code)` (`session.rs:1104`).
21 - The governed display name: `Regime::Governed { name, .. }` carries it; ungoverned connections have only the fingerprint `principal` (`ssh_key_principal` form). Holder string = governed name if governed, else `principal`.
22 - Repo path safety: `resolve_repo_path(repos_dir, requested)`; repo entry + policy via `crate::repos::entry_for_path(&self.config.repos_dir, resolved_path)`.
23 - Issue resolution server-side: open the bare repo with `git2::Repository::open(resolved_path)`, then `git_collab::state::resolve_issue_ref(&repo, id_or_prefix)` → `(ref_name, full_id)` and `git_collab::state::IssueState::from_ref(&repo, &ref_name, &full_id)` → `.status` (`git_collab::state::IssueStatus::Open`). This is exactly what `src/server/http/repo/issues.rs` does.
24 - Server config: `src/server/config.rs::ServerConfig` (serde `Deserialize` from TOML, `#[serde(default = "…")]` pattern per field; see `max_release_size`).
25 - HTTP state: `src/server/http/mod.rs::AppState { repos_dir, site_title }`; handlers get `State(Arc<AppState>)`. Issues templates: `IssueListItem` / `IssueDetailView` in `src/server/http/repo/issues.rs`, templates `issues.html` / `issue_detail.html` under `src/server/http/templates/`. Every template extending `repo_base.html` must provide `site_title`, `repo_name`, `active_section`, `open_patches`, `open_issues`.
26 - CLI SSH client pattern: `src/release.rs` — `parse_ssh_remote` (pub), `ssh_remote(repo, remote_name)` and `run_remote(repo, &remote, &remote_cmd, stdin)` (private today; Task 5 extracts them). SSH command resolution honors `GIT_COLLAB_SSH_COMMAND` > `GIT_SSH_COMMAND` > git config.
27 - CLI subcommands: `src/cli.rs` (`clap` derive; `IssueCmd` exists). Dispatch lives in `src/main.rs`.
28 - Test harness: `tests/common/mod.rs::ServerHarness` starts a real `git-collab-server`; `ssh_client_key()` generates + authorizes a client key; `ssh_exec_with_stdin(remote_cmd, stdin)` / `ssh_exec_as(key, remote_cmd, stdin)` run exec verbs; `named_key(name)` makes keys enrollable through governance. `open_issue(...)` in `tests/common/mod.rs` writes issue events; push them to the harness bare repo over SSH (`ssh_push`) so the server can resolve them.
29 - Time: store unix epoch seconds (`i64`) in SQLite; render RFC3339 in JSON via `chrono::DateTime::from_timestamp`.
30 - Run tests with `cargo test`, lints with `cargo clippy --all-targets`.
31 - Commit messages: plain imperative style ("Enforce one review vote per author per revision").
32
33 **Exit-code contract for `collab-lease` (used by CLI and agents):**
34
35 | Code | Meaning |
36 |---|---|
37 | 0 | success (including idempotent re-acquire/release) |
38 | 1 | error: bad arguments, unknown/unauthorized repo, unknown issue, closed issue |
39 | 4 | lease conflict: held by someone else (acquire), or not the holder (renew) |
40
41 All success output is one JSON object on stdout. Errors are one `error: …` line.
42
43 ---
44
45 ### Task 1: Lease store in the server bin
46
47 A `leases` module owning the SQLite schema and the four operations. All decisions (who wins, idempotency, expiry) live here, in plain functions over a `rusqlite::Connection`, unit-testable without SSH.
48
49 **Files:**
50 - Create: `src/server/leases.rs`
51 - Modify: `src/server/main.rs` (add `mod leases;`), `Cargo.toml`
52
53 - [ ] **Step 1: Add the dependency**
54
55 Run: `cargo add rusqlite --features bundled`
56
57 - [ ] **Step 2: Write the module skeleton and failing tests**
58
59 Create `src/server/leases.rs`:
60
61 ```rust
62 //! Work leases on issues: the one collaboration primitive git cannot
63 //! express (atomic claim with TTL). One SQLite database per server,
64 //! `(repo, issue_id)` primary key, lazy expiry. See
65 //! docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md.
66
67 use std::path::Path;
68
69 use rusqlite::Connection;
70
71 /// A live or expired lease row.
72 #[derive(Debug, Clone, PartialEq)]
73 pub struct Lease {
74 pub repo: String,
75 pub issue_id: String,
76 pub holder: String,
77 pub token: i64,
78 pub acquired_at: i64,
79 /// None = open-ended (human assignment).
80 pub expires_at: Option<i64>,
81 }
82
83 impl Lease {
84 pub fn live(&self, now: i64) -> bool {
85 self.expires_at.map(|e| e > now).unwrap_or(true)
86 }
87 }
88
89 #[derive(Debug, PartialEq)]
90 pub enum Acquire {
91 /// Caller now holds the lease (fresh tenure, or idempotent re-acquire).
92 Acquired { token: i64, expires_at: Option<i64> },
93 /// A different holder has a live lease.
94 Held { holder: String, expires_at: Option<i64> },
95 }
96
97 #[derive(Debug, PartialEq)]
98 pub enum Renew {
99 Renewed { token: i64, expires_at: Option<i64> },
100 /// No live lease held by caller (expired, released, or someone else's).
101 NotHolder { holder: Option<String> },
102 }
103
104 #[derive(Debug, PartialEq)]
105 pub enum Release {
106 /// Row deleted, or there was nothing to release (idempotent success).
107 Released,
108 /// A different holder has a live lease; refuse.
109 NotHolder { holder: String },
110 }
111
112 /// Open (creating if needed) the lease database and ensure the schema.
113 pub fn open(path: &Path) -> rusqlite::Result<Connection> {
114 todo!()
115 }
116
117 pub fn acquire(
118 conn: &Connection,
119 repo: &str,
120 issue_id: &str,
121 holder: &str,
122 ttl_secs: Option<i64>,
123 now: i64,
124 ) -> rusqlite::Result<Acquire> {
125 todo!()
126 }
127
128 pub fn renew(
129 conn: &Connection,
130 repo: &str,
131 issue_id: &str,
132 holder: &str,
133 ttl_secs: Option<i64>,
134 now: i64,
135 ) -> rusqlite::Result<Renew> {
136 todo!()
137 }
138
139 pub fn release(
140 conn: &Connection,
141 repo: &str,
142 issue_id: &str,
143 holder: &str,
144 now: i64,
145 ) -> rusqlite::Result<Release> {
146 todo!()
147 }
148
149 /// A live lease on the issue, if any.
150 pub fn current(
151 conn: &Connection,
152 repo: &str,
153 issue_id: &str,
154 now: i64,
155 ) -> rusqlite::Result<Option<Lease>> {
156 todo!()
157 }
158
159 /// All live leases in a repo, oldest first.
160 pub fn list(conn: &Connection, repo: &str, now: i64) -> rusqlite::Result<Vec<Lease>> {
161 todo!()
162 }
163 ```
164
165 Schema (inside `open`, `CREATE TABLE IF NOT EXISTS`; also set `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`):
166
167 ```sql
168 CREATE TABLE IF NOT EXISTS leases (
169 repo TEXT NOT NULL,
170 issue_id TEXT NOT NULL,
171 holder TEXT NOT NULL,
172 token INTEGER NOT NULL,
173 acquired_at INTEGER NOT NULL,
174 expires_at INTEGER,
175 PRIMARY KEY (repo, issue_id)
176 );
177 ```
178
179 Semantics to implement (each inside ONE transaction — `BEGIN IMMEDIATE` via `conn.transaction_with_behavior(TransactionBehavior::Immediate)` — so concurrent connections serialize):
180
181 - `acquire`: read the row. No row, or row not `live(now)` → take tenure: `INSERT OR REPLACE` with `token = old_token + 1` (1 if no row), `acquired_at = now`, `expires_at = ttl_secs.map(|t| now + t)` → `Acquired`. Live row held by caller → idempotent: keep token, update `expires_at` from this call's `ttl_secs` → `Acquired`. Live row held by another → `Held`.
182 - `renew`: live row held by caller → update `expires_at` (same rule), keep token → `Renewed`. Anything else → `NotHolder` (with the live holder's name if there is one).
183 - `release`: live row held by another → `NotHolder`. Otherwise delete any row → `Released` (idempotent: releasing nothing succeeds — a client retrying after a dropped connection must not fail).
184 - `current`/`list`: return only live rows; opportunistically `DELETE` expired rows they encounter (lazy reaping).
185
186 Unit tests in the same file (`#[cfg(test)]`, DB via `Connection::open_in_memory()` piped through the same schema init — factor `open` so tests can reuse the schema function):
187
188 ```text
189 acquire_free_issue_returns_token_1
190 acquire_sets_expiry_from_ttl / acquire_without_ttl_is_open_ended
191 second_acquire_by_other_holder_returns_held
192 reacquire_by_holder_is_idempotent_same_token_new_expiry
193 acquire_after_expiry_takes_over_and_bumps_token
194 renew_extends_expiry_keeps_token
195 renew_by_non_holder_returns_not_holder
196 renew_after_expiry_returns_not_holder
197 release_by_holder_deletes / release_idempotent_when_absent
198 release_by_non_holder_refused
199 list_shows_only_live_leases / current_none_after_expiry
200 open_ended_lease_never_expires (large `now`)
201 tenure_token_monotonic_across_holders (a→expire→b→expire→a: tokens 1,2,3)
202 ```
203
204 - [ ] **Step 3: Run tests to verify they fail**
205
206 Run: `cargo test leases::`
207 Expected: FAIL (panic at `todo!()`)
208
209 - [ ] **Step 4: Implement**
210
211 Fill in the five functions per the semantics above. Keep every branch decision in SQL-adjacent Rust (read row → decide → write); no clever single-statement upserts — the transaction provides atomicity and the explicit branches keep `Held`/`NotHolder` reporting exact.
212
213 - [ ] **Step 5: Run tests to verify they pass**
214
215 Run: `cargo test leases::`
216
217 - [ ] **Step 6: Commit**
218
219 `Add a server-side lease store for issue claims`
220
221 ---
222
223 ### Task 2: `collab_db` server config
224
225 **Files:**
226 - Modify: `src/server/config.rs`
227
228 - [ ] **Step 1: Write the failing tests**
229
230 In `config.rs` tests: `parse_minimal_config_uses_defaults` asserts `config.collab_db(&config.repos_dir)` — add a test that the default is `{repos_dir}/collab.db` and an explicit `collab_db = "/elsewhere/x.db"` wins.
231
232 Design: an `Option<PathBuf>` field (serde default `None`) plus an accessor, because the default depends on another field:
233
234 ```rust
235 #[serde(default)]
236 pub collab_db: Option<PathBuf>,
237
238 pub fn collab_db_path(&self) -> PathBuf {
239 self.collab_db
240 .clone()
241 .unwrap_or_else(|| self.repos_dir.join("collab.db"))
242 }
243 ```
244
245 - [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server config::`
246
247 - [ ] **Step 3: Implement** (as above)
248
249 - [ ] **Step 4: Run tests to verify they pass**
250
251 - [ ] **Step 5: Commit**
252
253 `Add a collab_db server config option defaulting beside the repos`
254
255 ---
256
257 ### Task 3: Parse `collab-lease` exec commands
258
259 **Files:**
260 - Modify: `src/server/ssh/session.rs` (`ExecCommand`, `parse_exec_command`, tests)
261
262 - [ ] **Step 1: Write the failing tests**
263
264 Next to the `collab-release` parser tests, following their shape (quoting, empty tokens, unknown verbs, extra args → `None`):
265
266 ```text
267 parse_lease_acquire collab-lease acquire 'r.git' 'a1b2c3d4'
268 parse_lease_acquire_with_ttl collab-lease acquire 'r.git' 'a1b2c3d4' --ttl 300
269 parse_lease_renew_requires_ttl collab-lease renew 'r.git' 'a1b2c3d4' --ttl 300 (without --ttl → None)
270 parse_lease_release collab-lease release 'r.git' 'a1b2c3d4'
271 parse_lease_list collab-lease list 'r.git'
272 rejects: unknown subverb, empty repo/issue, non-numeric or zero/negative ttl,
273 trailing args, unclosed quotes
274 ```
275
276 New variants:
277
278 ```rust
279 #[derive(Debug, Clone, PartialEq)]
280 pub enum LeaseCmd {
281 Acquire { repo: String, issue: String, ttl_secs: Option<i64> },
282 Renew { repo: String, issue: String, ttl_secs: i64 },
283 Release { repo: String, issue: String },
284 List { repo: String },
285 }
286 ```
287
288 `ExecCommand` gains `Lease(LeaseCmd)`; its `repo()` accessor covers it. `--ttl <n>` parses as `i64`, must be `> 0`.
289
290 - [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server parse_lease`
291
292 - [ ] **Step 3: Implement** — extend `parse_exec_command` after the `collab-release` block, same `shell_tokens` path.
293
294 - [ ] **Step 4: Run tests to verify they pass** (including the existing release parser suite — no regressions)
295
296 - [ ] **Step 5: Commit**
297
298 `Parse collab-lease exec commands`
299
300 ---
301
302 ### Task 4: SSH dispatch, authorization, and end-to-end server tests
303
304 **Files:**
305 - Modify: `src/server/ssh/session.rs` (dispatch), `src/server/main.rs`/wherever `SshHandler` gets config (it already holds `self.config`)
306 - Create: `tests/lease_server_test.rs`
307
308 - [ ] **Step 1: Write the failing e2e tests**
309
310 `tests/lease_server_test.rs`, using `ServerHarness`. Setup per test: create an issue locally (`common::open_issue`), push collab refs to the harness repo over SSH (`ssh_push` with the collab refspec the sync tests use), then drive `collab-lease` via `harness.ssh_exec_with_stdin(...)` / `ssh_exec_as(...)` with a second key where two principals are needed. Parse stdout as JSON; assert exit codes per the contract table.
311
312 ```text
313 acquire_open_issue_succeeds status=acquired, token=1, expires_at=null
314 acquire_with_ttl_reports_expiry RFC3339 expires_at
315 acquire_conflict_reports_holder second key → exit 4, status=held, holder names key 1
316 reacquire_same_key_is_idempotent exit 0, same token
317 renew_extends / renew_wrong_key_exit_4
318 release_then_other_key_can_acquire token increments
319 acquire_by_issue_id_prefix short prefix resolves; JSON carries the full id
320 acquire_unknown_issue_exit_1
321 acquire_closed_issue_exit_1 (close_issue + push first)
322 unknown_repo_and_unauthorized_are_identical same NOT_FOUND string, exit 1 (mirror release test)
323 list_shows_live_leases_json
324 read_only_principal_cannot_acquire policy write required (exit 1, NOT_FOUND)
325 ```
326
327 - [ ] **Step 2: Run tests to verify they fail** — `cargo test --test lease_server_test` (dispatch missing: server replies as if the verb is unknown)
328
329 - [ ] **Step 3: Implement the dispatch**
330
331 `handle_lease_command(&mut self, channel, session, cmd: LeaseCmd, resolved_path, principal, regime)` modeled line-for-line on `handle_release_command`:
332
333 1. `entry_for_path` → unknown repo → `NOT_FOUND`, exit 1.
334 2. `needed = Access::Read` for `List`, `Access::Write` otherwise; authorize per regime exactly as releases do; failure → same `NOT_FOUND`, exit 1.
335 3. Holder string: `Regime::Governed { name, .. }` → `name`, else `principal`.
336 4. For `Acquire`: open the bare repo, `resolve_issue_ref` (unknown → `error: issue not found\n`, exit 1), `IssueState::from_ref`, require `IssueStatus::Open` (`error: issue is closed\n`, exit 1). `Renew`/`Release` operate on the lease row alone (the issue's continued existence is not their business); `List` needs no issue.
337 5. `leases::open(&self.config.collab_db_path())`, call the store with `now = chrono::Utc::now().timestamp()`, map outcomes:
338 - `Acquired`/`Renewed`/`Released` → JSON on stdout, exit 0.
339 - `Held`/`NotHolder` → JSON (`status: "held"`, holder, expires_at) — exit 4.
340 6. Reply via `reply_and_close`. The store call is synchronous I/O on the async runtime — same accepted tradeoff as the release path, same caveat comment.
341
342 JSON shapes (all include `"issue"` as the FULL id, and RFC3339 timestamps or `null`):
343
344 ```json
345 {"status":"acquired","repo":"r.git","issue":"<full>","holder":"<principal>","token":1,"expires_at":null}
346 {"status":"held","repo":"r.git","issue":"<full>","holder":"<other>","expires_at":"2026-09-05T12:00:00Z"}
347 {"status":"renewed", …} {"status":"released","repo":"r.git","issue":"<full>"}
348 {"leases":[{"issue":"<full>","holder":"…","token":2,"acquired_at":"…","expires_at":null}]}
349 ```
350
351 - [ ] **Step 4: Run the e2e tests** — `cargo test --test lease_server_test`
352
353 - [ ] **Step 5: Run the full suite** — `cargo test` (release + governance suites must be untouched)
354
355 - [ ] **Step 6: Commit**
356
357 `Serve issue leases over a collab-lease SSH verb`
358
359 ---
360
361 ### Task 5: Extract the shared SSH client helpers
362
363 `release.rs` owns `SshRemote`, `parse_ssh_remote`, `ssh_remote`, `run_remote`; the lease client needs them too. Shared code moves to its own lib module rather than one feature reaching into another's.
364
365 **Files:**
366 - Create: `src/remote_ssh.rs`
367 - Modify: `src/lib.rs` (add `pub mod remote_ssh;`), `src/release.rs`
368
369 - [ ] **Step 1: Move** `SshRemote`, `parse_ssh_remote`, `ssh_remote`, `run_remote` (and their unit tests) into `src/remote_ssh.rs`, all `pub`. In `release.rs`, `use crate::remote_ssh::{ssh_remote, run_remote};` and re-export `pub use crate::remote_ssh::{parse_ssh_remote, SshRemote};` so existing callers and tests keep compiling.
370
371 - [ ] **Step 2: Verify** — `cargo test` (pure refactor: zero behavior change, zero test edits beyond module paths)
372
373 - [ ] **Step 3: Commit**
374
375 `Extract the SSH remote client helpers from release into remote_ssh`
376
377 ---
378
379 ### Task 6: CLI `issue claim` / `unclaim` / `renew` / `claims`
380
381 **Files:**
382 - Create: `src/lease.rs` (lib module: client side)
383 - Modify: `src/lib.rs`, `src/cli.rs` (`IssueCmd` variants), `src/main.rs` (dispatch)
384 - Create: `tests/lease_cli_test.rs`
385
386 - [ ] **Step 1: Add the CLI surface**
387
388 `IssueCmd` gains:
389
390 ```text
391 claim <id> [--ttl <secs>] [--remote <name>=origin] [--json]
392 unclaim <id> [--remote] [--json]
393 renew <id> --ttl <secs> [--remote] [--json]
394 claims [--remote] [--json]
395 ```
396
397 Note `tests/cli_surface_test.rs` snapshots the CLI surface — update it deliberately, not incidentally.
398
399 - [ ] **Step 2: Implement the client module**
400
401 `src/lease.rs` follows `release.rs::list` verbatim in shape: `ssh_remote(repo, remote_name)` → format the `collab-lease …` command (single-quoted args; ids validated as hex prefixes client-side before quoting) → `run_remote` → forward stdout when `--json`, otherwise render:
402
403 ```text
404 claimed a1b2c3d4 (expires 2026-09-05T12:05:00Z) exit 0
405 issue a1b2c3d4 is claimed by <holder> exit 4 (message on stderr)
406 ```
407
408 Exit codes pass through from the remote command (russh delivers the exit status; `run_remote` already surfaces it for releases — keep that behavior).
409
410 - [ ] **Step 3: Write failing CLI e2e tests**
411
412 `tests/lease_cli_test.rs` against `ServerHarness`: clone over SSH with `GIT_COLLAB_SSH_COMMAND` set (the release CLI tests show the incantation), then:
413
414 ```text
415 claim_then_claims_lists_it
416 claim_conflict_exit_code_4_names_holder (second key)
417 unclaim_frees / renew_updates_expiry
418 claim_json_passthrough_is_server_json
419 claim_against_repo_without_issue_fails_cleanly
420 ```
421
422 - [ ] **Step 4: Run** — `cargo test --test lease_cli_test`, then the full suite.
423
424 - [ ] **Step 5: Commit**
425
426 `Add issue claim, unclaim, renew and claims CLI commands`
427
428 ---
429
430 ### Task 7: Show claims in the web UI
431
432 A claim that is invisible gets double-worked around; the issues list and detail pages must show it.
433
434 **Files:**
435 - Modify: `src/server/http/mod.rs` (`AppState` gains `collab_db: PathBuf`; `main.rs` passes `config.collab_db_path()`), `src/server/http/repo/issues.rs`, `src/server/http/templates/issues.html`, `src/server/http/templates/issue_detail.html`
436 - Modify: `tests/web_rendering_test.rs` (or the closest existing web test file)
437
438 - [ ] **Step 1: Write the failing tests** — acquire a lease over SSH in a harness, `GET` the issues page and detail page, assert the holder appears; assert an expired lease does NOT appear.
439
440 - [ ] **Step 2: Implement** — `IssueListItem` and `IssueDetailView` gain `claimed_by: String` (empty = unclaimed, matching the existing `labels: String` convention). Handlers open the lease DB read-only per request (`leases::open` on a path that may not exist yet must still succeed — it creates the schema; that is fine, it is the server's own file). Render as `claimed by <holder>` next to the status badge.
441
442 - [ ] **Step 3: Run the web tests, then the full suite.**
443
444 - [ ] **Step 4: Commit**
445
446 `Show live issue claims in the web UI`
447
448 ---
449
450 ### Task 8: Final verification
451
452 - [ ] **Step 1: Full test suite** — `cargo test`
453 - [ ] **Step 2: Clippy** — `cargo clippy --all-targets`
454 - [ ] **Step 3: Docs** — README gains a short "Claiming work" subsection under the issues material: the three commands, the TTL/assignment distinction, one sentence that leases live on the server and require no sync. Mark phase 1 done in the design doc's roadmap.
455 - [ ] **Step 4: Commit any remaining fixes**
456
457 ---
458
459 ## Out of scope for this plan (deliberately)
460
461 - **Fencing enforcement** — tokens are stored and reported, but nothing checks them yet: pushes and events don't flow through lease-aware server paths until later phases.
462 - **Auto-release on merge/close** — needs the push-time merge scanning of phase 3.
463 - **Any HTTP mutation surface or API tokens** — mutations ride SSH, per the design doc.
464 - **IRC bot / foreman / eitri integration** — external subscribers; they consume this verb, they don't live here.
docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md
Old New
@@ -69,9 +69,9 @@ interdiffs, timelines, trailer-recorded merges) and relocates authority.
69 | Code, branches, tags | git | git (unchanged) | 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 | 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 | 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 | 72 | Work claims | (impossible) | SQLite lease rows, atomic server operation over SSH exec |
73 | Merge recording | sync-time scan on every clone | push-time scan on the server | 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 | 74 | Auth / identity | trust allowlist file, distributed by hand | server-side `authorized_keys`; agents are keypairs authorized once; SSH principal = identity |
75 | Conflict resolution | timestamp-wins DAG replay | none needed — the DB serializes writes | 75 | Conflict resolution | timestamp-wins DAG replay | none needed — the DB serializes writes |
76 | Event schema | frozen forever (signed) | migratable (DB), versioned (webhooks) | 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 | 77 | Portability of the record | live sync of `refs/collab/*` | optional one-way `export` to `refs/collab/*` as an archival artifact |
@@ -94,7 +94,8 @@ Every mutation on the forge emits one internal event — `issue.filed`,
94 3. **IRC bridge** — the human feed. Announcements mirrored into channels; 94 3. **IRC bridge** — the human feed. Announcements mirrored into channels;
95 scrollback becomes an ambient audit trail. 95 scrollback becomes an ambient audit trail.
96 96
97 Rule that keeps the seams clean: **machines talk HTTP, humans talk IRC, and 97 Rule that keeps the seams clean: **machines talk to the forge directly
98 (SSH exec in, webhooks out), humans talk IRC, and
98 the bridge translates only at the edges.** IRC carries intent only when a 99 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 human is speaking (DM intake, explicit commands like `claim`). No
100 machine-originated action ever rides the channel; automation consumes 101 machine-originated action ever rides the channel; automation consumes
@@ -113,11 +114,23 @@ WHERE id = :task
113 AND (lease_holder IS NULL OR lease_expires < :now); 114 AND (lease_holder IS NULL OR lease_expires < :now);
114 ``` 115 ```
115 116
116 One row affected = claimed (HTTP 200); zero = lost the race (HTTP 409). The 117 One row affected = claimed; zero = lost the race. The database's write
117 database's write serialization is the arbiter; no lock service. 118 serialization is the arbiter; no lock service.
119
120 **Transport and auth: SSH exec verbs, not HTTP.** The server already has an
121 authenticated command channel — the `collab-release` pattern: the client
122 shells out to `ssh`, `auth_publickey` maps the key to a principal, and
123 per-repo policies authorize the verb. Leases follow it (`collab-lease
124 acquire '<repo>' '<issue>' [--ttl <secs>]`), and `lease_holder` is the SSH
125 principal. This sets the rule for every later phase: **mutations ride SSH
126 (authenticated by key), HTTP stays read-only (web UI + git data), webhooks
127 are outbound and signed.** One auth system for humans and agents alike — an
128 agent's keypair is an `authorized_keys` line, and it already needs that key
129 to clone. If a non-SSH client ever matters, mint bearer tokens over SSH
130 (`collab-token new`); not before.
118 131
119 - **TTL + heartbeat.** Agents die mid-task (VM reaped, OOM, wedged). Leases 132 - **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 133 expire on their own; live holders renew (`collab-lease renew`). Humans
121 get an open-ended lease that renders as "assigned" — one mechanism, two 134 get an open-ended lease that renders as "assigned" — one mechanism, two
122 tenure policies. 135 tenure policies.
123 - **Fencing tokens.** `lease_token` increments on every acquire. Every write 136 - **Fencing tokens.** `lease_token` increments on every acquire. Every write
@@ -125,7 +138,8 @@ database's write serialization is the arbiter; no lock service.
125 token; the forge rejects stale tokens. Expiry alone protects liveness; 138 token; the forge rejects stale tokens. Expiry alone protects liveness;
126 fencing protects correctness against zombie workers waking up post-expiry. 139 fencing protects correctness against zombie workers waking up post-expiry.
127 - **Idempotent acquire.** Re-claiming a lease you already hold succeeds, so a 140 - **Idempotent acquire.** Re-claiming a lease you already hold succeeds, so a
128 client retrying a lost HTTP response does not deadlock against itself. 141 client retrying after a dropped connection does not deadlock against
142 itself.
129 143
130 Scale is a non-issue by construction: a lease op costs ~1ms of forge time 144 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 145 against tasks costing minutes of VM work, and contention is per-task (N racers
@@ -168,7 +182,7 @@ on behalf of alex, via DM*.
168 182
169 Issues belong to a repo; references are qualified (`mux#4f2a91`). `relates_to` 183 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 184 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`), 185 finds a side-issue in another repo files it (one exec verb / API call),
172 links it, and stays on task — the default policy is **file, link, continue**; 186 links it, and stays on task — the default policy is **file, link, continue**;
173 the lease system keeps workers honest about what they claimed. 187 the lease system keeps workers honest about what they claimed.
174 188
@@ -199,7 +213,8 @@ Nothing in this repo may depend on any of these existing.
199 - The Gerrit reconstruction: `patch.rs` diff/interdiff, `timeline.rs`, 213 - The Gerrit reconstruction: `patch.rs` diff/interdiff, `timeline.rs`,
200 revision refs (now server-written), TUI and web rendering. 214 revision refs (now server-written), TUI and web rendering.
201 - `merge_scan.rs` (relocated to push time), `hooks.rs` (unchanged role). 215 - `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. 216 - The CLI: same verbs, thin client over SSH exec (the `release.rs` pattern)
217 instead of a local ref-writer.
203 - `event.rs` vocabulary: becomes the webhook payload schema. 218 - `event.rs` vocabulary: becomes the webhook payload schema.
204 - `signing.rs`: signs webhook payloads; agent keys remain Ed25519. 219 - `signing.rs`: signs webhook payloads; agent keys remain Ed25519.
205 220
@@ -216,7 +231,7 @@ Nothing in this repo may depend on any of these existing.
216 - SQLite schema: issues, comments, patches, revisions, reviews, leases, 231 - SQLite schema: issues, comments, patches, revisions, reviews, leases,
217 webhook subscriptions. 232 webhook subscriptions.
218 - The lease endpoints (~50 lines + fencing checks on write paths). 233 - The lease endpoints (~50 lines + fencing checks on write paths).
219 - The HTTP API surface for issues/comments/reviews. 234 - The SSH exec verbs for issues/comments/reviews/leases (the mutation API).
220 - `refs/for/<base>` receive-hook handling. 235 - `refs/for/<base>` receive-hook handling.
221 - The event bus + webhook dispatcher. 236 - The event bus + webhook dispatcher.
222 - `git-collab export`: one-way materialization of the record into 237 - `git-collab export`: one-way materialization of the record into
@@ -231,10 +246,11 @@ useful the day it lands, and none blocks on the companions.
231 246
232 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks. 247 1. **Leases.** Schema + acquire/renew/release endpoints + fencing checks.
233 The agent loop works against the forge as it exists today; humans get 248 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 249 `issue claim`. Transport is the `collab-lease` SSH exec verb; no new
250 auth machinery. (The seam the whole agent story hangs on, and the one
235 thing git structurally cannot express.) 251 thing git structurally cannot express.)
236 2. **Issues and comments to SQLite.** The API + web UI read/write the DB; 252 2. **Issues and comments to SQLite.** Exec verbs + web UI read/write the
237 a comment becomes one `POST`. Kills the review round-trip burden. Includes 253 DB; a comment becomes one round trip. Kills the review burden. Includes
238 a one-time importer that replays existing `refs/collab/*` DAGs into the 254 a one-time importer that replays existing `refs/collab/*` DAGs into the
239 DB (the current `state.rs` materializer, run once, then retired). 255 DB (the current `state.rs` materializer, run once, then retired).
240 3. **Server-maintained revision refs + `refs/for/<base>`.** The receive hook 256 3. **Server-maintained revision refs + `refs/for/<base>`.** The receive hook