a73x

docs/superpowers/plans/2026-09-05-issue-leases.md

Ref:   Size: 23.5 KiB   History

# Issue Leases Implementation Plan

> **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.

**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`.

**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.

**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.

**Tech Stack:** Rust 2021, russh (SSH server), rusqlite (new dep, `bundled` feature), git2, serde_json, chrono.

**Spec:** `docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md`

**Codebase facts the implementer needs:**

- 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::…`.
- 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.
- 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).
- Replies go through `reply_and_close(session, channel, message, exit_code)` (`session.rs:1104`).
- 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`.
- Repo path safety: `resolve_repo_path(repos_dir, requested)`; repo entry + policy via `crate::repos::entry_for_path(&self.config.repos_dir, resolved_path)`.
- 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.
- Server config: `src/server/config.rs::ServerConfig` (serde `Deserialize` from TOML, `#[serde(default = "…")]` pattern per field; see `max_release_size`).
- 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`.
- 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.
- CLI subcommands: `src/cli.rs` (`clap` derive; `IssueCmd` exists). Dispatch lives in `src/main.rs`.
- 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.
- Time: store unix epoch seconds (`i64`) in SQLite; render RFC3339 in JSON via `chrono::DateTime::from_timestamp`.
- Run tests with `cargo test`, lints with `cargo clippy --all-targets`.
- Commit messages: plain imperative style ("Enforce one review vote per author per revision").

**Exit-code contract for `collab-lease` (used by CLI and agents):**

| Code | Meaning |
|---|---|
| 0 | success (including idempotent re-acquire/release) |
| 1 | error: bad arguments, unknown/unauthorized repo, unknown issue, closed issue |
| 4 | lease conflict: held by someone else (acquire), or not the holder (renew) |

All success output is one JSON object on stdout. Errors are one `error: …` line.

---

### Task 1: Lease store in the server bin

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.

**Files:**
- Create: `src/server/leases.rs`
- Modify: `src/server/main.rs` (add `mod leases;`), `Cargo.toml`

- [ ] **Step 1: Add the dependency**

Run: `cargo add rusqlite --features bundled`

- [ ] **Step 2: Write the module skeleton and failing tests**

Create `src/server/leases.rs`:

```rust
//! Work leases on issues: the one collaboration primitive git cannot
//! express (atomic claim with TTL). One SQLite database per server,
//! `(repo, issue_id)` primary key, lazy expiry. See
//! docs/superpowers/specs/2026-09-05-server-authoritative-collab-design.md.

use std::path::Path;

use rusqlite::Connection;

/// A live or expired lease row.
#[derive(Debug, Clone, PartialEq)]
pub struct Lease {
    pub repo: String,
    pub issue_id: String,
    pub holder: String,
    pub token: i64,
    pub acquired_at: i64,
    /// None = open-ended (human assignment).
    pub expires_at: Option<i64>,
}

impl Lease {
    pub fn live(&self, now: i64) -> bool {
        self.expires_at.map(|e| e > now).unwrap_or(true)
    }
}

#[derive(Debug, PartialEq)]
pub enum Acquire {
    /// Caller now holds the lease (fresh tenure, or idempotent re-acquire).
    Acquired { token: i64, expires_at: Option<i64> },
    /// A different holder has a live lease.
    Held { holder: String, expires_at: Option<i64> },
}

#[derive(Debug, PartialEq)]
pub enum Renew {
    Renewed { token: i64, expires_at: Option<i64> },
    /// No live lease held by caller (expired, released, or someone else's).
    NotHolder { holder: Option<String> },
}

#[derive(Debug, PartialEq)]
pub enum Release {
    /// Row deleted, or there was nothing to release (idempotent success).
    Released,
    /// A different holder has a live lease; refuse.
    NotHolder { holder: String },
}

/// Open (creating if needed) the lease database and ensure the schema.
pub fn open(path: &Path) -> rusqlite::Result<Connection> {
    todo!()
}

pub fn acquire(
    conn: &Connection,
    repo: &str,
    issue_id: &str,
    holder: &str,
    ttl_secs: Option<i64>,
    now: i64,
) -> rusqlite::Result<Acquire> {
    todo!()
}

pub fn renew(
    conn: &Connection,
    repo: &str,
    issue_id: &str,
    holder: &str,
    ttl_secs: Option<i64>,
    now: i64,
) -> rusqlite::Result<Renew> {
    todo!()
}

pub fn release(
    conn: &Connection,
    repo: &str,
    issue_id: &str,
    holder: &str,
    now: i64,
) -> rusqlite::Result<Release> {
    todo!()
}

/// A live lease on the issue, if any.
pub fn current(
    conn: &Connection,
    repo: &str,
    issue_id: &str,
    now: i64,
) -> rusqlite::Result<Option<Lease>> {
    todo!()
}

/// All live leases in a repo, oldest first.
pub fn list(conn: &Connection, repo: &str, now: i64) -> rusqlite::Result<Vec<Lease>> {
    todo!()
}
```

Schema (inside `open`, `CREATE TABLE IF NOT EXISTS`; also set `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout=5000`):

```sql
CREATE TABLE IF NOT EXISTS leases (
    repo        TEXT NOT NULL,
    issue_id    TEXT NOT NULL,
    holder      TEXT,           -- NULL = free; the row keeps its token
    token       INTEGER NOT NULL,
    acquired_at INTEGER NOT NULL,
    expires_at  INTEGER,
    PRIMARY KEY (repo, issue_id)
);
```

Semantics to implement (each inside ONE transaction — `BEGIN IMMEDIATE` via `conn.transaction_with_behavior(TransactionBehavior::Immediate)` — so concurrent connections serialize):

- `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`.
- `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).
- `release`: live row held by another → `NotHolder`. Otherwise **free** the row (`holder = NULL`, `expires_at = NULL`), keeping its `token` → `Released` (idempotent: releasing nothing succeeds — a client retrying after a dropped connection must not fail).
- `current`/`list`: return only held rows, by **filtering** on `holder IS NOT NULL AND (expires_at IS NULL OR expires_at > now)`.

> **Rows are a per-issue tenure ledger and are never deleted.** An earlier
> draft of this plan said `release` should delete the row and that
> `current`/`list` should opportunistically reap expired ones. Both reset
> `token` to 1 for the next holder, which destroys the only property a
> fencing token has: tenure 1's zombie could later present token 1 to a
> fresh tenure 1 and pass a check it must fail. The reaping path was the
> worse of the two — merely *reading* the lease list would have silently
> reset fencing. `holder IS NULL` means free; `token` only ever increments.
> The e2e test `release_frees_the_issue_and_bumps_the_next_tenure` is what
> caught it.

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):

```text
acquire_free_issue_returns_token_1
acquire_sets_expiry_from_ttl / acquire_without_ttl_is_open_ended
second_acquire_by_other_holder_returns_held
reacquire_by_holder_is_idempotent_same_token_new_expiry
acquire_after_expiry_takes_over_and_bumps_token
renew_extends_expiry_keeps_token
renew_by_non_holder_returns_not_holder
renew_after_expiry_returns_not_holder
release_by_holder_frees_the_issue / release_idempotent_when_absent
release_by_non_holder_refused / release_of_expired_lease_by_anyone_succeeds
released_issue_is_free_to_anyone
list_shows_only_live_leases / current_none_after_expiry
open_ended_lease_never_expires (large `now`)
tenure_token_monotonic_across_holders (a→expire→b→expire→a: tokens 1,2,3)
token_does_not_reset_after_release
token_does_not_reset_after_expiry_and_reads (the reaping bug, pinned)
```

- [ ] **Step 3: Run tests to verify they fail**

Run: `cargo test leases::`
Expected: FAIL (panic at `todo!()`)

- [ ] **Step 4: Implement**

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.

- [ ] **Step 5: Run tests to verify they pass**

Run: `cargo test leases::`

- [ ] **Step 6: Commit**

`Add a server-side lease store for issue claims`

---

### Task 2: `collab_db` server config

**Files:**
- Modify: `src/server/config.rs`

- [ ] **Step 1: Write the failing tests**

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.

Design: an `Option<PathBuf>` field (serde default `None`) plus an accessor, because the default depends on another field:

```rust
#[serde(default)]
pub collab_db: Option<PathBuf>,

pub fn collab_db_path(&self) -> PathBuf {
    self.collab_db
        .clone()
        .unwrap_or_else(|| self.repos_dir.join("collab.db"))
}
```

- [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server config::`

- [ ] **Step 3: Implement** (as above)

- [ ] **Step 4: Run tests to verify they pass**

- [ ] **Step 5: Commit**

`Add a collab_db server config option defaulting beside the repos`

---

### Task 3: Parse `collab-lease` exec commands

**Files:**
- Modify: `src/server/ssh/session.rs` (`ExecCommand`, `parse_exec_command`, tests)

- [ ] **Step 1: Write the failing tests**

Next to the `collab-release` parser tests, following their shape (quoting, empty tokens, unknown verbs, extra args → `None`):

```text
parse_lease_acquire            collab-lease acquire 'r.git' 'a1b2c3d4'
parse_lease_acquire_with_ttl   collab-lease acquire 'r.git' 'a1b2c3d4' --ttl 300
parse_lease_renew_requires_ttl collab-lease renew 'r.git' 'a1b2c3d4' --ttl 300 (without --ttl → None)
parse_lease_release            collab-lease release 'r.git' 'a1b2c3d4'
parse_lease_list               collab-lease list 'r.git'
rejects: unknown subverb, empty repo/issue, non-numeric or zero/negative ttl,
         trailing args, unclosed quotes
```

New variants:

```rust
#[derive(Debug, Clone, PartialEq)]
pub enum LeaseCmd {
    Acquire { repo: String, issue: String, ttl_secs: Option<i64> },
    Renew { repo: String, issue: String, ttl_secs: i64 },
    Release { repo: String, issue: String },
    List { repo: String },
}
```

`ExecCommand` gains `Lease(LeaseCmd)`; its `repo()` accessor covers it. `--ttl <n>` parses as `i64`, must be `> 0`.

- [ ] **Step 2: Run tests to verify they fail** — `cargo test --bin git-collab-server parse_lease`

- [ ] **Step 3: Implement** — extend `parse_exec_command` after the `collab-release` block, same `shell_tokens` path.

- [ ] **Step 4: Run tests to verify they pass** (including the existing release parser suite — no regressions)

- [ ] **Step 5: Commit**

`Parse collab-lease exec commands`

---

### Task 4: SSH dispatch, authorization, and end-to-end server tests

**Files:**
- Modify: `src/server/ssh/session.rs` (dispatch), `src/server/main.rs`/wherever `SshHandler` gets config (it already holds `self.config`)
- Create: `tests/lease_server_test.rs`

- [ ] **Step 1: Write the failing e2e tests**

`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.

```text
acquire_open_issue_succeeds            status=acquired, token=1, expires_at=null
acquire_with_ttl_reports_expiry        RFC3339 expires_at
acquire_conflict_reports_holder        second key → exit 4, status=held, holder names key 1
reacquire_same_key_is_idempotent       exit 0, same token
renew_extends / renew_wrong_key_exit_4
release_then_other_key_can_acquire     token increments
acquire_by_issue_id_prefix             short prefix resolves; JSON carries the full id
acquire_unknown_issue_exit_1
acquire_closed_issue_exit_1            (close_issue + push first)
unknown_repo_and_unauthorized_are_identical  same NOT_FOUND string, exit 1 (mirror release test)
list_shows_live_leases_json
read_only_principal_cannot_acquire     policy write required (exit 1, NOT_FOUND)
```

- [ ] **Step 2: Run tests to verify they fail** — `cargo test --test lease_server_test` (dispatch missing: server replies as if the verb is unknown)

- [ ] **Step 3: Implement the dispatch**

`handle_lease_command(&mut self, channel, session, cmd: LeaseCmd, resolved_path, principal, regime)` modeled line-for-line on `handle_release_command`:

1. `entry_for_path` → unknown repo → `NOT_FOUND`, exit 1.
2. `needed = Access::Read` for `List`, `Access::Write` otherwise; authorize per regime exactly as releases do; failure → same `NOT_FOUND`, exit 1.
3. Holder string: `Regime::Governed { name, .. }` → `name`, else `principal`.
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.
5. `leases::open(&self.config.collab_db_path())`, call the store with `now = chrono::Utc::now().timestamp()`, map outcomes:
   - `Acquired`/`Renewed`/`Released` → JSON on stdout, exit 0.
   - `Held`/`NotHolder` → JSON (`status: "held"`, holder, expires_at) — exit 4.
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.

JSON shapes (all include `"issue"` as the FULL id, and RFC3339 timestamps or `null`):

```json
{"status":"acquired","repo":"r.git","issue":"<full>","holder":"<principal>","token":1,"expires_at":null}
{"status":"held","repo":"r.git","issue":"<full>","holder":"<other>","expires_at":"2026-09-05T12:00:00Z"}
{"status":"renewed", …}  {"status":"released","repo":"r.git","issue":"<full>"}
{"leases":[{"issue":"<full>","holder":"…","token":2,"acquired_at":"…","expires_at":null}]}
```

- [ ] **Step 4: Run the e2e tests** — `cargo test --test lease_server_test`

- [ ] **Step 5: Run the full suite** — `cargo test` (release + governance suites must be untouched)

- [ ] **Step 6: Commit**

`Serve issue leases over a collab-lease SSH verb`

---

### Task 5: Extract the shared SSH client helpers

`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.

**Files:**
- Create: `src/remote_ssh.rs`
- Modify: `src/lib.rs` (add `pub mod remote_ssh;`), `src/release.rs`

- [ ] **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.

- [ ] **Step 2: Verify** — `cargo test` (pure refactor: zero behavior change, zero test edits beyond module paths)

- [ ] **Step 3: Commit**

`Extract the SSH remote client helpers from release into remote_ssh`

---

### Task 6: CLI `issue claim` / `unclaim` / `renew` / `claims`

**Files:**
- Create: `src/lease.rs` (lib module: client side)
- Modify: `src/lib.rs`, `src/cli.rs` (`IssueCmd` variants), `src/main.rs` (dispatch)
- Create: `tests/lease_cli_test.rs`

- [ ] **Step 1: Add the CLI surface**

`IssueCmd` gains:

```text
claim <id> [--ttl <secs>] [--remote <name>=origin] [--json]
unclaim <id> [--remote] [--json]
renew <id> --ttl <secs> [--remote] [--json]
claims [--remote] [--json]
```

Note `tests/cli_surface_test.rs` snapshots the CLI surface — update it deliberately, not incidentally.

- [ ] **Step 2: Implement the client module**

`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:

```text
claimed a1b2c3d4 (expires 2026-09-05T12:05:00Z)      exit 0
issue a1b2c3d4 is claimed by <holder>                exit 4 (message on stderr)
```

Exit codes pass through from the remote command (russh delivers the exit status; `run_remote` already surfaces it for releases — keep that behavior).

- [ ] **Step 3: Write failing CLI e2e tests**

`tests/lease_cli_test.rs` against `ServerHarness`: clone over SSH with `GIT_COLLAB_SSH_COMMAND` set (the release CLI tests show the incantation), then:

```text
claim_then_claims_lists_it
claim_conflict_exit_code_4_names_holder   (second key)
unclaim_frees / renew_updates_expiry
claim_json_passthrough_is_server_json
claim_against_repo_without_issue_fails_cleanly
```

- [ ] **Step 4: Run** — `cargo test --test lease_cli_test`, then the full suite.

- [ ] **Step 5: Commit**

`Add issue claim, unclaim, renew and claims CLI commands`

---

### Task 7: Show claims in the web UI

A claim that is invisible gets double-worked around; the issues list and detail pages must show it.

**Files:**
- 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`
- Modify: `tests/web_rendering_test.rs` (or the closest existing web test file)

- [ ] **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.

- [ ] **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.

- [ ] **Step 3: Run the web tests, then the full suite.**

- [ ] **Step 4: Commit**

`Show live issue claims in the web UI`

---

### Task 8: Final verification

- [ ] **Step 1: Full test suite** — `cargo test`
- [ ] **Step 2: Clippy** — `cargo clippy --all-targets`
- [ ] **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.
- [ ] **Step 4: Commit any remaining fixes**

---

## Out of scope for this plan (deliberately)

- **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.
- **Auto-release on merge/close** — needs the push-time merge scanning of phase 3.
- **Any HTTP mutation surface or API tokens** — mutations ride SSH, per the design doc.
- **IRC bot / foreman / eitri integration** — external subscribers; they consume this verb, they don't live here.