Teams (M8) — design
Status: livingWhy 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.tomlby default, orSWARM_POLICYpointing to a path/URL. It is the only layer that may containlocked. Repo-agnostic stays intact — the target repo is still only ever touched via the optional.swarm.toml. lockedis 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 forprotected.portsthatrulesForalready 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 lockedoffis allowed (an org can explicitly permit something) butdoctorwarns. loadConfigreturns, alongside the merged config, aprovenancemap{ key → layer }and aoverridden: [{key, layer, attempted}]list. The dashboard's rule-mode badges andswarm rulesshow the layer;swarm rules dryrunalready 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":
- Hooks —
install.status()grows per-event coverage: for each of the 10 hook events, is our entry present withtimeout ≥ 5? Missing or shortened → hooks incomplete. The daemon also checks this onSessionStart(it already reads settings to inject context) so the check is continuous, not just ondoctor. - Overrides of locked keys — from
loadConfig().overridden, recorded once per (repo, key) per daemon lifetime. - Guard disabled —
SWARM_GUARD=offin the hook environment is visible to the daemon as aPreToolUsethat returns{}without evaluation; when any rule is locked,SWARM_GUARD=offis 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;lockedis 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 tourl.SwarmClient, the MCP server and the hook shim read it from there and sendAuthorization: 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.jsfetches/v1/session-tokenonce (loopback-optional only) or gets it via the desktop app shell, and usesfetch-based SSE instead ofEventSourceso the header can be sent. Cookie fallback is not planned. /v1/fs/lsand/v1/worktrees/open(spawns an editor) become human-only endpoints — they requireactor.kind === "human".
Audit
- An
auditview (not a new table):eventsfiltered to ledger-changing types (claim.*,gate.*,permission.*,incident.*,handoff.*,resource.*,process.*,run.*,worktree.*,pr.*,policy.*) withactor. 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(default0= 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,rawnever included.- Redaction on ingest:
[privacy] store_prompts = true|false,store_reasoning = true|false,redact = ["AKIA[0-9A-Z]{16}", …]applied inslimForStoragebefore 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, amigrations: Array<(db) => void>list instore.ts, andswarm doctor --migrate. Backup/restore stays M8.5.
Decision (2026-08-23):
Actor = {kind, id, session?}on every ledger record and every event; bearer token indaemon.json, loopback-optional by default in 0.x; audit is a retained view overevents, 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, binswarm-teamd. It importscore(types, rules, pricing, audit) like any other package; nothing incore/daemon/cliimports 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.mdoverrides the repo's Apache-2.0 for this directory only; the rootLICENSEis untouched and the README gains a licensing section.tools/build-pkg.tsexcludespackages/teamfrom the free@ra3orblade/swarmbundle; the team daemon ships as its own npm package + abun build --compilebinary 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 returns409 {holder}on conflict.GET /t1/policy— the orgpolicy.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/healthreports schema + protocol versions.
Identity & roles
- Humans:
swarm loginruns 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 loginthe 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 inusers.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 = 5seconds. - An
outboxtable inswarm.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].urlis set; a background flush batches to/t1/ingestwith exponential backoff, at-least-once, pruning on ack. The hook path never waits on the network — enqueue is one local INSERT. - Cluster claims:
store.claimkeeps its local fail-closed semantics, then registers upstream asynchronously. An upstream409revokes the local claim (worktree untouched), opens aclaim_conflictincident, and notifies. Renew forwards on the existing auto-renew cadence; offline machines keep working — cluster claims degrade to advisory when unreachable, recorded asteam.offlineevents. - Policy: when
[team].urlis set the daemon fetches/t1/policy(signed) into~/.swarm/policy.toml+ verifies the signature with the pinned public key from login; the M8.1cpolicy.cache.jsonfinally 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 sharedviz.js(and the docs/12 design tokens) frompackages/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/stateis 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
tokenmode 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 inoidcmode.
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)
- M8.3a scaffold:
packages/team+ FSL LICENSE.md + README licensing note, config, db + migrations,/t1/health, build-pkg exclusion,swarm-teamdbin + compile script. - M8.3b forwarding: outbox +
[team]config + flush loop indaemon;/t1/ingest(idempotent) + machines table inteam;swarm doctorshows forward lag. - M8.3c auth: device-code flow end-to-end (
swarm login,/t1/auth/*, opaque tokens, roles middleware, first-user-admin), machine registration. - M8.3d cluster claims: register/renew/release/conflict-revoke +
claim_conflictincident; offline degradation. - M8.3e team dashboard:
/t1/state+/t1/eventsSSE, machine/user dimensions inweb. - M8.3f policy serving + signing (ed25519),
swarm install --config-urlhandshake (the M8.5 fleet-install hook); flip M8.3 ✅.
Order of work
- M8.1a
core/config.ts: org layer +locked+ provenance, unit-tested on an empty folder. (~1 PR) - M8.1b
install.status()per-event coverage;doctor+SessionStartchecks;incident.opened {rule:"policy"}. (~1 PR) - M8.1c policy cache + shim fail-closed for locked rules. (~1 PR, touches
hook) - M8.2a
Actortype + columns + migration list + back-fill. (~1 PR, coordinate with M7.6messagestable) - M8.2b token + middleware + client/MCP/hook headers + dashboard fetch-SSE. (~1–2 PRs)
- 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.emaila 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
originremote URL as the key) and machine tokens on shared boxes (bound to the lastswarm login).
Source: docs/14-teams.md · v0.16.0.