Swarm Swarm

Teams (M8) — design

Status: living

Why a design doc first

M8 touches the three things every later feature depends on — how config layers merge, who a ledger record belongs to, and who may call the daemon. Getting them wrong is expensive to reverse, and M7 (dispatch, messaging) is landing on the same tables at the same time. This doc records where the code is today and what changes, so M8.1/M8.2 can be built as small PRs that do not disturb M7.

Where the code is today (2026-08-23)

Concern Today Pointer
Config layers defaults ← ~/.swarm/config.toml ← <repo>/.swarm.toml; repo wins for every key; scalars/arrays replace wholesale; no "locked" concept; no org layer core/src/config.ts loadConfig/merge
Rule modes ask | deny | off per rule, first match wins; modes come from store.rulesFor(repoRoot) (30 s cache) core/src/rules.ts, daemon/src/store.ts rulesFor
Daemon auth none; Bun.serve bound to 127.0.0.1; no middleware, no CORS; /v1/fs/ls lists arbitrary dirs daemon/src/bin.ts, daemon/src/app.ts
Client SwarmClient sends only content-type; base URL from daemon.json → SWARM_URL → default port client/src/index.ts, client/src/daemon.ts
Hook shim 400 ms timeout, fails open, never starts the daemon hook/src/bin.ts
Identity free-form strings: claims.owner, resources.owner, processes.owner, handoffs.by; defaults process.env.USER ?? "me", "cli", "dashboard", "daemon", MCP SWARM_OWNER ?? "agent"; gates and incident_acks record no actor; events carry only sessionId cli/src/bin.ts, mcp/src/server.ts, store.ts SCHEMA
Incidents a view over events WHERE type='incident.opened' joined to incident_acks(seq, acked_at) store.ts incidents()
Schema migrations CREATE TABLE IF NOT EXISTS + ad-hoc ensureColumn + meta-flagged backfills; no version number store.ts
Export none; closest is GET /v1/events?since=&full=1 and --json on CLI commands —
Spend rollups by project / model / agent; attribution to a claim by cwd containment; nothing groups by person store.ts spend()/attribution()
doctor checks bun/claude on PATH, daemon health, "any hook of ours installed", MCP registered, gh/glab auth; not per-event coverage, timeouts, or rule downgrades cli/src/bin.ts doctor, cli/src/install.ts status

M8.1 — Policy precedence + tamper detection (free, core)

Layers

defaults  ←  org policy  ←  ~/.swarm/config.toml  ←  <repo>/.swarm.toml
             (locked keys win over everything to the right)
  • Org policy is a third TOML file: ~/.swarm/policy.toml by default, or SWARM_POLICY pointing to a path/URL. It is the only layer that may contain locked. Repo-agnostic stays intact — the target repo is still only ever touched via the optional .swarm.toml.
  • locked is a list of dotted keys in the org layer: locked = ["rules.destructive_git", "rules.protected.ports", "tasks.source"]. merge() gains a post-pass: for every locked key, the org value is reinstated after user/repo merge, and the attempt to override is recorded (see tamper below). Arrays stay replace-wholesale; the union semantics for protected.ports that rulesFor already does for held ports applies only to runtime holds.
  • Rule modes stay ask | deny | off; the org layer cannot introduce new modes, only pin them. A locked off is allowed (an org can explicitly permit something) but doctor warns.
  • loadConfig returns, alongside the merged config, a provenance map { key → layer } and a overridden: [{key, layer, attempted}] list. The dashboard's rule-mode badges and swarm rules show the layer; swarm rules dryrun already replays decisions and gains the same annotation.

Tamper detection

Three signals, all reported by doctor and (when a daemon is running) recorded as incident.opened with rule: "policy":

  1. Hooks — install.status() grows per-event coverage: for each of the 10 hook events, is our entry present with timeout ≥ 5? Missing or shortened → hooks incomplete. The daemon also checks this on SessionStart (it already reads settings to inject context) so the check is continuous, not just on doctor.
  2. Overrides of locked keys — from loadConfig().overridden, recorded once per (repo, key) per daemon lifetime.
  3. Guard disabled — SWARM_GUARD=off in the hook environment is visible to the daemon as a PreToolUse that returns {} without evaluation; when any rule is locked, SWARM_GUARD=off is ignored and an incident is opened.

Fail-closed for locked rules (resolves OQ-3)

The hook shim is allowed one new behaviour: when the daemon is unreachable, it evaluates only locked rules from a cache at ~/.swarm/policy.cache.json that the daemon writes whenever the org layer loads. Unlocked rules still fail open. The cache is a plain JSON of {modes, protectedPorts, writtenAt, sha256}; "signed" in M8.1 means integrity-checked (hash), not cryptographically signed — a signature needs a key distribution story that belongs to M8.3. Shim budget remains 400 ms; the local evaluation reuses guardBash/guardWrite from core (pure, no I/O), so it is one file read.

Decision (2026-08-23): org layer = ~/.swarm/policy.toml / SWARM_POLICY; locked is a list of dotted keys; fail-closed applies to locked rules only, via a hash-checked local cache; cryptographic signing deferred to M8.3.

M8.2 — Actor, auth, audit export (free)

Actor

One shape, used everywhere a record is written:

type Actor =
  | { kind: "human";  id: string }          // OS user or OIDC subject (M8.3); "alice"
  | { kind: "agent";  id: string; session: string }  // claude-code session id
  | { kind: "run";    id: string; session?: string } // spawned run (M3.1)
  | { kind: "daemon"; id: "daemon" }        // auto handoffs, gates the daemon executed

Stored as two columns actor_kind, actor_id (plus the existing session_id where present). Added via ensureColumn to claims, resources, processes, handoffs, gates, incident_acks, sessions; on events as top-level fields of SwarmEvent (actor?: Actor), persisted in the row and on the wire. Existing free-form owner/by strings remain and are back-filled: "cli"/process.env.USER → human, "agent" + session → agent, "dashboard" → human (the desktop user), "daemon"/auto:* → daemon.

How the daemon learns the actor instead of trusting the string: every request carries the token (below); the token is bound to a local principal at install time (swarm install records {token, human: os.userInfo().username, host} in daemon.json). Agent identity comes from the hook payload's session_id / MCP CLAUDE_SESSION_ID, which the daemon already correlates. Until M8.3 this is still "whoever holds the laptop", but it is now the daemon asserting it, not the client — which is what forwarding needs.

Auth token

  • swarm install / first daemon start generates a 32-byte token, stored in ~/.swarm/daemon.json (mode 0600) next to url. SwarmClient, the MCP server and the hook shim read it from there and send Authorization: Bearer ….
  • Daemon middleware: required for every request unless the socket peer is loopback and daemon.auth = "loopback-optional" (the default in 0.x so existing installs keep working). daemon.auth = "required" is what the org policy locks for teams. A wrong token is always 401.
  • The dashboard: / serves the page; app.js fetches /v1/session-token once (loopback-optional only) or gets it via the desktop app shell, and uses fetch-based SSE instead of EventSource so the header can be sent. Cookie fallback is not planned.
  • /v1/fs/ls and /v1/worktrees/open (spawns an editor) become human-only endpoints — they require actor.kind === "human".

Audit

  • An audit view (not a new table): events filtered to ledger-changing types (claim.*, gate.*, permission.*, incident.*, handoff.*, resource.*, process.*, run.*, worktree.*, pr.*, policy.*) with actor. Events are already append-only in practice; M8.2 makes it explicit — prune() keeps audit types indefinitely (today only incidents survive 30 d), and retention becomes [audit] retain_days (default 0 = forever) vs [events] retain_days (default 30).
  • swarm audit export [--since 30d] [--project X] [--format jsonl|csv] → GET /v1/audit?since=&project=&format=. One line per event, raw never included.
  • Redaction on ingest: [privacy] store_prompts = true|false, store_reasoning = true|false, redact = ["AKIA[0-9A-Z]{16}", …] applied in slimForStorage before the row is written. These exist so that forwarding (M8.3) can be configured to send ledger events without any transcript text.
  • Schema versioning lands here because the actor columns are the first change that forwarding depends on: meta.schema_version, a migrations: Array<(db) => void> list in store.ts, and swarm doctor --migrate. Backup/restore stays M8.5.

Decision (2026-08-23): Actor = {kind, id, session?} on every ledger record and every event; bearer token in daemon.json, loopback-optional by default in 0.x; audit is a retained view over events, exported as JSONL; schema versioning starts with M8.2.

M8.3 — Team daemon (paid, packages/team, self-hosted)

Full design, decided 2026-08-24. One deployment = one org. The local daemon stays the only hook target and the only writer of ~/.swarm/swarm.db; the team daemon (swarm-teamd) is a second, separate service that machines forward to and humans log in to. Nothing in the free tier changes behaviour when [team] is absent from config.

Package & license

  • packages/team, published as @ra3orblade/swarm-team, bin swarm-teamd. It imports core (types, rules, pricing, audit) like any other package; nothing in core/daemon/cli imports it back.
  • License: FSL-1.1-ALv2 (Functional Source License 1.1 with Apache-2.0 future license — source-available, competing use restricted, each release converts to Apache-2.0 after two years; this is the current SPDX id of what OQ-15 called "FSL-1.1-Apache-2.0"). A per-package LICENSE.md overrides the repo's Apache-2.0 for this directory only; the root LICENSE is untouched and the README gains a licensing section. tools/build-pkg.ts excludes packages/team from the free @ra3orblade/swarm bundle; the team daemon ships as its own npm package + a bun build --compile binary on GitHub Releases.
  • Pricing stays open (OQ-15); the code being source-available means evaluation needs no purchase.

Storage

Its own SQLite database (~/.swarm/team.db by default, --db to relocate), bun:sqlite, WAL, the same versioned migrate() pattern M8.2a introduced. Postgres is deliberately not in v1: the store lives behind the TeamStore class boundary so a Postgres driver can land later without protocol changes (OQ-18). Tables (prefix t_ omitted here): machines (id, name, token_hash, owner_subject, first_seen, last_seen, version), users (subject, email, name, role), teams + team_members + team_projects (org → team → project; one implicit org per deployment), projects (cluster key → name), events (machine_id, machine_seq, the forwarded audit event; UNIQUE(machine_id, machine_seq)), spend (machine_id, project, day, model, agent, cost, tokens), claims (cluster ledger: project key, task, machine_id, actor, lease), policies (the served org policy + history), tokens (opaque session tokens, hashed).

Wire protocol (/t1/*, bearer-authed)

  • POST /t1/ingest — batched forwarded records {machine, seq, kind: "event"|"spend", body}; idempotent by (machine_id, seq); returns the high-water mark so the sender can prune.
  • POST /t1/claims / DELETE /t1/claims/:key / POST /t1/claims/:key/renew — cluster claim register/release/renew. Register returns 409 {holder} on conflict.
  • GET /t1/policy — the org policy.toml + its ed25519 signature; POST /t1/policy (admin).
  • POST /t1/auth/device / POST /t1/auth/token — device-code login (below); GET /t1/me.
  • GET /t1/state, GET /t1/events (SSE) — the team dashboard's snapshot + live stream.
  • Versioned by path (/t1/); GET /t1/health reports schema + protocol versions.

Identity & roles

  • Humans: swarm login runs an OIDC device-code flow. The team daemon is the OAuth client (issuer + client id configured in teamd, discovered by the CLI via /t1/auth/config) — the local machine never handles refresh tokens. On success teamd verifies the ID token, upserts the user, and issues its own opaque token; the CLI stores it in ~/.swarm/team-token (0600). actor.kind = "human" ids become the OIDC subject (OQ-17's "record both" holds: the OS username stays in the local ledger, the subject rides on forwarded records).
  • Machines: on first swarm login the local daemon generates a machine id + token, registered with teamd and bound to the logged-in user (OQ-19: shared machines re-bind on re-login). Forwarding authenticates with the machine token; humans with their own.
  • Roles: viewer (dashboards, exports), developer (own machine's forwarding, cluster claims, gate/handoff writes), admin (org policy, members/roles, retention, machine revocation). Stored in users.role, settable only by admins; the first user becomes admin.

Forwarding (local daemon side, free — the seam ships in daemon)

  • Config: [team] url = "https://swarm.example.internal", forward = ["ledger", "cost"] (default; "transcripts" — session titles + last-text — strictly opt-in and still passes the M8.2c redaction), interval = 5 seconds.
  • An outbox table in swarm.db (seq, kind, payload, attempts, sent_at NULL). Audit-type events (the M8.2c list) and daily spend rollups are enqueued at write time when [team].url is set; a background flush batches to /t1/ingest with exponential backoff, at-least-once, pruning on ack. The hook path never waits on the network — enqueue is one local INSERT.
  • Cluster claims: store.claim keeps its local fail-closed semantics, then registers upstream asynchronously. An upstream 409 revokes the local claim (worktree untouched), opens a claim_conflict incident, and notifies. Renew forwards on the existing auto-renew cadence; offline machines keep working — cluster claims degrade to advisory when unreachable, recorded as team.offline events.
  • Policy: when [team].url is set the daemon fetches /t1/policy (signed) into ~/.swarm/policy.toml + verifies the signature with the pinned public key from login; the M8.1c policy.cache.json finally gets its cryptographic signature (closes the OQ-3 deferral).

Team dashboard

Decision (2026-08-24, M8.3e): reuse the web package's components, not its app: teamd serves its own page (packages/team/public) that loads the shared viz.js (and the docs/12 design tokens) from packages/web/public. The local app.js is built around sessions and transcripts the team daemon deliberately never has — forking it would have meant hiding most views; a dedicated page over /t1/state is smaller and honest about what a team sees.

The page shows machines (live/quiet by last forward), cluster claims (holder, machine, lease), spend — today, by project, by user (the chargeback surface M8.4 exports), by machine, by day — and the forwarded audit-event feed. GET /t1/state is the snapshot; GET /t1/events (SSE) pings on ingest/claim changes and the page refetches, debounced. Auth: paste the token from ~/.swarm/team-token once (?token= → localStorage); the shell itself is static and data-free.

Settings (M13.13) — admins get a section below the dashboard: members with a role picker (POST /t1/users/:subject/role) and remove (DELETE /t1/users/:subject, which ends their sessions; their machines stay until revoked), machines with revoke (DELETE /t1/machines/:id, its token stops working; what it forwarded stays), and the org policy as an editor that signs and publishes through POST /t1/policy. A team always keeps one admin: demoting or removing the last is refused.

Decision (2026-09-18, M13.13): in token mode the shared secret is the admin credential. One secret makes every holder equal — there are no member accounts to rank — so the holder may use every admin route (policy, budgets, Settings), and a policy set that way is recorded as by "shared secret". Revoking a machine there removes it from the list but cannot lock it out: the answer says to rotate the secret. Roles only mean something in oidc mode.

Discovery (M13.13) — swarm-teamd announces itself on the LAN as _swarm-team._tcp.local (mDNS / DNS-SD: PTR, SRV, TXT v + auth, A; core/src/mdns.ts, packages/team/src/mdns.ts) whenever it listens beyond loopback, and says goodbye on shutdown; SWARM_TEAM_MDNS=0 turns it off. The local daemon's GET /v1/team/discover sends one legacy-unicast query and lists what answers within 1.5 s, and the Team panel shows it under Join. Name, address, port and auth mode only — never a secret, so a token team still needs its invite link. Checked against macOS's own mDNSResponder in both directions (dns-sd -B/-L sees the announcement; a dns-sd -R service is discovered).

Order of work (one PR each, gates green, roadmap flipped on the last)

  1. M8.3a scaffold: packages/team + FSL LICENSE.md + README licensing note, config, db + migrations, /t1/health, build-pkg exclusion, swarm-teamd bin + compile script.
  2. M8.3b forwarding: outbox + [team] config + flush loop in daemon; /t1/ingest (idempotent) + machines table in team; swarm doctor shows forward lag.
  3. M8.3c auth: device-code flow end-to-end (swarm login, /t1/auth/*, opaque tokens, roles middleware, first-user-admin), machine registration.
  4. M8.3d cluster claims: register/renew/release/conflict-revoke + claim_conflict incident; offline degradation.
  5. M8.3e team dashboard: /t1/state + /t1/events SSE, machine/user dimensions in web.
  6. M8.3f policy serving + signing (ed25519), swarm install --config-url handshake (the M8.5 fleet-install hook); flip M8.3 ✅.

Order of work

  1. M8.1a core/config.ts: org layer + locked + provenance, unit-tested on an empty folder. (~1 PR)
  2. M8.1b install.status() per-event coverage; doctor + SessionStart checks; incident.opened {rule:"policy"}. (~1 PR)
  3. M8.1c policy cache + shim fail-closed for locked rules. (~1 PR, touches hook)
  4. M8.2a Actor type + columns + migration list + back-fill. (~1 PR, coordinate with M7.6 messages table)
  5. M8.2b token + middleware + client/MCP/hook headers + dashboard fetch-SSE. (~1–2 PRs)
  6. M8.2c audit view, retention split, export, redaction. (~1 PR)

Each step passes the full gate (test, typecheck, lint, smoke, docs:check) and flips its roadmap row the same turn.

Open questions raised here

  • OQ-16 Should daemon.auth = "required" become the default once 1.0 ships, and what is the migration story for the Tauri app (which talks to the daemon over HTTP)?
  • OQ-17 Human identity before OIDC: OS username is spoofable on a shared box; is git config user.email a better local principal, or is "the laptop owner" good enough for the free tier?
  • OQ-18 Team daemon storage: SQLite v1, Postgres only when a real deployment outgrows one node — the /t1/* protocol must survive the swap.
  • OQ-19 Cluster project identity (normalized origin remote URL as the key) and machine tokens on shared boxes (bound to the last swarm login).

Source: docs/14-teams.md · v0.16.0.