# Reason Network, Beacon and the hive: a technical overview

Two recent projects each solve half of a problem that teams running coding agents now have. Reason Network (Reason, Rust, version 0.12.0) connects the agent sessions on a team's machines into a live directory and delivers messages into running sessions, with no server in between. Beacon (Asymptote Labs, Go, version 1.3.45) records what agents do across more than 20 harnesses in one normalized local log and turns reviewed traces into lessons that later sessions can recall.

Parts 1 and 2 describe each system from its code, with a file and line for every statement, and each ends with what the system does not do and where its documentation and code disagree. Part 3 designs a system that combines the two with Freyja's always-on agent: one daemon per machine, a team context graph, memory that travels as signed lessons, and an always-on member that keeps obligations, prepares work while people are away and decides when to interrupt them. Its last design section covers the work between people that a personal agent cannot see: changes that reach other repositories, handoffs nobody picked up, agents stuck on solved problems, and autonomy earned from a record. A companion piece, `hive-vision.html`, presents the same design as an illustrated vision with live animations.

All three parts were written on 2026-10-21 against Reason Network at 0.12.0 and Beacon at commit `99c70908`. Where a repository's documentation and its code disagree, the code wins and the disagreement is listed.

## Contents

- [Part 1: Reason Network, how it works and where it stops](#part-1-reason-network-how-it-works-and-where-it-stops)
- [Part 2: Beacon, from endpoint telemetry to reviewed memory](#part-2-beacon-from-endpoint-telemetry-to-reviewed-memory)
- [Part 3: The hive, a technical design](#part-3-the-hive-a-technical-design)

## Part 1: Reason Network, how it works and where it stops

Reason Network is a Rust daemon, one per computer, that lets coding agents on different teammates' machines see each other and send each other messages without a central server. Lars Talian at Reason wrote it. It started on 2026-10-11 as a commit titled "comms", was renamed tincan the same day, shipped as Reason Network v0.11.0 on 2026-10-20, and was at 0.12.0 later that day (`git log`; `Cargo.toml:3`). Of 190 commits, 165 are Lars's. The crate has about 14.9K lines of Rust under `src/`, about 12K of them outside the inline `#[cfg(test)]` modules, plus a 143-line opencode plugin and a 71-line pi extension in TypeScript.

Every statement below cites the file and line I read. "DESIGN" means `DESIGN.md` in the repo. Where DESIGN and the code disagree I follow the code and list the disagreement at the end.

### 1. The problem it names

DESIGN keeps its case for novelty narrow. Around 15 projects from 2026 let agents message each other, but almost all of them are single-machine, single-person or Claude-only (`DESIGN.md:15-16`). The gap it targets is "a team directory of live sessions with context (person, machine, agent, repo, branch, busy/idle, what it is doing), so an agent can pick the right recipient instead of broadcasting and hoping. That is the core of this project; messaging is the easy part" (`DESIGN.md:23-25`).

That framing explains most of the design. The directory is live and derived (presence, git state, transcript tails), not curated. The message path is deliberately thin: one envelope, one recipient, an ack, an outbox. A large share of the code goes into the hard part DESIGN names, "Getting messages into agents" (`DESIGN.md:218-220`), meaning per-harness injection.

Status as documented: milestones M0 to M3 are built and tested on macOS with two machines over iroh and real Claude Code and Codex sessions; M4 is not started (`DESIGN.md:10-11`).

### 2. Team model

```
team ──< person ──< machine ──< session
```

DESIGN defines four nouns (`DESIGN.md:29-40`):

- **Team**: a named group with a signed membership roster.
- **Person**: a human who owns one or more machines. A person is not an address.
- **Machine**: one daemon with one iroh key; its identity is its `EndpointId`, an ed25519 public key.
- **Session**: one live agent conversation, with a name, agent kind, cwd, repo, branch, status, a one-line `doing`, and a delivery tier.

The daemon takes its own person and machine name from the signed roster, not from the config file, once it is in a team (`src/daemon/mod.rs:181-185`). The directory does the same for teammates: when it lists a peer's sessions it overwrites `person`, `machine` and `address` with what the roster says about that endpoint, ignoring what the peer's presence reported (`src/daemon/directory.rs:240-245`).

**Why a person is not an address.** If an agent sends to a bare person name, the resolver refuses and lists that person's live sessions instead: "messages go to agent sessions; alice's are: …" (`src/daemon/directory.rs:395-407`). The only exception is a pop-up: with `notify`, an agent may address its own user, which shows a desktop notification on each of that user's online machines and delivers nothing to any session (`src/daemon/mail.rs:130-169`). A receiving machine accepts a session-less message only if it comes from one of the recipient's own machines (`src/daemon/mail.rs:482-489`). The code gives no explicit reason. The effect is that every message lands in a conversation that has context, and that a person's attention is reached only through their own agent or their own machine.

### 3. Addressing and naming

| Address | Resolves to | Source |
|---|---|---|
| `alice/api-cursor` | one session | `src/daemon/directory.rs:382-394` |
| `alice/api-cursor@xps` | that session on machine `xps` | `src/daemon/directory.rs:345-351` |
| `api-cursor` | a session, if the name is unique in the team | `src/daemon/directory.rs:408-409` |
| `alice/*` | every live session of alice's | `src/daemon/directory.rs:386-388` |
| `repo:acme/api`, `repo:api` | every live session whose repo equals it or ends in `/api` | `src/daemon/directory.rs:374-381` |
| `*` | every live session | `src/daemon/directory.rs:372-373` |

When several sessions share a name the resolver prefers the live one; if more than one is live it fails with "ambiguous" and lists `person/name@machine` options (`src/daemon/directory.rs:417-432`). A group never includes the sender's own session (`src/daemon/directory.rs:352`). Resolution can also target offline sessions, both local ones remembered in SQLite and remote ones a peer lists under `known`, so mail can queue for them (`src/daemon/directory.rs:298-331`).

**Names.** A session's default name is `<repo-root-or-dir>-<agent>`, slugged (`src/agent.rs:181-186`), with `-2`, `-3` on collision (`src/daemon/sessions.rs:312-331`). Names are unique across all of one person's machines, because `pick_name` treats names in use on the person's other machines as taken (`src/daemon/sessions.rs:92-101`). A session cannot take the person's own name (`src/daemon/sessions.rs:112`).

**Inheritance across restarts.** `pick_name` first reuses the name previously stored for the same session key; failing that it takes the default base name if no live session holds it (`src/daemon/sessions.rs:314-329`). Because mail is stored by address (`person/name`), a new session that takes a stale name immediately sees that name's unread mail: registration reads `pending(&address)` and delivers anything with `wake` set (`src/daemon/sessions.rs:84-89`, `src/daemon/sessions.rs:26-33`). DESIGN states the purpose: "tell the api agent X" survives restarts (`DESIGN.md:56-58`). Renaming a session moves its unread mail to the new address (`src/store.rs:369-378`).

**Group guard.** The daemon refuses a group address from any sender whose address does not end in `/terminal`: "agents message one session, not a group: pick it with list_team, or ask your user" (`src/daemon/mail.rs:126-129`). A receiving machine also refuses a teammate's agent's group message, which only an older build would send (`src/daemon/mail.rs:518-523`). The CLI decides whether a human is at the terminal by checking whether stderr is a TTY; a script must pass `--from <you>/terminal` explicitly (`src/main.rs:417-423`). DESIGN acknowledges that the guard does not stop an agent from messaging a person's sessions one by one, which the loop guard and inbound policy are meant to cover (`DESIGN.md:316-320`).

### 4. Membership: the signed roster

The roster is a small replicated state in which every entry is signed by a member machine's key. The module header sets the trust assumption in one sentence: "Built for a team of friends who trust each other, not adversarial members" (`src/team.rs:1-3`).

```rust
pub struct Signed<T> { pub body: T, pub by: EndpointId, pub sig: String }          // src/team.rs:18-23

pub struct TeamState {                                                             // src/team.rs:88-103
    pub id: String,                       // 16 random bytes, hex
    pub founder: EndpointId,
    pub meta: Signed<Meta>,               // team name, optional relay, ts
    pub admissions: BTreeMap<String, Signed<Member>>,   // kept forever
    pub members: BTreeMap<String, Signed<Member>>,      // latest entry per machine, LWW
    pub invites: BTreeMap<String, Signed<Invite>>,      // keyed by blake3(secret)
    pub link: Option<Signed<Link>>,       // the reusable team-link secret
    pub attestations: BTreeMap<String, String>,         // endpoint -> sign-in JWS, unsigned by members
}
```

A signature covers the bytes `"tincan/1/{team}/{kind}/"` followed by the entry's JSON (`src/team.rs:25-30`), so an entry cannot be replayed into another team or reinterpreted as another kind.

**Admissions.** A machine counts as admitted only if its admission chain reaches the founder: the founder's self-signed entry is depth 0 and every other admission must be signed by an already-admitted machine (`src/team.rs:262-283`). Admissions are never removed, "so who may sign entries never depends on the order changes arrive in" (`src/team.rs:84-87`). Merging repeats over incoming admissions until nothing new verifies (`src/team.rs:407-430`).

**Members and last-writer-wins.** Member entries merge by `(ts, sig)`, so the signature breaks timestamp ties deterministically (`src/team.rs:112-115`). Merge processes entries in admission-depth order so a person's first machine holds their name before a later machine is checked against it (`src/team.rs:433-436`). Three rules then apply (`src/team.rs:437-459`): a machine may sign its own departure but never its own return (the founder's first entry aside); a machine cannot take a name another active machine holds unless one of that person's machines vouches for it or a Reason sign-in proof ties it to the same account (`src/team.rs:184-190`); and only names made of `a-z`, `0-9` and `-`, at most 64 bytes, are accepted (`src/team.rs:125-130`).

**Clock skew.** Any entry stamped more than a day in the future is refused (`MAX_SKEW_MS = 86_400_000`, `src/team.rs:109-123`). Timestamps are otherwise trusted.

**Invites, links and tickets.** An invite stores only `blake3(secret)` (`src/team.rs:63-75`, `src/team.rs:525-527`). One-time invites are marked `used_by` when redeemed; the team link's invite is `reusable` and works until replaced (`src/team.rs:383-386`). An invite dies with the machine that made it (`src/team.rs:349-351`), and invites that expired over a week ago are pruned (`src/team.rs:518-522`). Removing anyone replaces the team link (`src/daemon/teamops.rs:550-551`), and a machine the state marks as removed is sent the roster without the link or any invite (`src/net.rs:291-299`). The link's ticket packs the team id, the secret, the team name, relay hints, up to two private LAN addresses, and the keys of this machine plus up to two others, preferring online ones (`src/team.rs:569-609`, `src/daemon/teamops.rs:413-430`). Four-word invite codes (about 41 bits) instead derive a temporary iroh key that the inviting machine listens on (`src/invite.rs:1-4`); DESIGN explains that a sign-in never joins with a code, because a code cannot show which team answers it (`DESIGN.md:137-140`).

**Departures.** `remove` signs an inactive entry for the target machines, makes a new link, broadcasts, and drops their connections after two seconds so the news arrives first (`src/daemon/teamops.rs:518-564`, `src/net.rs:486-495`). `leave` refuses unless some teammate is online to hear it, unless forced (`src/daemon/teamops.rs:566-574`). A machine that learns it was removed clears its team state, deletes saved presence, checkpoints the SQLite WAL so the old link is not left on disk, and restarts (`src/daemon/teamops.rs:137-145`).

**Gate.** An incoming connection on the team ALPN from a non-member is closed with code 403. A machine that was a member but has been removed first gets the secret-free state, so it learns why; strangers learn nothing (`src/net.rs:328-340`). Every frame on a live link is rechecked for membership (`src/net.rs:709-711`).

DESIGN states what the trust assumption costs: a removed machine "can no longer connect, but it is not made cryptographically powerless: this is for friends" (`DESIGN.md:124-126`). Any member may invite and remove anyone (`DESIGN.md:124`); there are no roles.

### 5. Sign-in proofs

A Reason sign-in produces a compact JWS stating that an endpoint id belongs to a Reason account and the name that account uses (`src/proof.rs:1-4`). Verification is local: RS256 only, never `none` or HS256 (`src/proof.rs:177-180`), against public keys compiled into the binary (`src/proof.rs:37-41`), with issuer `https://reasonmachines.com/network`, audience `reason-network` and type `reason-network-proof` (`src/proof.rs:12-14`). Proofs over 4 KB, keys under 2048 bits and proofs issued more than a day ahead are refused (`src/proof.rs:19-23`). The token's fields are `sub`, `name` (1 to 40 of `a-z0-9-`), `email`, `endpoint`, `iat`, `exp` (`src/proof.rs:45-56`, `src/proof.rs:151-156`). Debug builds also trust keys in `REASON_NETWORK_TRUST_KEYS`; release builds do not (`src/proof.rs:101-120`).

Proofs ride in `attestations`, outside the signed entries, so older builds pass them on untouched, and every machine verifies each one itself (`src/team.rs:99-102`). The code distinguishes two checks (`src/proof.rs:123-133`):

- `verify` enforces expiry and decides only the "verified" mark shown next to a person (`src/team.rs:218-225`).
- `verify_ignoring_expiry` decides who may hold a name, because "who holds a name mustn't change as time passes (or differ between machines whose clocks do)".

For the same reason, the comment on `KEYS` says never to remove a signing key, even a retired one, since a build without it would drop machines every other build keeps (`src/proof.rs:28-30`).

### 6. Transport over iroh

DESIGN chose Rust because iroh 1.x (QUIC dialed by public key, hole punching, HTTPS relays, mDNS) and rmcp (the official MCP SDK) both exist there (`DESIGN.md:85-86`). The machine key lives in `~/.reason/network/machine.key`, mode 0600 (`src/daemon/mod.rs:161-172`).

**Binding.** Without a team relay, the endpoint uses iroh's N0 preset (n0 DNS address lookup) with Reason's relays and, if `lan` is on, mDNS (`src/net.rs:140-150`). With a team relay it uses the Minimal preset and only that relay, and strips relay URLs that LAN neighbours advertise so a stale teammate cannot pull it onto n0's relays (`src/net.rs:151-193`). DESIGN summarises the result: such a team's machines "never contact n0" (`DESIGN.md:108-112`). Setting a relay first checks it is reachable within 10 seconds (`src/net.rs:162-172`).

**Reason's relays.** The list comes from `https://reasonmachines.com/network/relays.json`, signed with the proof key under its own type and issuer, at most 64 KiB and 16 relays, refreshed every 6 hours (`src/relays.rs:24-38`). Each machine homes on the nearest relay, and peers reach it through that home relay, since relays do not talk to each other (`src/relays.rs:1-3`). The compiled fallback is a single relay, `relay-sfo1.compute.reasonmachines.com` (`src/net.rs:39-41`). Dialing adds every configured relay URL to the peer address (`src/net.rs:563-569`).

**Wire protocol.** The team protocol uses ALPN `tincan/1` and joins use `tincan/join/1`, both kept from the old name because "never change them" (`src/net.rs:31-35`). Each connection carries one bidirectional stream of frames: a 4-byte big-endian length, then JSON, with frames over 4 MiB refused (`src/net.rs:199-216`).

```rust
#[serde(tag = "t", rename_all = "snake_case")]                       // src/net.rs:47-55
pub enum Wire {
    Hello { team: String, proto: u32 },
    State { state: Box<TeamState> },
    Presence { presence: Presence },     // { sessions: Vec<SessionInfo>, known: Vec<String>, features: Vec<String> }
    Msg { msg: Message },
    Ack { id: String, state: String, note: String },
}
```

| Frame | When it is sent | What the receiver does |
|---|---|---|
| `Hello` | first, on every new link (`src/net.rs:670`) | drops the link if the team id differs; logs a protocol mismatch (`src/net.rs:713-720`) |
| `State` | on connect, and rebroadcast whenever a merge changed something (`src/net.rs:671-673`, `src/daemon/teamops.rs:130-134`) | `merge_state` (`src/net.rs:721`) |
| `Presence` | on connect, then from the presence loop (`src/net.rs:674`, `src/net.rs:607-622`) | `tidy_presence`, then store (`src/net.rs:722-726`) |
| `Msg` | per message, plus outbox replays (`src/net.rs:675-677`, `src/net.rs:623-632`) | `receive`, then always answers with `Ack` (`src/net.rs:727-734`) |
| `Ack` | in reply to each `Msg` | clears the outbox entry unless the state is `retry`; wakes the waiting sender (`src/net.rs:735-743`) |

**Mesh and dialing.** Each daemon dials every active member it is not connected to, every 2 seconds, with per-peer backoff doubling from 2 to 60 seconds and a 20-second connect timeout (`src/net.rs:536-584`). When both sides dial at once, both keep the connection dialed by the smaller endpoint id; a redial after the old link is more than 5 seconds old replaces it, on the reasoning that the peer lost its end (`src/net.rs:644-657`, `src/net.rs:749-755`). DESIGN calls the topology a full mesh, "fine to ~50 machines" (`DESIGN.md:164`).

**Presence cadence.** The presence loop wakes every 2 seconds or when a local session comes or goes, rebuilds this machine's presence, and broadcasts it if it changed or if 60 seconds passed. Every 30 seconds it also resends outbox messages older than 20 seconds to connected peers (`src/net.rs:586-635`). Rebuilding presence is not free: it scans Claude Code's session files, runs (cached) git lookups and tails transcripts for `doing` (`src/daemon/directory.rs:192-210`, `src/daemon/directory.rs:54-126`). Incoming presence is sanitised: at most 200 sessions and 100 known names, names slugged to 64 characters, other fields cut to one printable line (`src/net.rs:757-782`). By default a session's cwd is cut to its last component before it leaves the machine (`src/daemon/directory.rs:194-199`).

**Sending.** `send_msg` puts a `Msg` on the link and waits up to 5 seconds for the `Ack`; on timeout the message simply stays in the outbox (`src/net.rs:517-534`, `src/net.rs:36`). The outbox gives up on a message after 7 days (`src/store.rs:321-327`).

### 7. The local daemon and its request protocol

```
 agent ── reason-network mcp ──┐                         ┌──────────────────────────┐
 agent ── reason-network hook ─┼── NDJSON over socket ──▶│ daemon: sessions, roster, │◀── iroh QUIC ──▶ teammates
 you   ── reason-network CLI ──┘   (0600 / named pipe)   │ directory, mail, SQLite   │     (tincan/1)
                                                          └──────────────────────────┘
```

One daemon runs per state folder, enforced by a lock file (`src/daemon/mod.rs:41-54`). Clients talk to it with one JSON object per line over `~/.reason/network/network.sock` (`src/paths.rs:63-80`), chmodded to 0600 (`src/ipc.rs:60`), or a named pipe on Windows (`src/paths.rs:81-95`). Each request runs on its own task so a long-poll cannot block the connection (`src/daemon/mod.rs:257-271`). A connection that registered sessions also receives pushed `Event::Deliver { key, ids, text }` frames, which the client must acknowledge with `Delivered` (`src/proto.rs:270-275`).

The request enum is internally tagged by `op` (`src/proto.rs:82-210`); dispatch is at `src/daemon/mod.rs:278-324`.

| Request | Purpose |
|---|---|
| `Register{session}` | register or refresh a session; returns name, address, unread count |
| `SetStatus{key, doing?, name?}` | set `doing` or rename |
| `Who{me?, filter?, all}` | the directory; `all` adds sessions offline within the last day |
| `Send{from, to, body, reply_to?, wake, notify, files}` | send; `from` is a session key, or for CLI-replying agents an address or name |
| `Inbox{key, wait_secs, peek}` | unread mail; long-poll capped at 600 s (`src/daemon/mod.rs:39`, `:289`) |
| `Unread{key}` | count and senders |
| `Ask{key, question, about?}` | `ask_user` pop-up |
| `StopWaiting{key}` | the client cancelled an `Inbox` or `Ask` wait |
| `Delivered{key, ids}` | mark messages read and clear in-flight |
| `Hook(HookCall)` | a harness hook fired; returns mail to inject |
| `Attach{agent, cwd}` | map an id-less MCP call (Cursor, opencode) to a hook-known session |
| `CreateTeam`, `Join`, `Proof`, `Invite`, `Team`, `Link`, `Remove`, `Leave`, `SetRelay` | membership and network |
| `Held`, `Settle{ids, release}` | list and approve or drop held messages |
| `Shutdown` | exit; the service manager restarts it |

Sessions register by several routes, in priority order: a per-call `_meta` session id (Codex, opencode V2), environment variables (Claude, Copilot, Grok), hook stdin correlated by PID ancestry, or one session per MCP process (`DESIGN.md:205-209`). The MCP server must handle many sessions per process because Codex, opencode and Cursor share one MCP process across threads (`DESIGN.md:78-79`). Hook-only sessions are dropped when their agent's PID dies or after 6 hours without a hook (`src/daemon/sessions.rs:9-10`, `src/daemon/sessions.rs:264-281`).

### 8. Storage and retention

State lives in `~/.reason/network/` (`README.md:92`). The SQLite database runs in WAL mode with `secure_delete` on, and its files are chmodded 0600 because it holds the team link (`src/store.rs:95-110`). Tables (`src/store.rs:48-87`, with `approval` and `files` columns added by migration at `src/store.rs:111-119`):

- `messages(id, sender, sender_agent, sender_repo, recipient, via, reply_to, body, wake, sent_at, read_at, held, approval, files)`
- `outbox(id, target, msg, created_at)`
- `kv(key, value)`: the team state and saved presence
- `sessions(name, key, agent, cwd, repo, branch, doing, tier, last_seen)`

| Limit or constant | Value | Source |
|---|---|---|
| Message body | 64 KB | `src/daemon/mail.rs:17` |
| Wire frame | 4 MiB | `src/net.rs:35` |
| Ack wait | 5 s | `src/net.rs:36` |
| In-flight before re-showing | 90 s | `src/daemon/mail.rs:23-24` |
| Hook-only session TTL | 6 h | `src/daemon/sessions.rs:9-10` |
| Forget a gone session and its unread mail | 24 h | `src/daemon/sessions.rs:11-12`, `src/store.rs:397-417` |
| Outbox give-up | 7 days | `src/store.rs:321-323` |
| Files kept after read / while unread | 1 day / 7 days | `src/files.rs:24-26` |
| Expired invites pruned | after 7 days | `src/team.rs:518-522` |
| Inbound rate limit | 60 messages/min per peer machine | `src/daemon/mail.rs:18-19` |
| Loop guard | 12 messages per (from, to) per 10 min | `src/daemon/mail.rs:20-22` |
| Notification throttle / staleness | 1 per sending person per 60 s / none over 15 min old | `src/daemon/notify.rs:12-15` |
| `ask_user` wait / pop-up lifetime | 600 s / 3600 s | `src/daemon/mod.rs:39`, `src/daemon/ask.rs:15-16` |
| Presence cadence | 2 s check, 60 s floor; outbox replay 30 s | `src/net.rs:586-635` |
| Reason session poll | 15 s, 20 sessions | `src/daemon/reason.rs:14`, `:82` |
| Relay list refresh | 6 h | `src/relays.rs:35` |

Forgetting a session deletes only its *unread* mail; the person's own CLI mailboxes and held mail are exempt, and nothing is forgotten in the first 6 hours after the daemon starts, while live sessions register again (`src/store.rs:397-417`, `src/daemon/sessions.rs:282-287`). I found no code path that deletes a message after it has been read; see section 18.

### 9. Delivery tiers

Each session advertises a tier so a sender knows when the message will be seen (`src/proto.rs:8-20`):

- **push**: injected right away; wakes an idle session.
- **turn**: injected at the next tool call or when the turn ends; an idle session waits for its human.
- **pull**: seen only when the agent checks its inbox.
- **none**: not on Reason Network, cannot receive.

| Harness | Tier | Mechanism | Source |
|---|---|---|---|
| Claude Code (with the MCP server) | push | the MCP child posts a `{"type":"user"}` frame to `CLAUDE_CODE_MESSAGING_SOCKET`; read between tool calls, starts a turn if idle | `src/daemon/mail.rs:286`, `src/mcp.rs:355-365`, `src/agent.rs:209-229` |
| Claude Code (open before setup, no MCP) | push | the daemon posts to the socket path in `~/.claude/sessions/<pid>.json`; the agent replies via CLI | `src/daemon/mail.rs:293-298`, `src/daemon/directory.rs:64-93` |
| Codex | push | `codex queue --thread <id> --message …`; hooks inject mid-turn (`PostToolUse`) and at stop (`decision: block`) | `src/daemon/mail.rs:287-289`, `src/daemon/sessions.rs:18`, `src/hook.rs:128-135` |
| opencode | push | plugin: `client.session.promptAsync` (1.x), `ctx.session.prompt` (2.x) | `src/opencode-plugin.ts:81`, `:124`, `src/daemon/sessions.rs:20` |
| pi | push | extension: `sendUserMessage`, `deliverAs: "steer"` while busy | `src/pi-extension.ts:28`, `src/daemon/sessions.rs:20` |
| Reason (cloud) | push | `reason session send <id> --message …`; replies via the CLI | `src/daemon/mail.rs:290-292`, `src/daemon/reason.rs:1-3` |
| Cursor | turn | `postToolUse` `additional_context`; `stop` `followup_message` | `src/hook.rs:136-137` |
| Gemini CLI | turn | `AfterTool` / `BeforeAgent` `additionalContext`; stop `block` | `src/hook.rs:132-135` |
| Copilot CLI | turn | `additionalContext`; stop `block` | `src/hook.rs:135`, `:138` |
| Grok CLI | turn | `PostToolUse` `additionalContext`; `Stop` (`end_turn`) block; subagent hooks ignored | `src/hook.rs:116-121`, `:131`, `:135` |
| DeepSeek Harness, any MCP client | pull | `check_inbox(wait_secs)` long-poll plus an unread notice on Reason Network tool results | `src/mcp.rs:205-240`, `src/mcp.rs:326-341` |

The daemon picks a channel per message in `deliver` (`src/daemon/mail.rs:284-301`): Claude socket, agent CLI command, direct Claude socket, hooks if the session has ever fired one, else pull. A session currently blocked in `check_inbox` gets the message there instead of a push (`src/daemon/mail.rs:277-279`). A message sent with `wake: false` (`send --fyi`, `src/main.rs:413`) is only queued for the next natural pause (`src/daemon/mail.rs:274-276`), and a mid-turn tool hook injects only `wake` messages while stop and prompt hooks inject everything (`src/daemon/sessions.rs:208-213`). DESIGN records that MCP notifications are not a delivery path because "no client passes them to the model (checked Oct 2026)" (`DESIGN.md:241`).

**At-least-once.** A message stays unread until whoever showed it to the agent confirms with `Delivered` (`src/daemon/mail.rs:1-5`). While a push or hook is outstanding the id sits in `in_flight` and is hidden from the inbox and other hooks; after 90 seconds unconfirmed it becomes visible again (`src/daemon/mail.rs:40-46`). The hook command marks messages read only after it has written them to stdout (`src/hook.rs:21-25`); `check_inbox` peeks and acknowledges right before returning (`src/mcp.rs:219-236`). Duplicates from peers are dropped by message id (`src/daemon/mail.rs:501-503`, `src/store.rs:137-142`).

**Tier downgrade.** If the agent-CLI channel fails (in practice `codex queue`), the daemon logs it, sets `push_failed`, and changes the session's tier to `Turn`, with the comment "Hooks (or check_inbox) show it instead; tell senders the truth from now on" (`src/daemon/mail.rs:437-444`). A re-registration cannot raise it again (`src/daemon/sessions.rs:52`). Reason is treated differently because it has no hooks to fall back on: failures back off from 15 seconds doubling to 30 minutes, and the 15-second sync pushes again (`src/daemon/mail.rs:426-436`, `src/daemon/reason.rs:134-141`). A failed Claude socket post does not downgrade anything; the message stays unread and resurfaces through hooks or the inbox (`src/daemon/mail.rs:355-371`, `src/mcp.rs:359-364`).

**What the sender learns.** Each recipient gets a `state` and a human-readable `note` (`src/proto.rs:447-453`). The states the code actually produces are `pushed`, `queued`, `delivered`, `held`, `refused`, `retry` (receiver busy or over file quota), `notified` and `not notified` (`src/daemon/mail.rs:189-215`, `:313-352`, `:472-531`). Notes describe the tier in words, for example "queued: it sees it at its next tool call or when its turn ends" (`src/daemon/mail.rs:306-312`).

### 10. What an agent sees: framing and defang

Incoming mail is rendered as peer input, never as the user (`src/render.rs:239-284`):

```
<reason-message from="alice/api-cursor" agent="cursor" repo="acme/api" id="01JA…">
Did you change the session token format? My tests started failing on refresh.
</reason-message>
From alice's agent. Reply if useful with send_message to "alice/api-cursor".
```

Attribute values are escaped so they cannot close the attribute or tag (`src/render.rs:300-303`). A file list, if any, goes *after* the closing tag so the body cannot imitate it (`src/render.rs:265-266`). The trailer says whether the text was typed by a person at a terminal or by an agent, and for CLI-only recipients gives the exact `reason-network send … --from …` reply command (`src/render.rs:269-282`).

`defang` is the main defence against forged frames (`src/render.rs:305-331`). It replaces any opening bracket, including 19 look-alike Unicode brackets, with `[` when the tag word after it spells "reason" under a leetspeak reading (`0`→o, `3`→e, `4`/`@`→a, `5`/`$`→s, other non-letters dropped), contains anything other than printable ASCII, or runs past 64 characters (`src/render.rs:335-353`). It is applied to message bodies, approval text, `list_team` output as a whole (because "a teammate's fields side by side (`repo@branch`) could open a frame that none of them opens alone", `src/render.rs:133-137`), send results, and tool errors (`src/mcp.rs:176`, `src/mcp.rs:382-385`). Peer message metadata is reduced to safe character sets and the id must be a canonical ULID (`src/daemon/mail.rs:659-677`). The most recent merged PR, #54, is titled "nothing a teammate sends opens a frame where agents see it" (`git log`).

Framing is the only content control. No code inspects what a message asks for. The skill text and MCP instructions carry the rest (section 15).

### 11. Trust controls

All of these act on messages arriving from teammates' machines in `receive` (`src/daemon/mail.rs:451-540`), in this order:

1. **Speaks for its own person.** The sender machine's roster person must prefix `msg.from` (`src/daemon/mail.rs:462-465`).
2. **Approval check** (section 12).
3. **Inbound policy.** `accept` (default), `hold` or `refuse`, with a default and per-person overrides in `config.toml` (`src/config.rs:26-42`, `src/config.rs:97-99`). Refuse bounces with a note (`src/daemon/mail.rs:472-475`). Hold stores the message with `held = 1` and saves no files until the person approves with `reason-network held` / `approve` (`src/daemon/mail.rs:524-531`, `src/store.rs:270-311`).
4. **Rate limit.** At most 60 messages per minute per peer machine; beyond that the receiver answers `retry`, so the sender keeps the message in its outbox and replays it later. Deferred, not dropped (`src/daemon/mail.rs:476-481`, `src/daemon/mail.rs:542-553`, `src/net.rs:735-739`).
5. **File quota** (section 13).
6. **Loop guard.** At most 12 messages from one session address to another in any 10-minute window; the 13th is refused with "looks like a loop. Check with your user before continuing" (`src/daemon/mail.rs:20-22`, `:49-64`, `:74-75`, `:515-517`). Senders ending in `/terminal` are exempt (`src/daemon/mail.rs:50-54`). For local recipients the sender's daemon applies the same check (`src/daemon/mail.rs:190-192`).
7. **Group guard** for messages from older builds (`src/daemon/mail.rs:518-523`).

**Notifications.** A `notify` pop-up shows at most once per sending person per minute and never for a message older than 15 minutes, so "a looping agent can't flood the screen" (`src/daemon/notify.rs:12-15`, `:20-46`).

**Claude Code's own layer.** Claude Code has a `crossSessionInbound` setting; a session running in `bypassPermissions` holds a stranger's message unless that setting is `accept`. Reason Network detects this from the transcript and tells the sender `held by Claude Code…` (`src/claude.rs:78-86`, `DESIGN.md:309-313`).

I think the loop guard is the most consequential default in this list, because it is the only control that stops two agents from exchanging messages indefinitely with no human involved. It is also weaker than DESIGN's description: it is a sliding count per direction, not a count of consecutive messages reset by human input (see section 19 and the closing notes).

### 12. `ask_user` and signed approvals

`ask_user(question, about?)` asks the session's own person with a pop-up on that machine and returns the answer as the tool result, "their own decision, not a message from anyone" (`src/daemon/ask.rs:1-3`). On macOS it is an osascript JavaScript panel, on Windows a PowerShell panel, on Linux a `notify-send` notification with Approve and Deny actions (`src/daemon/ask.rs:378-383`, `:424-434`, `:439`). One question per session is up at a time; asking the same question again joins the wait, a different one is refused (`src/daemon/ask.rs:70-91`). The call waits up to 10 minutes; the pop-up stays for an hour, and a later answer arrives as a message from `<person>/terminal` (`src/daemon/ask.rs:93-113`, `:235-266`). With no desktop the call errors so the agent asks in its own session (`src/daemon/ask.rs:80-86`).

With `about` set to the id of a teammate's message to this session, the pop-up shows that request first, in the sender's words, clipped to 500 characters, then the agent's question clipped to 400 (`src/daemon/ask.rs:50-69`). Direction overrides, zero-width characters and tag characters are stripped from what is shown and signed (`src/daemon/ask.rs:385-393`). When the person answers, the daemon builds an `Approval` holding what the panel reported it showed, signs it with this machine's key, and sends it to the requesting session from the asking session, or from the person's terminal if that session has gone (`src/daemon/ask.rs:166-213`).

```rust
pub struct Approval {                                    // src/proto.rs:279-291
    pub about: String, pub asker: String, pub approver: String, pub approved: bool,
    pub question: String, pub request: String, pub note: String, pub at: i64,
}
```

**What "verified" means.** The receiving machine keeps the approval only if `approval_holds`: the signer is the machine that delivered it, `approver` equals that machine's roster person, `asker` equals the session it arrived at, the text fields total at most 2,000 characters, and the signature verifies (`src/daemon/mail.rs:650-657`). Otherwise the approval is stripped and the body gains "(It came with an approval that didn't verify.)" (`src/daemon/mail.rs:466-471`). The agent then sees `<reason-approval from=… about=… verified>` (`src/render.rs:250-260`). Which request it answers is the approver machine's word, so the skill tells agents to check that `from` is the person they asked and `about` is their request's id (`src/daemon/mail.rs:650-652`, `src/skill.md:41-42`).

**The project's own caveat.** It proves the approver's machine recorded its person's answer. Release builds only run the system's dialog programs, so an agent cannot start the engine with a stand-in that answers (`src/daemon/ask.rs:401-408`). DESIGN still calls this "a speed bump, not a guarantee: software that controls the approver's machine can still sign (the machine's key is a file there, and it could build its own engine)" (`DESIGN.md:280-281`).

### 13. File attachments and confinement

A message can carry up to 10 UTF-8 text files, 256 KB each and 512 KB in total, inside the envelope, so they are as authenticated as the text (`src/files.rs:1-4`, `src/files.rs:15-17`). An agent's `send_message` reads files only through `Confine`: inside the repo root or cwd, no hidden path component, nothing under `~/.config`, `~/.ssh`, `~/.aws`, `~/Library`, `%APPDATA%`, `%LOCALAPPDATA%` or the state folder, and nothing at all if the session works in the home folder or above it (`src/files.rs:36-80`, `src/mcp.rs:368-380`). The stated reason is that "an agent can be talked into sending what its harness wouldn't let it read" (`src/mcp.rs:369-370`). A person's `send --file` has no such limit.

The receiver saves files in a new folder named by a locally generated ULID, never the sender's id, with the folder 0700 and files 0600, never executable, and marked as downloaded via macOS quarantine or the Windows zone identifier (`src/files.rs:276-346`, `src/store.rs:22-30`). The paths are listed after the frame with "read them, don't run them" (`src/render.rs:286-298`). A machine keeps at most 100 MB of teammates' files, 25 MB from any one person, and takes at most 20 MB per person per hour; past that it answers `retry` (`src/files.rs:27-32`, `src/files.rs:371-399`, `src/daemon/mail.rs:504-514`). For a peer whose presence lacks the `files` feature, the sender inlines the files as fenced blocks if they fit 64 KB, otherwise refuses for that recipient (`src/daemon/mail.rs:679-692`).

### 14. How "doing" is derived

"Agents never report what they are doing" (`DESIGN.md:283`). `doing.rs` builds a line of the form `title · task in progress`, clipped to 120 characters (`src/doing.rs:16-49`), from data the harness writes anyway:

- **Title**: Claude Code `ai-title` transcript entries, Codex thread names in `~/.codex/session_index.jsonl` (`src/doing.rs:59-66`, `src/doing.rs:187-209`), Reason session titles (`src/daemon/reason.rs:128`). opencode and pi report their own (`src/proto.rs:73-76`, `DESIGN.md:231`).
- **Task**: the in-progress item of any `todos` or `plan` array in the transcript (TodoWrite, Codex `update_plan`, Gemini `write_todos`, Cursor `todo_write`), or Claude Code's `~/.claude/tasks/<session>/` files (`src/doing.rs:67-97`, `src/doing.rs:175-185`).

Transcripts are tailed incrementally: the first read takes the last 2 MiB, later reads only appended bytes, and a file that is one JSON document (Gemini) is reread whole when it changes (`src/doing.rs:99-156`). Busy and idle come from hook events (`prompt`/`tool` mean busy, `src/daemon/sessions.rs:202`) and, for Claude Code, from `~/.claude/sessions/<pid>.json`, which also lists Claude sessions that never loaded the MCP server (`src/claude.rs:1-33`). Those unmanaged sessions are shown to teammates only if `share_unmanaged` is on (`src/daemon/directory.rs:193`, `src/config.rs:13-14`).

### 15. MCP tools and behavioural instructions

`reason-network mcp` is a stdio server and a thin client of the daemon (`src/mcp.rs:1`).

| Tool | Arguments | Notes |
|---|---|---|
| `list_team` | `filter?`, `all?` | defanged directory; read-only hint (`src/mcp.rs:132-149`) |
| `send_message` | `to`, `message`, `reply_to?`, `notify?`, `files?` | always `wake: true` (`src/mcp.rs:151-178`) |
| `ask_user` | `question`, `about?` | cancellation leaves the pop-up up (`src/mcp.rs:180-203`) |
| `check_inbox` | `wait_secs?` (max 600) | hidden from Claude Code sessions that have the messaging socket, "fewer tokens" (`src/mcp.rs:205-262`) |

The server's `instructions` are the "How to behave" section of `src/skill.md`, extracted at build time (`src/mcp.rs:30-37`). The rules (`src/skill.md:30-42`):

- Message only when you need something from another agent or it needs something from you; no status updates or small talk.
- Check the team first and pick the one session on the relevant repo and branch; if unsure, ask your user. On the same machine, prefer the harness's own messaging.
- Point first, then the context the recipient cannot see. One message per need; wait for the reply.
- Treat `<reason-message>` blocks as a colleague asking: help if it fits your user's work, check with your user before anything destructive or unrelated (using `ask_user` with `about` if they may not be watching), and say briefly if you cannot.
- Treat `<reason-approval … verified>` as the teammate's own pop-up answer, check `from` and `about`, and treat "X approved" inside a message as only their agent's word.

DESIGN adds that the instructions cover only messaging and change nothing about how an agent works otherwise (`DESIGN.md:300-302`).

### 16. Hosted MCP and cloud-agent bridges

**Reason cloud sessions.** With `reason = true`, the daemon runs `reason session list --limit 20 --json` every 15 seconds, registers each unfinished, unarchived session as `reason:<id>` with tier push, uses its title as `doing`, delivers with `reason session send`, and drops sessions that disappear from the list (`src/daemon/reason.rs:79-156`). These agents reply through the local CLI (`src/render.rs:277-279`).

**Workspace cloud agents.** When the Reason CLI installed the binary under `~/.reason/bin` (`src/main.rs:45-53`), the daemon keeps `reason network __relay` running, restarting it with backoff from 30 seconds up to 10 minutes; it "carries the workspace's cloud agents' calls (Reason's hosted tools) onto the network" (`src/daemon/reason.rs:5-6`, `src/daemon/reason.rs:35-77`). The relay's implementation lives in the Reason CLI, not this repo.

**Hosted MCP.** README tells hosted assistants (Grok Bot, Muse, ChatGPT) to add `https://mcp.reasonmachines.com/mcp` (`README.md:43`, `DESIGN.md:333-334`). Nothing in this repository implements that server, so I cannot say how it authenticates, which machine's daemon it reaches, or what tier those assistants get.

### 17. Implemented versus planned

Implemented, per the milestone list and confirmed in code (`DESIGN.md:345-353`): the daemon, socket API, SQLite, the MCP server, CLI, Claude push and passive discovery (M0); iroh transport, signed roster, invites, joins, removal, presence sync, acks and outbox, hold and refuse, rate limit, mDNS, custom relay (M1); Codex, Cursor, Gemini, Copilot and opencode adapters, `setup`, service install, `doctor` (M2); release builds and install scripts (M3). Sign-in proofs, Reason's signed relay list, file attachments and the Reason bridges landed after the M3 list in October 2026 (`git log`).

Planned as M4 and not started (`DESIGN.md:10-11`, `DESIGN.md:354-355`):

- **File-overlap warnings**, such as "bob/api-codex is also editing src/auth.rs", derived from `PostToolUse` edit paths.
- **An anchor**: "just a member daemon on an always-on box (VPS, Pi) with `role=anchor` that also holds mail for offline machines and keeps searchable team history. Same binary, no special server" (`DESIGN.md:197-198`).
- **Hold and approve notifications.**
- **A web dashboard.**

None of these exists in `src/`: there is no `role` field in `Member` (`src/team.rs:54-61`), no history or search request in `Request` (`src/proto.rs:84-210`), and no edit-path tracking in the hook handler (`src/hook.rs:67-77`). Amp appears in DESIGN's tier table as push via a plugin (`DESIGN.md:233`), but the repo ships no Amp plugin and M2 says "Amp: MCP only, by hand" (`DESIGN.md:350`); in code, Amp is only recognised by its MCP client name (`src/agent.rs:64-65`), which puts it on the pull tier.

### 18. What Reason Network does not do

These are gaps in the current code and docs, not criticisms; several are deliberate.

- **No durable shared history or search.** Mail lives on the recipient's machine. Mail for a session gone more than a day is deleted unread (`src/store.rs:397-417`), the outbox gives up after a week (`src/store.rs:321-323`), and no request reads past messages (`src/proto.rs:84-210`). Read messages do remain in the local `messages` table: I found no path that deletes them, but none that exposes them either. Searchable history is the M4 anchor's job (`DESIGN.md:197-198`).
- **No offline relay of mail between machines.** A message for an offline machine waits in the *sender's* outbox and goes out when both are online (`src/daemon/mail.rs:247-261`, `src/net.rs:675-677`). Two teammates who are never online at the same time cannot exchange mail. Holding mail for offline machines is again planned for the anchor.
- **No memory.** The system remembers session names, last-known repo, branch and `doing` (`src/store.rs:75-85`), and nothing about what was decided, learned or asked.
- **No content analysis or screening.** The only content control is framing and `defang` (`src/render.rs:305-331`). Nothing classifies a request as destructive, sensitive or off-topic; that judgement is left to the receiving agent through the skill text (`src/skill.md:38-40`).
- **No team-wide interruption budget.** Throttles are local and per sender: 60 messages a minute per machine, 12 per session pair per 10 minutes, one pop-up per sending person per minute (section 11). Nothing bounds how often one person is interrupted across all senders, or coordinates attention across a person's machines.
- **No routing intelligence.** The directory gives the agent context, and the agent chooses. There is no ranking, no "who knows about X", and no record of whether a choice was right (`src/daemon/directory.rs:212-282`).
- **Scale ceiling.** Full mesh, every machine connected to every other, "fine to ~50 machines" (`DESIGN.md:164`). Presence is rebuilt every 2 seconds (`src/net.rs:613-617`), and every roster change rebroadcasts the full `TeamState`, whose `admissions` grow forever (`src/team.rs:84-87`, `src/daemon/teamops.rs:130-134`).
- **One team per machine.** `CreateTeam` fails if the machine is already in a team (`src/daemon/teamops.rs:191-193`), and `TeamState` is stored under a single `kv` key (`src/daemon/teamops.rs:45-50`). There are no projects, sub-teams or channels inside a team; `repo:` addressing is the closest thing to a project scope (`src/daemon/directory.rs:374-381`).
- **No roles or adversarial security.** Any member can invite and remove anyone, and removal is not cryptographic revocation (`DESIGN.md:124-126`).
- **No always-on agent as a member.** A Reason cloud session can be addressed (section 16), but nothing in the code runs an agent that stays up and acts for the team.
- **No delivery receipts beyond the first ack.** Each `Msg` gets exactly one `Ack` with the receiver's immediate state (`src/net.rs:727-734`). The sender never learns when a queued message was actually read.

### 19. Surprising or non-obvious things in the code

- **Peer mail enters Claude Code as a `user`-role message.** The push frame is `{"type":"user","message":{"role":"user",…}}` (`src/agent.rs:222-229`). Only the `<reason-message>` frame and the trailer distinguish it from the human, which is why so much of the recent work is in `defang`.
- **Approvals are signed over the team's name, not its id.** Roster entries use `self.id` in the signature payload (`src/team.rs:138`, `:304`), but approvals use `team.name()` when signing and verifying (`src/daemon/ask.rs:201`, `src/daemon/mail.rs:467`). Any member can sign a new team name (`src/team.rs:246-247`, `:461-469`), so a rename between signing and arrival makes an in-flight approval fail verification.
- **The `/terminal` exemption trusts a string.** Both the group guard and the loop guard key on the sender address ending in `/terminal` (`src/daemon/mail.rs:50-54`, `:127`). The CLI accepts `--from <you>/terminal` from any process (`src/main.rs:417-423`), and a peer machine's message only has to start with its own person's name (`src/daemon/mail.rs:462-465`). An agent with shell access, or a teammate's build, can therefore send group messages and bypass the loop guard by posing as the terminal.
- **The loop guard is directional.** Its key is `(from, to)` (`src/daemon/mail.rs:55`), so a two-agent ping-pong gets 12 messages each way, 24 in total, per 10 minutes before either is refused.
- **Name inheritance keys on the name alone.** Any new session whose default name matches a stale one inherits it and its unread mail, whether or not it runs in the same directory (`src/daemon/sessions.rs:321-329`, `src/agent.rs:181-186`). Two checkouts with the same final directory name and agent share a mailbox identity over time.
- **Verified teammates' email addresses replicate to every member.** The proof's token payload includes `email` (`src/proof.rs:50`), and the full JWS travels in `attestations` to every member machine (`src/team.rs:99-102`, `:491`). The code never displays the email, but every machine stores it.
- **The ALPN and signature prefix still say `tincan/1`** (`src/net.rs:32`, `src/team.rs:27`), and old `tincan1` tickets are still read (`src/team.rs:546-547`).
- **The unread notice is narrower than the docs suggest.** It is appended to Reason Network's own tool results (`src/mcp.rs:326-341`), not to every tool result of the harness. A pull-tier agent that never calls a Reason Network tool never learns mail is waiting.
- **The roster is gossip with anti-entropy.** Any merge that changes the state rebroadcasts the full state to every peer (`src/daemon/teamops.rs:106-135`), and a relay change or self-removal restarts the daemon (`src/daemon/teamops.rs:137-149`).

### Notes: where the lead notes, DESIGN and the code disagree

The code wins in each case.

1. **Size.** The notes say about 10K lines in `src/`. `wc -l` gives about 14.9K lines of Rust, about 12K outside inline test modules.
2. **Ack states.** The notes list `stored|delivered|held|refused|retry`. `stored` appears only in a test (`src/net.rs:843`). The states produced are `pushed`, `queued`, `delivered`, `held`, `refused`, `retry`, `notified`, `not notified`. The notes' `inbox` state appears only in a doc comment (`src/proto.rs:450`). DESIGN's "later receipts (`delivered`, `read`) flow back" (`DESIGN.md:170-171`) has no implementation; there is one `Ack` per `Msg`.
3. **Loop guard.** The notes say 12 per pair per 10 minutes, and DESIGN says "a cap on consecutive agent↔agent messages in a thread without any human input" (`DESIGN.md:314-315`). The code counts per *directed* `(from, to)` pair in a sliding window, has no thread concept, and nothing resets on human input (`src/daemon/mail.rs:49-64`).
4. **Tier downgrade.** The notes say push failure falls back to hooks with the tier downgraded. Only the agent-CLI channel (Codex) downgrades to `Turn` (`src/daemon/mail.rs:437-444`). Reason backs off and retries (`src/daemon/mail.rs:426-436`); Claude socket failures do not change the tier.
5. **`check_inbox` hiding.** The notes say it is hidden for push agents. It is hidden only for Claude Code sessions that have a messaging socket (`src/mcp.rs:251-269`); Codex, opencode, pi and Reason sessions still see it.
6. **Notification throttle.** The notes say one per sender per minute. The key is the sending *person* (`src/daemon/notify.rs:24-33`).
7. **Amp.** The notes list an Amp plugin with push delivery. No Amp plugin exists in the repo; Amp is recognised by MCP client name only (`src/agent.rs:64-65`, `DESIGN.md:350`).
8. **Presence and envelope shape in DESIGN.** DESIGN describes `Presence{machine, seq, sessions[], human_status}` and `Envelope{id, from, to, reply_to, thread, body, sent_at}` (`DESIGN.md:166`, `:169`). The code has `Presence{sessions, known, features}` (`src/net.rs:59-67`) and a `Message` with no `thread` or `seq` field (`src/proto.rs:293-318`).
9. **Ticket peers.** DESIGN says the link carries "up to six members' keys" (`DESIGN.md:132`). The code puts this machine plus at most two others in a ticket (`src/daemon/teamops.rs:416-418`).
10. **Socket path in DESIGN's diagram.** The diagram shows `~/.reason/network/sock` (`DESIGN.md:67`); the code uses `network.sock`, or a short path under `/tmp` when that is too long (`src/paths.rs:63-80`). The module comment in `src/proto.rs:2` still names an older path.

### What I could not verify

- The hosted MCP server at `mcp.reasonmachines.com` and the Reason CLI's `reason network __relay`: neither is in this repo.
- Runtime behaviour of any kind. I did not build or run the binary, so the cadences and limits above are as written in source, not as measured.
- The ~50-machine ceiling in DESIGN is the author's estimate; I found no benchmark or test that exercises it.
- The per-harness hook contracts (Cursor's background `wait`, Gemini's experimental idle injection, Codex `/hooks` approval) come from DESIGN (`DESIGN.md:229-237`); I checked the output formats in `src/hook.rs` but not the harnesses' side.

---

## Part 2: Beacon, from endpoint telemetry to reviewed memory

Beacon (`agent-beacon`, Asymptote Labs, MIT) is a Go monorepo. This part describes the checkout at `~/work/services/agent-beacon`, HEAD `99c70908` (2026-10-20, tagged `v1.3.45`). Paths are repo-relative. Two prefixes recur: `AO/` is `pkg/asymptoteobserve/`, the shared module for schema, ids, dedupe, privacy and the rules engine, and `CB/` is `cli/beacon/`. Every `file:line` below was opened and checked against the code. Anything that lives only in docs, or only on the closed hosted side, is labelled as such.

### 1. Two products on one log

Beacon makes two pitches, and both run on the same code.

The README now leads with "Self‑improving memory for coding agents" and the line "A problem solved by one agent shouldn't need to be learned from scratch by another." It promises cross-harness history, "knowledge that compounds", and "reviewed knowledge future agents can retrieve through MCP or Agent Skills" (`README.md`). The problem it names is that what one agent learns in a session vanishes when the session ends, and that the next agent, often in a different harness, starts from scratch.

The other front is endpoint telemetry for local agent runtimes. The contributor guide still opens with "Beacon Endpoint Agent is a local-only endpoint telemetry agent for AI runtimes" and states the posture as "visibility-first endpoint telemetry for local AI agent runtimes, not a hosted policy service or general endpoint protection product" (`CLAUDE.md:7`, `CLAUDE.md:28`). The problem here is that security and platform teams cannot see what coding agents do on laptops, in CI and in cloud sandboxes. That front ships SIEM packs, MDM packaging, a CEL rule format with 77 rules, and a fleet installer.

The shift is recent and visible in the dates:

| Date | Change | Source |
|---|---|---|
| 2026-05-11 | First commit | `git log` |
| 2026-05-13 | v0.0.1, "local agent harness telemetry", Wazuh rules | `docs/changelog/cli.mdx:1660` |
| 2026-06-13 | Open threat-rule format, `beacon scan` | changelog |
| 2026-09-21 | v1.3.17: `beacon login`, Standard vs Metadata-only, local reviewed memory | `docs/changelog/cli.mdx:411` |
| 2026-10-18 | Memory over Beacon Cloud MCP | `docs/changelog/cloud.mdx:30-32` |
| 2026-10-19 | Beacon Cloud Pro ($20/month); memory and findings MCP tools gated to Pro | `docs/changelog/cloud.mdx:21-25` |
| 2026-10-19 | README tagline switched to memory | `git log -- README.md` |

So the memory loop is about one month old, and the hosted memory tools are three days old at HEAD. The telemetry material was not removed, only demoted: offline `beacon scan` is a free feature, and "block or require approval" is an Enterprise line item. `CLAUDE.md` has not caught up and mentions memory only as a clause about the agent skills. The useful way to read the repo is that the memory product is a consumer of the telemetry substrate, and almost every property of the memory loop (locality, privacy, scoping, provenance) comes from decisions made for telemetry.

### 2. Shape of the system

There is no Beacon daemon in the capture path. Three kinds of process append to one JSONL file, and everything else reads that file on demand.

```
 harnesses (20+)                                                     
   | hook exec (stdin JSON)   | OTLP gRPC/HTTP 127.0.0.1:4317/4318   | session stores on disk
   | plugins spawn the same   |                                      |
   | hook binary              |                                      |
   v                          v                                      v
 beacon-hooks              beacon-otelcol (only resident            beacon CLI pollers
 (one short-lived          capture process; launchd/systemd/        (install-time backfill,
  process per event)        SCM/pidfile)                             manual, or --watch)
   |                          |                                      |
   +----------- flock(runtime.jsonl.lock) -> dedupe tail 256 KiB -> rotate 10 MiB x5 -> append
                                      |
                     ~/.beacon/endpoint/logs/runtime.jsonl
                                      |
   +-----------+-----------+----------+-----------+-------------+--------------+
   v           v           v          v           v             v              v
 dashboard   beacon     history.db  beacon      beacon mcp    beacon memory  Vector forwarder
 :8765       traces     (opt-in,    scan        serve         -> memory.db   -> Beacon Cloud
 + lenses    (TUI)      SQLite FTS) (CEL rules) (read-only)   -> SKILL.md    (Standard |
                                                                             Metadata-only)
```

The architecture doc calls the log "the stable handoff boundary" and keeps its active path fixed "so dashboards and shippers can follow it" (`docs/architecture/architecture.mdx:88`). Any consumer, including a third-party daemon, can tail the file without asking Beacon for anything.

### 3. The capture layer

#### 3.1 Capture families

| Family | Mechanism | Harnesses (examples) | `collection_method` |
|---|---|---|---|
| Native hooks | Harness execs `beacon-hooks --platform <x> <event>` with JSON on stdin; process writes and exits | Claude Code, Cursor, Qwen, Kiro, Kimi, Muse, Grok, Hermes, Devin, OpenHands, dsh, Antigravity, goose | `hook` |
| Managed plugins | Single-file TS/JS plugins installed into the harness; each spawns `beacon-hooks` with a 2,000 ms send timeout (`plugins/opencode-beacon/src/beacon.ts:8`) | OpenCode, Cline, Pi, Oh My Pi, Prime, Senpi/omo, OpenClaw | `plugin` |
| Local OTLP | Custom OpenTelemetry Collector `beacon-otelcol` with a `beaconjson` exporter that converts logs, traces and metrics into the same event | Claude Code, Codex, Gemini CLI, Copilot, Factory, VS Code, Cowork | `otlp` |
| Pollers | `beacon endpoint <rt> sync` reads the harness's own session store with per-file cursors | 14 store readers (Claude, Codex, Cursor SQLite, OpenCode, Cline, Copilot, Factory, fx, Grok, Hermes, OpenClaw, Pi, Prime, dsh) plus Cursor Admin API and Devin v3 API pulls | `poll` |
| Browser extension (beta) | MV3 extension tees claude.ai and chatgpt.com SSE streams and posts OTLP logs to `127.0.0.1:4318` | claude_web, chatgpt_web | `otlp` |
| CI | `beacon ci exec -- <cmd>` starts an ephemeral collector around one command | Claude Code, Codex | `otlp` |
| Cloud sandboxes | Hooks inside Claude Code on the web or Cursor cloud write `/tmp/beacon/runtime.jsonl`; `cloudshuttle` uploads new lines on Stop | Claude web, Cursor cloud; Devin via central API pull | `hook` / `poll` |
| SDK | `@asymptote/sdk` emits OTLP traces to a customer collector or hosted Observe, not to the local log | agents in code | n/a |

The plugin-versus-hook split is decided by the `--platform` flag, not by harness name, because some harnesses report through two paths under one name (`AO/provenance.go:98-107`). Claude Code is deliberately wired both ways: its OTLP export has no session lifecycle, file activity or subagent events, so hooks fill those in, and the call id lets the writer collapse the overlap.

Two caveats matter for anyone treating Beacon as a live feed. First, pollers are not scheduled by anything in the service layer. I found no launchd or systemd unit that runs a sync (`CB/internal/endpoint/service/` holds collector, forwarder, inventory, updater and script-forwarder units only); syncs run at install and connect time as a 30-day bounded backfill, by hand, or under `--watch` with a one-minute ticker. Harnesses that are poll-only therefore appear in the log only when someone runs a sync. Second, the cloud-sandbox uploader is the single place a hook touches the network, and `CLAUDE.md` calls it the bounded exception, active only with `BEACON_ORIGIN=cloud`.

#### 3.2 One log, three writers

The three writers are the hook adapter (`cli/beacon-hooks/internal/logging/logging.go`), the collector exporter (`collector-builder/exporter/beaconjsonexporter/writer.go`) and the CLI writer used by pollers and CI (`CB/internal/endpoint/writer/writer.go`). Each carries a comment telling maintainers to keep the rotation and file-mode contract mirrored with the other two (`writer.go:257`, `logging.go:649`, `beaconjsonexporter/writer.go:211`).

`writer.AppendEvent` (`writer.go:63-129`) shows the contract in order:

1. Attach a derived trace id, sanitize (redact, then cap strings), validate.
2. Marshal. If over 64 KiB, drop `raw` and cut `message` to 1,024 bytes. If still over, `compactRetainedContent` drops diff, command output, prompt text, gen_ai input and output, and tool arguments and results, and marks the content as held back.
3. Stamp `event.id` last, over the bytes about to be written. An event that would exceed the cap with its id is written without one.
4. `appendJSONL` (`writer.go:258-322`): take an exclusive lock on `runtime.jsonl.lock`, run the duplicate check, reserve backfill budget, rotate if needed, open with `O_APPEND`, write.

Rotation keeps the live file plus five archives at 10 MiB each (`writer.go:22-26`), so the log holds roughly 60 MiB. The docs put that at "a day or two of activity on a busy machine" (`docs/cli/endpoint-traces.mdx:32`). A backfill sweep carries a `RetentionGuard` that refuses an append if rotation would discard the sweep's own earlier output, and install-time backfill shares a 24 MiB budget so old history cannot evict live data.

There is no IPC between hooks and anything else. A hook resolves its log path from `BEACON_ENDPOINT_LOG` and three aliases, falls back to the default path only when `BEACON_ENDPOINT_MODE` is set, and otherwise records nothing (`logging.go:613-626`).

#### 3.3 Provenance markers

Every event carries two independent provenance fields. `harness.collection_method` is one of `hook | plugin | otlp | poll`. `event.fidelity` is `observed` when the source named the action and `inferred` when Beacon derived it (`AO/event.go:59-62`). `CLAUDE.md` calls these "the only sanctioned way to express that difference" and forbids per-runtime confidence fields. Approvals show why both fields exist: several runtimes give only a pre-tool notification, and the hook records that as `approval.allowed` with `fidelity=inferred`, because "nothing was gated, a tool call was seen". Anything counting real human decisions has to filter on `observed`.

#### 3.4 Dedupe at write time and read time

Write-time dedupe runs inside the lock, in all three writers, through one function: `AO.IsDuplicateEndpointEvent` (`AO/dedupe.go`). It scans the last 256 KiB of the log (`dedupe.go:16`) and applies only to tool-shaped actions (`mcp.tool_invoked`, `command.executed`, `file.read`, `file.modified`, `tool.invoked`, `tool.completed`, `tool.failed`). Two lines must first share a key built from action, session, workspace and a per-action target; for files the target is the path only, because Claude's `Write` reads as a modify in the hook and a create in OTLP. Then (`dedupe.go:85-115`):

- An equal, non-empty `gen_ai.tool.call.id` settles it with no time window. The comment explains why: "A hook writes when the tool runs and the collector writes when its batch flushes, so the two reports of one call routinely land five seconds apart."
- Without matching ids, lines from the same harness are never collapsed, since "two adjacent calls from one path can legitimately touch the same file".
- Across harness names, a 2-second window applies (`dedupe.go:15`), widened to 10 seconds for `tool.completed` (`dedupe.go:241`).

The header records why the function is shared: two implementations of the rule "have already drifted apart here once, silently" (`dedupe.go:64`). The writer never dedupes on `event.id`.

Read-time dedupe covers what write time cannot. Token usage events carry no call id, and several runtimes are read twice (live, then by a later sync). `preferLiveOverPoll` (`CB/internal/tokens/tokens.go:758-787`) suppresses poll usage at or after the earliest live capture of a session and keeps poll usage from before Beacon was installed.

#### 3.5 Observe-only and fail-open

Every hook path exits 0. The reason is spelled out in `logging.go:236-246`: on the Claude-shaped hook contract that several runtimes copy, exit 2 is a block that would "refuse the tool call, erase the prompt, or -- on Stop -- keep the agent running", and "A telemetry write that failed must never become a control decision." Write failures are reported on stderr and surfaced by `beacon endpoint doctor` instead.

Plugins apply the same rule. The OpenClaw plugin refuses to register `before_prompt_build` and `agent_turn_prepare` (prompt injection), `inbound_claim` (can swallow a message) and `skill_proposal_evaluate`: "Beacon observes. Registering any of them would ask OpenClaw for a power Beacon must not hold" (`plugins/openclaw-beacon/src/beacon.js:29-34`). Its `before_tool_call` handler dispatches and returns without awaiting, because OpenClaw registers that hook fail-closed.

The only control path is the policy provider (section 10), and it can only deny.

#### 3.6 Per-harness reply contracts

What a hook prints back is a security property, because on several runtimes an "allow" is a pre-approval that would disarm the user's own prompts. `preToolResponse` (`cli/beacon-hooks/cmd/pre_tool.go:173-260`) states the rule ("a telemetry hook never answers 'allow' where the runtime reads that as a pre-approval") and documents each runtime:

| Runtime | No-opinion reply | Reason given in code |
|---|---|---|
| Antigravity | nothing | `decision` is required and every value is an opinion; `{}` reads as a deny |
| Cursor | `{"permission":"allow"}` | silence and non-schema output are risky; Cursor staff confirmed allow is not a pre-approval. "If Cursor ever makes hook verdicts authoritative, this branch must be revisited" |
| Grok Build | `{}` | a response with no `decision` is no objection |
| Qwen, DeepSeek Harness, Claude-shaped | `{}` | `permissionDecision: allow` would skip the approval prompt |
| Muse | `{}` | its runner rejects unknown keys, so an allow object would fail the hook |
| OpenHands | `{}` | the reply is shown verbatim in the conversation |
| goose | explicit allow object | goose classifies a blocking hook's `{}` as a failed hook; Stop needs the same (`cmd/stop.go:29-40`) |
| Kiro, Kimi | nothing on stdout | stdout is pasted into model context (`hookStdoutIsConsumedAsAgentContext`, `cmd/kiro.go:232`) |

The same comments record which hook outputs reach the model's context on each runtime (Kiro and Kimi stdout, Claude Code's SessionStart `additionalContext`, OpenHands' `additionalContext` key). Beacon uses that knowledge only to stay silent.

### 4. The canonical event and its ids

The event is a nested JSON object defined once in `AO/event.go:642` and aliased by the CLI's schema package. Trimmed:

```go
type Event struct {
    Timestamp     string        `json:"timestamp"`          // RFC3339Nano UTC
    Sequence      uint64        `json:"sequence,omitempty"` // per-writer counter, tiebreak only
    Vendor        string        `json:"vendor"`             // "beacon"
    Product       string        `json:"product"`            // "endpoint-agent"
    SchemaVersion string        `json:"schema_version"`     // "1.0"
    Event         EventInfo     `json:"event"`    // id, kind, action, category, fidelity
    Severity      Severity      `json:"severity"`
    Endpoint      EndpointInfo  `json:"endpoint"` // hostname, os, agent_version
    User          UserInfo      `json:"user,omitempty"`     // OS name, uid
    Harness       HarnessInfo   `json:"harness"`  // name, version, ..., collection_method
    Origin        Origin        `json:"origin,omitempty"`   // local | cloud | ci
    Run           *RunInfo      `json:"run,omitempty"`      // CI/cloud run ids
    Session       *SessionInfo  `json:"session,omitempty"`  // id, working_directory
    Handoff       *HandoffInfo  `json:"handoff,omitempty"`
    Trace         *TraceInfo    `json:"trace,omitempty"`
    Tool, File, Command, MCP, Approval, Policy, Prompt, SystemContext, Content, VCS, GenAI ...
    Model, Repository, Branch, Message string; Raw map; ...
}
```

`Validate` (`AO/event.go:750-793`) requires vendor, product, schema version, `event.kind` and `event.action`, severity, `endpoint.os` and `harness.name`, and checks the enums for origin, fidelity, collection method and `content.retention`. Harness names and model ids are canonicalized at write time, not at read time.

Correlation ids:

| Id | Field | Derivation |
|---|---|---|
| Session | `session.id` | the runtime's own id, resolved per platform; OTLP `conversation.id` maps here |
| Turn | `prompt.id` | first hit in an alias table (`promptId`, `prompt_id`, `turn_id`, `generation_id`, ...); unique only within a session; survives Metadata-only |
| Tool call | `gen_ai.tool.call.id` | alias table over `tool_use_id`, `call_id`, `callId`, ...; links call, result, approval and the OTLP twin; also used as `trace.span_id` |
| Approval | none of its own | the tool-call id plus `approval.*` fields |
| Event | `event.id` | UUIDv5 under a fixed namespace (`AO/identity.go:143-196`) |
| Trace | `trace.id` | OTLP trace id if present, else `session:<harness>:<session.id>` (`AO/identity.go:230`) |
| Handoff | `handoff.source_harness`, `handoff.source_session_id` | parsed from a marker in the first prompt (section 8) |
| Commit | `vcs.ref.head.revision` on `session.commit_linked` | git-note attribution (section 9) |

The `event.id` derivation has two modes (`identity.go:177-196`). When the event has a tool-call id and a resolvable target, the id hashes session, lowercased action, target and call id, so the hook and OTLP reports of one call get one id despite different timestamps and fields. Otherwise it hashes the whole serialized line. A kind prefix (`call` or `content`) keeps the two value spaces apart. The comment is careful about what equality means: a content-digest match says "nothing recorded tells these apart", which is weaker than saying it is the same action and, in the comment's words, "not one to drop an event over".

Ordering is by `(timestamp, sequence)`, never by file position. `sequence` is per writer and restarts with each hook process (`event.go:644-654`), so "a hook event numbered 1 may well have happened after an exporter event numbered 900".

### 5. Privacy

**Redaction.** `SanitizeEvent` runs in every writer before validation. String values pass through a rule table (labelled secrets, key-name suffixes, Authorization headers, URL credentials, PEM blocks, provider token prefixes) and become `[REDACTED]`, keeping type prefixes such as `ghp_[REDACTED]` so detection rules still match. Strings are capped at 4,096 bytes, raw strings at 2,048 (`AO/privacy.go:14-21`). `CleanString` "redacts before it truncates, so a cut can never leave part of a secret behind" (`privacy.go:608`). The docs state the limit: "A credential with no label and no recognizable shape, such as a bare random password, is not detected" (`docs/security/retention-redaction.mdx:33`).

**Retention.** Beacon keeps full content locally by default. The old `--content-retention` flag is a deprecated no-op ("Beacon now always captures full content", `CB/cmd/ci.go:99-101`). The local log therefore holds prompts, command output, diffs and tool results, after redaction and size caps.

**Standard versus Metadata-only.** These are forwarding modes; capture is the same in both.

| | Standard | Metadata-only |
|---|---|---|
| Prompts, responses, reasoning, `message`, `raw` | sent (redacted, truncated) | removed |
| Tool arguments, results, descriptions, definitions | sent | removed |
| Command output, file diffs | sent | removed |
| `approval.reason`, `policy.reason`, eval explanations | sent | removed |
| Ids, `prompt.id`, timestamps, actions, harness, model, token usage | sent | kept |
| `command.command`, `tool.command`, file paths, decisions | sent | kept |
| `content` block | as captured | rewritten to `retention=metadata, included=false, redacted=true` |

Enforcement happens in two places with one field list. The Vector forwarder applies the `beacon_runtime_metadata` transform (`CB/internal/endpoint/asymptote/pack/metadata-transforms.toml:1`) on the device, before the disk buffer. The Go function `StripToMetadata` (`AO/metadata_only.go:20-61`) mirrors it, and its only non-test caller is the share builder (`AO/share.go:339`), so a share from a Metadata-only endpoint sends no more than forwarding would. The doc comment says "A test in the forwarding pack holds the two to the same list of fields." Neither applies to the local log, the dashboard, lenses or the memory store, which all see full content.

Metadata-only is a structural projection: it keeps who did what to which file with which command, and drops what anyone said.

### 6. Local surfaces

**Dashboard.** `beacon endpoint dashboard` runs a foreground HTTP server on `127.0.0.1:8765` (`CB/internal/endpoint/dashboard/server.go:26`). It rejects non-loopback bind addresses and non-loopback `Host` headers to stop DNS rebinding (`RequireLoopbackHost`, `server.go:443`). The docs say it "never mutates endpoint configuration, telemetry, or memory review state" (`docs/architecture/architecture.mdx:101`). It is not a service.

**Lenses.** A lens is one HTML file (16 MiB cap) with a JSON manifest (`api: "beacon.lens.v1"`), loaded in a sandboxed iframe whose CSP includes `connect-src 'none'` (`dashboard/lenses.go:49-50`). Its only input is one call, `window.beacon.getTrace()`, which resolves to a `beacon.trace.v1` bundle plus findings and token data. Seven are built in (activity, approvals, files-changed, mcp, security-review, skills, token-usage). The docs concede two limits: the sandbox is best-effort hardening, and "A lens sees one trace. A view across several sessions is not supported yet" (`docs/concepts/lenses.mdx:144`).

**History search.** `history.db` is an SQLite file with FTS5 trigram indexes over events and traces (`dashboard/history_store.go:356-357`). It keeps traces 90 days and caps the file at 1 GiB (`history_store.go:48-51`). It is off until the user turns it on: the first interactive `beacon traces` asks once, `traces reindex` creates it, and scripts, agents and `--json` callers are never asked (`docs/cli/endpoint-traces.mdx:36-40`). Every trace command catches it up with the log first, so it needs no background process; activity that rotates out of the log before any catch-up is lost.

**Local MCP.** `beacon mcp serve` speaks MCP `2024-11-05` over stdio or loopback HTTP at `127.0.0.1:8766` (`CB/cmd/mcp.go:21`). It exposes seven tools (`CB/internal/mcpserver/server.go:126`): four activity tools (`search_activity`, `summarize_activity`, `get_activity_event`, `list_activity_filters`) and three memory tools (`search_memory`, `get_memory`, `get_memory_context`). All seven are read-only. An agent connected to the local server can recall memory but cannot file it.

### 7. The memory loop

#### 7.1 Overview

```
 runtime.jsonl / history.db
        |  dashboard.ShowTrace (beacon.trace.v1)
        v
 A. SCORE (optional, networked)            B. AUTHOR (local)
    BuildProjection: <=80 events,             agent reads the trace with
    head 40 + tail 40, 1,200-char fields,     `beacon endpoint traces show`
    redacted -> POST Jev or SubQ              and drafts a lesson
    3 yes/no questions -> probabilities       `beacon memory candidates create`
    PromotionDecision -> candidate
    (placeholder body)
            \                                  /
             v                                v
          memory.db: candidates {state: candidate}
                      |  approve (title/body/applicability edits) | reject | supersede
                      v
          memory.db: memories (one project_id, superseded_by)
              |                                        |
          RECALL (pull)                            PROMOTE
          search_memory / get_memory_context       .agents/skills/beacon-<slug>/SKILL.md
          beacon memory list -q                    -> git commit -> teammates
```

Nothing in this loop runs from hooks. The docs say so directly ("Nothing in this workflow runs from hooks", `docs/concepts/cross-harness-memory.mdx:17`), and the code agrees: no hook path imports the learning package.

#### 7.2 Project identity and storage

Memory lives in `memory.db`, an SQLite file next to the log directory, so `~/.beacon/endpoint/memory.db` in user mode (`CB/internal/learning/store.go:52-61`). The docs call it "durable local state, unlike the rebuildable trace index". It has three tables: `evaluations`, `candidates` and `memories`, each with a few denormalized columns and the full record as JSON.

Every record belongs to exactly one project. `ProjectID` hashes the lowercased remote URL, or the path when there is no remote: `"sha256:" + hex(sha256(lower(remote_url || path)))` (`store.go:193-200`). Worktrees resolve to the main checkout's remote. Two consequences follow. The id is deterministic across machines when the remote string is identical, which is the only cross-machine key the memory store has. And an `ssh://` clone and an `https://` clone of the same repo get different ids, so their memory is silently split.

#### 7.3 Evaluation and the promotion rule

`beacon memory evaluations run` is the only networked step. It sends a projection of one trace to a System One decisions endpoint: TypeSafe Jev by default (`https://api.typesafe.ai/v1/systemone`) or SubQ (`https://api.subq.ai/v1/systemone`), with a 10-second default timeout and a flat cost estimate of $0.00035 per trace (`CB/internal/learning/evaluator.go:18-26`, `:315-317`). `--dry-run` makes no request.

The projection keeps at most 80 events. For longer traces it keeps the first 40, an `omitted` marker and the last 40, because "Long sessions resolve at the end ... otherwise the evaluator never sees the fix or outcome a reusable lesson depends on" (`evaluator.go:240-253`). Each text field is cut to 1,200 characters with redaction.

The rubric is three yes/no questions (`evaluator.go:72-76`):

- `task_success`: "Did the trace complete the user's engineering task successfully?"
- `reusable_correction`: "Does the trace contain a correction or debugging pattern that future agents should reuse?"
- `evidence_supported`: "Is the reusable lesson supported by concrete events in the trace?"

The score is the plain mean of the three probabilities (`evaluator.go:452-461`). A trace becomes a candidate only when the evaluation completed, `task_success` was answered and is at least 0.50, and the mean is at least 0.60 (`candidate.go:13`, `:25`, `:32-47`). The 0.50 precondition came from issue #649: answers of 0.27, 0.86 and 0.69 average 0.6067, which cleared the score threshold for a trace the judge said had failed (`candidate.go:19-24`).

The evaluator returns probabilities, and the lesson text comes later. A scored candidate gets a guessed kind (keywords like "fix" or "debug" give `debugging_pattern`, otherwise `correction`) and a boilerplate body that says, when no rationale came back, "The evaluator returned scores with no rationale, so no lesson text was extracted. Review the source trace before approving." (`candidate.go:302`). The README's "evaluate what worked, extract useful knowledge" is, in code, a hosted classifier that filters traces, followed by an agent that writes the lesson and a person who approves it.

#### 7.4 The agent-authored path

`beacon memory candidates create --trace T --kind K --title ... --body-file -` files a candidate with no evaluator (`CB/internal/learning/authored.go`). Kind, title and body are required. `SourceEvaluationID` stays empty, "which is how an authored candidate is told apart from a Jev-scored one" (`authored.go:12-13`). The `beacon-memory-distill` skill drives this path when the user declines the networked evaluation or has no key. It tells the agent that "the strongest evidence for a lesson is a failed command followed by a different command that succeeded" and to pick three sessions at most (skill guidance, not a CLI limit; the command takes one `--trace`).

#### 7.5 Records and evidence

The schemas are in `AO/learning.go` under `beacon.learning.v1`:

```go
type LearningEvidenceV1 struct {
    TraceID     string   `json:"trace_id"`
    EventIDs    []string `json:"event_ids,omitempty"`
    Summary     string   `json:"summary,omitempty"`
    Redacted    bool     `json:"redacted,omitempty"`
    Truncated   bool     `json:"truncated,omitempty"`
    ContentHash string   `json:"content_hash,omitempty"`
}

type LearningCandidateV1 struct {
    SchemaVersion, ID, MemoryID, State, Kind, Title, Body, Applicability string
    Tags               []string
    Project            LearningProjectV1     // id, path, remote_url, branch
    SourceEvaluationID string                // empty for agent-authored
    Evidence           []LearningEvidenceV1
    CreatedAt, UpdatedAt, ApprovedAt, RejectedAt, SupersededAt string
    SupersededBy, ReviewReason string
}

type LearningMemoryV1 struct {
    SchemaVersion, ID, CandidateID, Kind, Title, Body, Applicability string
    Tags         []string
    Project      LearningProjectV1
    Evidence     []LearningEvidenceV1
    CreatedAt, UpdatedAt, SupersededBy string
}
```

| States (candidate) | Kinds (both) |
|---|---|
| `candidate`, `approved`, `rejected`, `superseded` (`learning.go:11-16`) | `workflow`, `correction`, `debugging_pattern`, `gotcha`, `convention` (`learning.go:18-24`) |

Evidence is by reference: a trace id and event ids, never trace content. Those references resolve only on the machine whose log or `history.db` still holds the trace. Ids are truncated SHA-256 content hashes (`store.go:676-690`): a candidate id covers project, kind, title, body and evidence, so filing an identical lesson twice produces one record; a memory id covers project, candidate id, kind and title.

The records have no author field, no reviewer field, no confidence, no usage counter, no TTL and no scope other than the single project.

#### 7.6 The review gate, and how strong it is

`ApproveCandidateWithEdits` (`candidate.go:131-172`) is the one place a memory comes into existence. It checks that the kind is valid and the candidate's state is `candidate`, builds the memory from the reviewer's edits (empty fields fall back to the candidate's values), writes the memory, then flips the candidate to `approved` with `ApprovedAt`, `MemoryID` and a free-text `ReviewReason`. The candidate keeps the evaluator's original text "so the review history shows what was changed" (`candidate.go:100-105`). Reject requires the `candidate` state too. Supersede requires a replacement memory in the same project but does not check the candidate's state, so an already-approved candidate can be superseded, which also stamps `superseded_by` on its memory.

The review is a convention of the workflow that the code does not check. The approval records the lesson text, a timestamp and a reason string, but not who approved it. The CLI command (`CB/cmd/memory.go:498-512`) needs no TTY, no confirmation and no reason, and nothing distinguishes a person from an agent running `beacon memory candidates approve`. If no `--body` is given, a scored candidate's placeholder body becomes the memory body; the only code reference to the placeholder string is the generator itself. The human-in-the-loop guarantee lives in the distill skill's text: "Never approve a candidate with its placeholder body" and "A candidate the user has not decided on stays a candidate. Silence is not approval." (`agent-skills/skills/beacon-memory-distill/SKILL.md:27`, `:254`). The docs describe the loop as "deliberately review-gated" (`cross-harness-memory.mdx:32-33`), which is true of the intended workflow and not of the Go API.

#### 7.7 Recall

Recall is pull-only. An agent calls `get_memory_context` or `search_memory` over local MCP, or the CLI's `beacon memory list -q`, and reads the results.

`Store.ListMemories` (`store.go:591-651`) selects non-superseded memories for the project, ordered by `updated_at DESC`. When there is a query, it loads the rows and applies `matchesText` in Go (`store.go:737-749`): split the query on whitespace, lowercase, and require every term to appear as a substring of `id + title + body + kind`. Applicability and tags are not searched. I would not call this search in the usual sense: it ANDs substrings over four fields and returns the newest matches first, with no ranking, stemming or embeddings. The recall skill compensates by telling agents to search with "two or three distinctive terms", since a full sentence matches nothing.

Scoping happens before matching (`server.go:386-406`). An explicit `project_id` wins. A `project` path is matched only against projects already present in `memory.db` and never touches the filesystem; an unknown path "keeps scoping the query to nothing rather than widening it to every project" (`store.go:234-241`). With neither, the server resolves the project from git in its own working directory, so a stdio server launched by a harness inside the repo scopes itself. `search_memory` returns at most 20 summaries with bodies cut to 1,200 characters; `get_memory_context` returns at most five, and its `task` argument is just an alias for `q`.

#### 7.8 Promotion to Agent Skills

`beacon memory skills install <candidate>` renders an approved, non-superseded memory as `.agents/skills/beacon-<slug>/SKILL.md` in the project (`CB/internal/learning/skills.go:19-70`, `AO/learning_skill.go:11-13`). Provenance (`beacon_memory_id`, `beacon_candidate_id`, kind, tags) goes under the Agent Skills `metadata` map "so a generated skill validates and can be published like any other". The file is written 0644 and is not overwritten without `--force`. A superseded memory does not update an installed skill.

This is the only path by which a lesson reaches a teammate in the open product, and it reaches them through git: someone commits the file and the teammates' harnesses load it. It is also the only path with semantic matching, since the harness compares the skill's description (title plus applicability) against the task. The promote skill warns accordingly: "Each installed skill adds to what every agent considers on every task."

#### 7.9 Cloud memory is a separate store

Beacon Cloud runs the same `beacon.learning.v1` workflow over forwarded sessions through the hosted `beacon-managed` MCP server: `beacon_memory_candidate_create`, `_list`, `_get`, `_review`, `beacon_memory_search`, `beacon_memory_get` and `beacon_memory_skill`. The tool names and arguments come from `docs/deployment/managed-mcp.mdx` and the distill skill; the server code is not in this repo. There is no evaluator on the cloud path ("There is no evaluator", `managed-mcp.mdx:175`): the agent reads a session, drafts, and files only after the user confirms. Cloud cannot write to the repository, so `beacon_memory_skill` returns content and a path for the agent to write. The renderer is shared, so a skill promoted from either side is byte-identical.

The two stores never sync: "Memory approved through Beacon Cloud stays there; it is not copied into the local store, and local memory is not uploaded" (distill skill, `SKILL.md:259`). Cloud memory is personal, like the rest of Cloud (section 11).

### 8. Handoff briefs

`beacon handoff list|export|resume` moves work between sessions and runtimes on one machine. It reads each runtime's own session store, not the Beacon log, because "the store is what the runtime itself resumes from, and it keeps content that the runtime log truncates" (`CB/internal/handoff/session.go`). If the store is gone it falls back to the log and says the brief keeps less.

The brief is Markdown capped at 48 KiB (`handoff/brief.go:18-23`): source session details, first and latest requests and the last agent message (800 runes each), up to 40 changed files, up to 12 recent commands with exit codes, and the last 30 steps at 1,200 runes each, dropping the oldest first to fit. Events pass through `SanitizeEvent`, and fences are lengthened so quoted content cannot forge structure. A closing section tells the receiving agent to check `git status` and to confirm its understanding with the user before changing anything. Briefs are written 0600 under `~/.beacon/endpoint/handoffs/`.

`resume` reopens the session natively (`claude --resume`, `codex resume`) when the runtime, CLI, session file and directory allow it. Otherwise it starts a new session whose first prompt names the brief by path, never inline, because "a command line is visible to every local user through the process table" (`handoff/resume.go:179-185`), and ends with the marker `[beacon-handoff from=<harness> session=<id>]` (`AO/handoff.go:25`). The hook adapter (`cli/beacon-hooks/cmd/handoff_link.go`) and 14 poller mappers parse the marker and write a `session.handoff` event linking the new session to the old one. The doc comment is explicit that the marker is unverified prompt text and not a verified identity, because "anyone can type it" (`AO/handoff.go:16-19`). Launches never pass flags that disable approvals and unset switches like `COPILOT_ALLOW_ALL` and `HERMES_YOLO_MODE`.

### 9. Share links and git notes

**Share.** `beacon share` projects a session into the same `beacon.trace.v1` bundle that lenses and the evaluator use and uploads it to `https://api.beacon.sh` (`AO/share.go`; `CB/cmd/share*.go`). Before upload it sanitizes every event, rewrites paths relative to the session directory and the home directory to `~`, drops the working directory and repo path, and strips credentials from the remote URL. Visibility is `direct` (default, anyone with the link), `private` (owner only) or `public` (listed; needs confirmation) (`share.go:55-57`). There is no per-user, team or org ACL. Uploads are idempotent by event id, and `refresh` sends only events after the server's watermark. A committed `.beacon/share.json` can turn auto-share off for a repo but never on, so "a cloned repository can never publish your sessions".

**Git notes.** `beacon git setup` installs a post-commit hook that attributes a commit to sessions by file overlap: it scans the log for sessions with write operations on the commit's changed files, in a window of at least 2 hours and at most 24 hours before the commit plus one minute of slack, linking up to 32 sessions (`CB/internal/gitlink/link.go:13-21`). The only attribution method is `file_overlap` (`gitlink/events.go:15`), and the docs call a link "an inference, not an observation". Links are written as a note under `refs/notes/beacon` (`gitlink/note.go:13`), one line per session, `beacon:<harness>/<session-id> [share-url]`, and a `session.commit_linked` event is written to the log. Share URLs in notes are accepted only for https on allowed hosts, because notes can come from anyone who can push. With `--share-notes`, remote notes are fetched into `refs/notes/beacon-remotes/<remote>` (`gitlink/share.go:15`) and merged with git's `cat_sort_uniq` strategy, which keeps every line from both sides. This is the one decentralized, server-free team primitive in the product, and it carries identifiers only.

### 10. Detection rules

Rules are YAML with a CEL `match` expression over an event `e`, or a `correlation` of steps within a time window, scoped to one session (the only scope in spec v1.1, `spec/threat-rules/SPEC.md:99`). Evaluation cost is capped at 5,000,000 CEL units per expression; exceeding it is an error, never a match (`AO/threatrules/cel.go:94`). The spec calls the format "Sigma for agent telemetry" and allows the engine that consumes it to be "separate (and closed)".

The corpus has 77 rules across 10 categories: 57 stable and 20 experimental, and all 77 are `posture: detect` (counted with grep over `rules/`). Examples include `secret-read-then-egress` (a credential-file read and a `curl`/`wget`/`nc` egress within 120 seconds, in either order) and `indirect-injection-in-tool-result`, which matches a derived `result_text` field carrying both an override phrase and a download-and-execute payload.

Evaluation today is offline. `beacon scan` reads the log, sorts by `(timestamp, sequence)` and returns findings: a Go struct with rule id, severity, session and the evidence events (`AO/threatrules/findings.go:59-70`). The dashboard runs the same scan on demand, and `--fail-on high` makes it a CI gate. Scan "does not mutate endpoint telemetry" (`docs/detections/engine.mdx:115`), so findings are not written back to the log, and the open build has no alert channel. Nothing under `cli/beacon-hooks` imports the rules engine. Because scan reads only what the log still holds, a rule can see roughly 60 MiB of history unless `history.db` is on.

Metadata-only data keeps command lines, file paths and MCP fields, so command, file and correlation rules still work on it. It removes prompt text, tool results and tool descriptions, so the prompt-injection rules that match those fields are blind there.

**The policy provider.** The only hook-time control is `BEACON_POLICY_PROVIDER`, an executable the hook consults on the `pre-tool` and `permission-request` phases (`AO/policycontract/policycontract.go:30-32`). The client "contains no enforcement decision logic of its own", is inert when unset, and is fail-open "on every error path (missing provider, marshal failure, exec failure, timeout, non-zero exit, malformed output)" with a 2-second timeout (`cli/beacon-hooks/internal/policy/policy.go:1-31`). A `deny` is rendered in each runtime's own deny shape and logged as `policy.decision=deny`. The repo ships a reference provider that denies one pattern, and `beacon endpoint enforcement enable` downloads a signed Enterprise package; the decision logic is not open.

### 11. Beacon Cloud and its team model

Beacon Cloud is reached through a Vector forwarder that tails the log and posts gzip NDJSON to `/v1/ingest/runtime` with a per-device bearer key, after the on-device privacy transform. Connecting stages a 30-day backfill. The account model is a beacon.sh sign-in (browser PKCE); `CLAUDE.md:30` insists "Signing in alone never starts forwarding" and the install wizard's confirm screen is the consent point.

The team model is thin. Each machine has its own `bcn_device_*` key. On every request the ingest checks that "the user who approved the device is still a member of the organization" and that the org is enabled, cached for one minute (`docs/log-forwarding/asymptote.mdx:42`). The tenant is added server-side and the device never asserts it. Beyond that, the product is personal: "You see only your own history. Results are limited to sessions forwarded from your devices" (`docs/deployment/managed-mcp.mdx:239`). SSO, RBAC and audit logs appear only as Enterprise bullets in the plans table, and no role names exist in the repo. MDM packages do not enroll devices: "there is no headless enrollment for MDM fleets yet; it is a planned follow-up" (`docs/mdm/index.mdx:62`).

Cloud adds hourly "findings" (security, skill and behavior kinds) over forwarded activity, with user feedback read by the next run. The docs say these are separate from local `beacon scan` results; whether they reuse the CEL rules is not visible in this repo.

### 12. Implemented, documented-only, planned

| Capability | Status | Basis |
|---|---|---|
| Hooks, plugins, collector, pollers, three writers, dedupe, rotation | Implemented | code read |
| Browser extension | Implemented, beta; adapters marked experimental | code and docs |
| Provenance markers, deterministic `event.id` | Implemented | `AO/provenance.go`, `AO/identity.go` |
| Redaction, Standard/Metadata-only (Vector + Go) | Implemented client side | `AO/privacy.go`, `AO/metadata_only.go`, pack TOML |
| Dashboard, lenses, `history.db`, local MCP (read-only) | Implemented | code read |
| `memory.db`, evaluator gate, authored candidates, approve/reject/supersede | Implemented | `CB/internal/learning/*` |
| Skill promotion to `.agents/skills` | Implemented | `skills.go`, `learning_skill.go` |
| Handoff list/export/resume, marker linking | Implemented | `CB/internal/handoff`, `handoff_link.go` |
| Share client, git notes and note sharing | Implemented (beacon.sh server closed) | `AO/share.go`, `CB/internal/gitlink` |
| `beacon scan`, 77 rules, CI gate | Implemented, offline only | `cmd/scan.go`, `rules/` |
| Policy provider client | Implemented, inert by default | `policy.go` |
| Inline enforcement, block or require approval | Enterprise, closed; installer stub only | `cmd/endpoint_enforcement.go`, plans |
| Cloud ingest, findings, memory MCP tools, alerts | Documented-only here (closed service) | `docs/deployment/managed-mcp.mdx`, cloud changelog |
| SSO, RBAC, audit logs, Okta/Slack connectors | Documented-only (plan bullets) | `docs/deployment/plans.mdx` |
| Scheduled poll collection | Not present (docs list poll as a capture path) | no service unit runs syncs |
| MDM headless enrollment | Planned | `docs/mdm/index.mdx:62` |
| Cross-session lenses | Planned ("not supported yet") | `docs/concepts/lenses.mdx:144` |

### 13. What Beacon does not do today

These are the gaps Part 3 builds on. Each was checked against the code or the docs' own wording.

- **No team or org scope for memory.** Every memory belongs to one project id. The docs say "Cross-project or user-global memory is not automatic." Cloud memory is per account and never synced with local memory. The only shared channels are committed skills, git notes and share links.
- **No reviewer identity or signatures.** Approval records text, a timestamp and a free-text reason. No `approved_by`, no author, no signature, no check that a person rather than an agent approved, and no check against the placeholder body. Events are unsigned too; the log is trusted by file permission (0600 in user mode, a shared mode on the system log).
- **No ranked or semantic recall.** Recall ANDs lowercase substrings over id, title, body and kind and orders by recency. Applicability and tags are not searched. No embeddings, no relevance scores, no usage signal.
- **No live injection into running sessions.** Recall is pull-only: an agent must call a tool, or a committed skill must match. Hooks write nothing into the model's context on purpose, plugins refuse injection hooks, and the only hook output with effect is a policy deny.
- **No streaming detection.** Rules run in batch over the log when someone runs `beacon scan` or opens the dashboard. Findings are not written back, and the open build has no alerting.
- **No durable long-term history by default.** The log holds about 60 MiB. `history.db` keeps 90 days or 1 GiB but is opt-in and catches up only when a trace command runs. Memory evidence points at trace and event ids that resolve only on the originating machine while that history exists.
- **No scheduled polling.** Poll-only harnesses appear when a sync runs, not continuously.
- **No freshness or lifecycle on memory.** No TTL, no validity window, no "still true at HEAD" check. Supersede is a manual, same-project action, and installed skills do not follow supersession.
- **No conflict handling beyond exact duplicates.** Content-hash ids collapse identical candidates only; two differently worded versions of one lesson coexist.
- **No outcome loop.** Memory records carry no cost or usage data, nothing records whether a recalled memory helped, and the evaluator never sees later sessions.
- **No verified cross-session provenance.** The handoff marker is unverified prompt text, and commit links are file-overlap inferences.
- **No identity beyond the OS user and a device key.** The event has `user.name` and `uid`; Cloud adds an org check at ingest. There is no roster, no person-to-agent mapping and no notion of a teammate's session.
- **No normalization of project ids.** Different remote URL spellings split one repo's memory.

### 14. Non-obvious details

- **Loopback is not trusted.** The OTLP receiver does not check the `Host` header, so a DNS-rebinding page could have written `session.share_requested`, which Beacon Cloud treats as a request to publish. The collector now ignores four Beacon-only actions when they arrive over OTLP (`AO/beacon_actions.go:17-23`, `docs/changelog/cli.mdx:37`).
- **`active-sessions.json` is a small presence table.** Every hook records harness, session id, working directory and a timestamp, capped at 256 entries and 14 days, "never content" (`AO/activesession/activesession.go:7-35`). `beacon share --current` uses it to find the session it runs inside.
- **Claude's env file is part of the plumbing.** The SessionStart hook appends `export BEACON_SESSION_ID=... BEACON_HARNESS=claude_code` to `$CLAUDE_ENV_FILE`, with a strict regex on the id because that file is executed by a shell.
- **The MCP `project` argument is treated as hostile.** It is matched only against projects already in `memory.db`, so an agent cannot use it to probe the filesystem.
- **At the store layer, empty scope means all projects.** `ListMemories` with neither a project id nor a path adds no project clause (`store.go:272-284`, `queryWhere`). The MCP server and CLI always resolve a project first, so this only matters to a new caller of the Go API.
- **One projection feeds four consumers.** The `beacon.trace.v1` bundle is what lenses render, what share uploads, what the evaluator scores and what the dashboard trace view shows, so a change to it reaches all four.
- **The rule corpus depends on capture quality.** The repo's sandbox scenario `m01-poisoned-mcp-tool` exists because a missing `mcp.server` field had made every rule gated on it "silently dead".

---

## Part 3: The hive, a technical design

Parts 1 and 2 describe two systems that each build one piece of what a team of agents needs. Reason Network gives a team's agents a shared present: who is running where, on which branch, doing what, and a way to put a message into a running session. Beacon gives one person's agents a past: a normalized record of what every harness did and a reviewed set of lessons drawn from it. Neither has a future tense, meaning obligations, standing requests and an agent that is still awake when the laptops close. Freyja's always-on platform design and its orchestrator design supply that part.

This part describes one system built from all three, which I call the hive. It is a design, not a description of running code, and every component is mapped to the code it would reuse. Where the design needs a change to either repository, the change is named with the file it touches.

### 1. Constraints

1. **No required server.** Reason Network's mesh needs no central service, and the hive keeps that property. The always-on machine (the anchor) is a roster member like any other, so losing it costs history and proactive help but not messaging between online machines.
2. **The friends assumption stays.** The roster module's first line says it is "Built for a team of friends who trust each other, not adversarial members" (`src/team.rs:1-3`). The hive defends against compromised content (a poisoned issue, a malicious package, a web page an agent read) and leaves a malicious teammate to the social controls a team already has.
3. **Metadata leaves the machine by default, content does not.** Presence and the graph use Beacon's Metadata-only projection. Prompts, tool output and diffs leave a machine only when their owner shares a trace or approves a lesson.
4. **One author per proactive message.** Any session or watcher may propose something for a person's attention, but only the anchor decides whether it reaches them, and every decision is recorded.
5. **Everything shared is a projection over append-only logs.** The graph, project pages and session cards can be dropped and rebuilt, and retraction is a replicated tombstone, not a hope that every copy gets deleted.
6. **Authority is earned.** Learned components start in shadow and are promoted on measured agreement, the way Freyja promotes its Jev decision models.
7. **Unattended work stops at drafts.** The anchor may do anything inside its own sessions and branches. Anything another person will see, or that changes shared state, waits for a signed approval from the person it affects.
8. **The graph finds the work.** Proactive work starts from a record the anchor can check: a contract that changed, two branches that fail when merged, a pull request that is ready with no reviewer, a date that has passed. A model does the work; it is not asked to search for something to do, because models find work far less reliably than they do it. On a subset of Active-SWE, Claude Opus 4.8 resolved 59% of bugs when given the issue report and 26% when it had to find them (arXiv:2608.04682, Table 3).

### 2. One adapter per harness

Both repositories maintain a per-harness adapter layer. Beacon's observes: native hooks for about 20 harnesses, seven managed plugins, a local OTLP collector and 14 session-store pollers. Reason Network's injects: a Claude Code messaging socket, the Codex queue command, opencode and pi plugins, hook outputs for Cursor, Gemini CLI, Copilot and Grok, and an MCP server for everything else. Where a harness has hooks, both attach at the same points (session start, prompt submit, tool results, stop), and both encode, harness by harness, which hook outputs reach the model. Beacon records that table only to stay silent (`cli/beacon-hooks/cmd/pre_tool.go:173-260`, `hookStdoutIsConsumedAsAgentContext` in `cmd/kiro.go`); turn-tier delivery needs exactly the same table.

The hive merges them into one descriptor per harness:

```rust
pub struct HarnessAdapter {
    identity: Identity,  // canonical name, aliases, version probe, session-id and cwd keys
    install:  Install,   // owned-block writer, detector, uninstaller, re-parse check
    observe:  Vec<Source>, // Hook | Plugin | Otlp | Poll, each tagged collection_method + fidelity
    presence: Presence,  // which events update busy/idle/doing/files, and their latency class
    inject:   Inject,    // tiers offered: Push(channel) | Turn(hook points) | Pull(mcp)
    gate:     Gate,      // approval visibility, deny shapes, stdout-is-context flags, no-opinion reply
}
```

| Plane | Reason Network today | Beacon today |
|---|---|---|
| identity | agent kind from MCP client name and hook flags (`src/agent.rs`) | `NormalizeHarnessName`, session-id alias tables (`AO/harness.go`, `AO/identity.go`) |
| install | `setup.rs` writes hooks, plugins and MCP config | `CB/internal/endpoint/hooks/*`, owned blocks with uninstall |
| observe | transcript tails and todo files for `doing` (`src/daemon/directory.rs:54-126`) | four capture families into `runtime.jsonl` with provenance |
| presence | rebuilt every 2 seconds from session files and git (`src/net.rs:586-635`) | `active-sessions.json` presence table; `session.*` events |
| inject | push, turn and pull tiers (`src/daemon/mail.rs`, `src/hook.rs`) | none, by design |
| gate | `ask_user` dialog and signed approval (`src/daemon/ask.rs`) | deny-only policy provider, inert by default (`policy.go`) |

**Observation feeds presence.** The daemon tails Beacon's log, which the architecture doc calls "the stable handoff boundary", instead of re-parsing transcripts. `prompt.submitted` marks a turn as started, `tool.completed` and `agent.message` keep it busy, `session.ended` and the idle events end it, todo-shaped tool calls update `doing`, and `file.read` and `file.modified` give the files a session touched. Presence gets the same latency as capture: immediate for hook and plugin harnesses and batch-flush latency for OTLP. Poll-only harnesses update today only when someone runs a sync, so the daemon runs their pollers in Beacon's watch mode (a one-minute ticker), and the descriptor states the resulting minute-scale latency instead of hiding it.

**The hook process is the turn-tier vehicle.** Beacon already runs a short-lived hook process at every turn boundary on every hooked harness. In the hive, that process writes its event as it does today and then asks the daemon over the local socket whether anything is queued for this session. It waits at most 150 ms (an assumption to tune), and on a timeout or an empty queue it prints the harness's no-opinion reply from Beacon's table, so a slow daemon can never become a control decision.

This is Beacon's own rule: "A telemetry write that failed must never become a control decision" (`logging.go:236-246`). One hook binary per event replaces the two that would otherwise run.

**Injection is visible in the same log.** Every delivery writes an event (`team.message.injected` with the session id, turn id and tier), so the record of what entered a model's context sits next to the record of what the model did. Beacon's security posture depends on never writing into context; the hive keeps that property for everything except team traffic, and makes the exception auditable.

### 3. The daemon

```
 hooks / plugins ─┐                      ┌──────────────────────────────────────────┐
 OTLP collector ──┼─► runtime.jsonl ─────►  capture → presence → directory          │
 pollers ─────────┘   (Beacon writers)   │  mailbox ⇄ delivery (push / turn / pull)  │◄─► teammates
 hook "anything for me?" ── socket ──────►  screen (defang, rules, inbound policy)   │    (iroh QUIC)
 CLI / MCP ──────────────── socket ──────►  lessons ⇄ review (ask_user) → sign        │
                                         │  graph projection; obligations (anchor)  │
                                         └──────────────────────────────────────────┘
 stores: runtime.jsonl (Beacon) · network.db (RN sessions, mail, outbox) · lessons.db · graph.db
```

The process model follows Beacon's analysis of the merge: hooks stay thin and stateless, and state (queues, peers, lessons) is kept in Reason Network's resident daemon. The OTLP collector stays a separate process, since it is the one resident capture process Beacon already runs under launchd, systemd or a service manager.

Presence gains three fields. Reason Network's presence frame today is `{sessions, known, features}` (`src/net.rs:59-67`); each session entry gains `files` (paths touched in the last 30 minutes, at most 32, paths only), `turn` (`busy`, `idle` or `waiting_approval`, the last from an `approval.requested` without an answer) and `head` (the commit the branch points at). `files` and `turn` come from Beacon events under the Metadata-only projection, which keeps commands and file paths and drops prompts, outputs and diffs. `head` comes from one more `git rev-parse HEAD` beside Reason Network's existing repo and branch lookup (`src/git.rs:11-25`), since neither project reports it as presence today.

Metadata-only keeps file paths (`AO/metadata_only.go:37-40` clears only the diff), so the hive adds a path filter before a `files` entry leaves the machine. Its first patterns can come from Beacon's `credential-access/credential-file-read` rule, which already matches `.env` files, `.aws/credentials`, `id_ed25519` and git credential stores; without the filter, a teammate's directory would show `~/.ssh/id_ed25519` whenever an agent read it.

### 4. Wire protocol

Reason Network's team protocol is a stream of length-prefixed JSON frames tagged `Hello`, `State`, `Presence`, `Msg` and `Ack` (`src/net.rs:47-55`). The hive adds four:

```rust
#[serde(tag = "t", rename_all = "snake_case")]
pub enum Wire {
    Hello { team: String, proto: u32 },
    State { state: Box<TeamState> },
    Presence { presence: Presence },
    Msg { msg: Message },
    Ack { id: String, state: String, note: String },
    // hive additions, sent only to peers whose presence lists the "hive/1" feature
    Entry { entry: Signed<LogEntry> },        // one numbered entry in an (author, scope) log
    Body { hash: String, body: Vec<u8> },     // a lesson body, sent after its entry or on request
    Sync { want: Vec<(String, Scope, u64)> }, // highest seq held per (author, scope), for anti-entropy
}
```

The feature gate is not optional. An unknown frame type fails to deserialize, and the read loop treats any read error as a disconnect (`src/net.rs:681-690`), so one hive frame sent to an older build would drop the link. Reason Network already negotiates optional behaviour through the `features` list in presence (attachments use a `files` feature, and a sender inlines files for a peer without it, `src/daemon/mail.rs:679-692`), and the hive frames would be gated on a `hive/1` entry in that list. A `#[serde(other)]` catch-all variant in new builds would make later additions safe without a feature check.

Every signature uses the team id. Roster entries sign over `"tincan/1/{team}/{kind}/"` with the team id (`src/team.rs:25-30`; signed with `self.id` at `:304`, verified at `:421` and `:456`), but approvals sign over the team's name (`src/daemon/ask.rs:201`, `src/daemon/mail.rs:467`), so a rename while an approval is in flight makes it fail verification. Hive signatures use `"hive/1/{team_id}/{kind}/"`, and the approval path should move to the id at the same time.

#### Store-and-forward

Today a message for an offline machine waits in the sender's outbox for up to seven days (`src/store.rs:321-327`), so two teammates who are never online together cannot exchange mail. The anchor fixes this by accepting custody: when the recipient is offline and the anchor is online, the sender's daemon hands the message to the anchor, the anchor answers with a new ack state, `anchored`, and the sender clears its outbox entry. The anchor delivers when the recipient's machine connects and keeps the entry until the recipient acks.

Custody needs a change to how messages are authenticated. A peer message is trusted today because of the connection it arrives on: the receiver checks that the sender address starts with the roster person of the connected machine (`src/daemon/mail.rs:462-465`). A message relayed by the anchor arrives on the anchor's connection, so that check cannot pass.

Relayed messages therefore travel as `Signed<Message>`, signed by the originating machine's key with the same envelope as approvals, and the receiver verifies the signature against the roster instead of the connection. The anchor can read relayed mail, which is acceptable for a member the team runs. A team that wants relayed bodies unreadable by the anchor needs an encryption key per machine, for example an X25519 key for HPKE added to each roster entry; the roster's ed25519 keys are signing keys, and using them for encryption would need a separately specified conversion.

### 5. Lessons

#### The record

Beacon's approved memory (`LearningMemoryV1`) has an id, a kind, a title, a body, an applicability string, tags, a project and evidence pointers, and "no author field, no reviewer field, no confidence, no usage counter, no TTL and no scope other than the single project" (Part 2, section 7.5). Reason Network's approval has an approver, a question, the request it answers, a decision, a note and a time, signed by the approver's machine. A hive lesson joins the two:

```rust
pub struct LessonBody {
    pub id: String,             // blake3 over author, scope, kind, text, applicability, quote, evidence
    pub kind: LessonKind,        // Convention | Correction | Gotcha | Decision | Routing | Preference
    pub text: String,           // at most 1,200 characters, Beacon's recall body cap
    pub applicability: String,  // when it applies (Beacon's field)
    pub quote: Option<Quote>,   // { speaker, words, at }: required for Preference and Decision
    pub scope: Scope,           // Personal(person) | Project(project_id) | Team
    pub evidence: Vec<Evidence>,// { kind: Event | Commit | Message | Approval, reference, digest }
    pub author: String,         // a session address, e.g. alice/api-claude
    pub supersedes: Option<String>,
    pub at: i64,
}

pub struct Review {             // Reason Network's Approval, generalized
    pub about: String,          // lesson id, or message id for a request approval
    pub asker: String, pub approver: String, pub approved: bool,
    pub question: String, pub request: String, pub note: String, pub at: i64,
    pub scope: Option<Scope>,   // the scope the reviewer approved for sharing
    pub edits: Option<String>,  // the reviewer's replacement text, if any
    pub diff_hash: Option<String>, // prepared work: hash of the exact change approved (section 7)
    pub base: Option<String>,   // prepared work: the commit its tests ran on
}

pub struct Tombstone { pub lesson: String, pub reason: String, pub at: i64 }

pub struct LogEntry {           // what a log stores and its author signs
    pub author: String,         // the originating machine's roster endpoint
    pub scope: Scope,           // one log per (author, scope)
    pub seq: u64,               // gap-free within that log
    pub record: Record,         // Lesson(header + body_hash) | Review | Tombstone
}
```

Two rules from Freyja's orchestrator design apply at write time. A `Preference` lesson must quote the person it describes, so "Carol's agent says Bob prefers squash merges" is stored as hearsay with Carol's agent as the speaker and never treated as Bob's preference. And a lesson with no evidence is rejected: cite or omit.

#### Lifecycle

```
 draft ──propose──► proposed ──review: approve──► verified ──supersede──► superseded
 (local)            (visible to    │                  │
                     its reviewer) ├─ reject ──► rejected
                                   └─ no answer ──► held ──(morning digest)──► proposed
 any state ──retract──► retracted (tombstone replicates; the body is deleted from every daemon's store)
```

The states extend Beacon's candidate machine (`candidate`, `approved`, `rejected`, `superseded`, `learning.go:11-16`) with `held` and `retracted`. Beacon's `ApproveCandidateWithEdits` (`candidate.go:131-172`) is the single place a memory comes into existence today, which makes it the place to require a signed `Review` whose approver is a roster person.

Choosing the reviewer is a graph query. A personal lesson goes to the session's own user. A project lesson goes to whoever owns the files in its evidence: the CODEOWNERS entry if the repo has one, otherwise the person whose sessions and approved decisions touched those files most in the last 30 days. A team-scope lesson needs a reviewer per project it names.

The request travels through `ask_user`, so the reviewer sees the lesson's text, its evidence summary and the scope requested, and the answer comes back as a signed `Review`. A request that gets no answer in 24 hours moves to `held` and reappears in the reviewer's morning digest instead of interrupting again.

#### Scope and replication

Scope decides which machines receive a lesson. A `Personal` lesson never leaves its owner's machines. A `Project` lesson goes to the machines of the project's members, and a `Team` lesson goes to every member. Project membership needs an explicit record, because inferring it from activity would let anyone who clones a repo receive its lessons. The hive adds `Signed<ProjectMember { project, person }>` entries to the roster, proposed automatically when someone first runs a session in the repo and confirmed by the project owner through `ask_user`.

The project id is Beacon's: `sha256:` followed by the SHA-256 of the lower-cased, slash-normalized remote URL, or of the project path when there is no remote (`cli/beacon/internal/learning/store.go:193-199`). It needs normalization first (strip `.git`, map `git@host:org/repo` to `https://host/org/repo`), since today an ssh clone and an https clone of the same repo produce different ids and split its memory.

Replication runs over append-only logs, one per author and scope. Each machine numbers the entries it originates in each scope without gaps, peers exchange `Sync { want }` with the highest sequence they hold for each (author, scope) pair, and the sender replies with the missing entries for scopes the receiver belongs to, then any bodies it lacks. The logs are split by scope because filtering a single log would leave gaps that a receiver could not tell from loss. This is the read-union model Beacon already uses for git notes (`refs/notes/beacon` plus `beacon-remotes/*`, merged at read time and never written into a peer's ref), applied to logs instead of a ref.

A lesson's entry holds its header (id, kind, scope, author, evidence pointers, review references) and the hash of its body (`text`, `applicability` and `quote`, hashed together with the lesson id so two lessons never share a body), and bodies are kept in a separate content store keyed by that hash. The author signs the entry, not the body, so a header stays verifiable after its body is deleted, and the log stays append-only while a body can still go. A review is an entry in the reviewer's log, and a receiver treats a lesson as verified once it holds both entries.

A tombstone replicates with the same scope as its lesson. Receivers delete the body from their content store, keep the header and the tombstone so a late copy of the body is refused by its hash, and re-render every projection that cited the lesson. That is the forgetting rule Freyja's orchestrator design uses for personal memory, extended to a team. It removes a lesson from every agent's context and every daemon's store, but not from a copy a person saved outside the daemon, and the retraction prompt should say so.

#### Recall

Recall uses all three of Reason Network's tiers, with a different trigger for each. Pull is the default, and push is rare and always passes the anchor's gate.

| Tier | Trigger | What the agent receives |
|---|---|---|
| Pull | the agent calls `search_memory` | Beacon's MCP tools, serving merged results (own, project, team), each tagged with origin, reviewer, age and a verified bit |
| Turn | presence matches a lesson's evidence files, applicability or error text | one framed line at the next tool result: `<team-memory id="m_8f2" scope="project:acme/api" reviewed-by="bob" verified>` and the lesson text |
| Push | a verified correction whose evidence matches a command the session is about to repeat | the same frame mid-turn, only after the anchor's gate allows it |

Ranking for pull and turn recall combines five signals: scope (narrowest first), text relevance (SQLite FTS5 over text, applicability and tags, which replaces Beacon's AND of substrings), overlap between the lesson's evidence files and the session's current files, an outcome rate, and age. The outcome rate is a smoothed success rate over sessions that cited the lesson, (successes + 1) / (citations + 2), so a new lesson starts at one half and moves with use. Superseded and retracted lessons are never returned. A lesson borrowed from another project is labelled as a hypothesis until it has been cited in a successful session in the new one.

The citation comes from text the agents already write. Beacon's recall skill asks an agent to say "Using Beacon memory `memory_…`" when it uses a memory; the daemon sees that text in the agent's message events and writes `lesson.cited` with the session id. The outcome is the Beacon evaluator's task-success score when a session is evaluated, and otherwise a proxy: the session's tests pass after the citation, and no revert of its changes appears in the next day's commits. A lesson cited before a failure, or whose evidence files change in a way that breaks the behaviour it describes, gets `lesson.contradicted` and returns to its reviewer.

### 6. The graph

#### Schema

| Node | Created from | Tense |
|---|---|---|
| person, machine | roster entries | timeless |
| session | presence, `session.started` | present while live, past after `session.ended` |
| repo, project | normalized remote URL | timeless |
| file | `file.read`, `file.modified` | present while open, past afterward |
| commit | git, `session.commit_linked` | past |
| message | Reason Network mail | past |
| approval, decision | `Review` records; a decision is an approved request with a stated effect | past |
| lesson | lessons log | past (verified), future (proposed) |
| obligation | decisions, standing requests, promises in messages | future |

| Edge | Source |
|---|---|
| session touched file | Beacon file events (path, diff hash) |
| commit came from session | Beacon git notes (`session.commit_linked`, file-overlap inference within 2 to 24 hours) |
| commit touched file | git |
| lesson derived from event | lesson evidence pointers |
| review signed by person, about message or lesson | `Review` signature and `about` |
| decision produced obligation | the decision's stated effect ("remove the grace by 10-28") |
| obligation waits on event | its condition |
| person owns file | CODEOWNERS, or recent approved decisions and sessions |

The graph is two SQLite tables, `nodes(id, type, tense, attrs, t_start, t_end)` and `edges(src, dst, type, t, evidence)`, built incrementally from the logs on each machine and on the anchor. Each machine's graph covers its own full-content events plus the metadata its teammates share. The anchor's graph is the union of what members share, plus lessons and any history members opt into.

#### Queries

Agents query the graph through the directory tools they already have. `list_team` gains a filter on files and topics, and three tools are added.

- `who_knows(file | error | topic)` returns sessions and people ranked by how recently and how much they touched the relevant files, whether they wrote the relevant commits or approved the relevant decisions, and whether they are live now. Each result includes its reasons ("live now, editing src/auth.rs; its Monday session wrote c7f2"), so the asking agent can judge the route.
- `owed(event | person | project)` returns open obligations, with their owners and conditions.
- `context_for(session)` returns the session card described in section 10.

Each `who_knows` call that leads to a message writes a routing record with the context, the candidates, the choice and, once the exchange ends, the outcome (answered, helpful, redirected, ignored). Freyja's orchestrator design specifies this ledger as the supervision for a learned router.

#### Obligations

An obligation has an owner, a condition, an action, an optional deadline and a source. Conditions are expressions over events, and Beacon already ships an engine for that: its 77 detection rules are CEL expressions over the same event schema, with correlation within session windows. The anchor evaluates obligation conditions with the same engine as events arrive, for example `e.event.action == "vcs.merged" && e.branch == "auth-rotation"`. When a condition fires, the action becomes a candidate item for the gate in section 7, never a direct interruption.

### 7. The anchor

The anchor is a member daemon with `role=anchor` in the roster, which is how Reason Network's design plans it ("just a member daemon on an always-on box", `DESIGN.md:197`). It has five jobs.

1. **Custody.** Mail for offline machines (section 4).
2. **History.** Members who opt in stream their metadata to the anchor, along with any messages and traces they choose to archive. The anchor indexes them with FTS5, which Beacon's opt-in `history.db` already uses locally.
3. **Runtime.** Freyja's always-on platform as its design describes it: workflows with triggers and steps, persistent watchers over CI, review queues and deploys, capabilities denied by default, and an audit log for every check.
4. **Gate.** The single place where proactive items become interruptions.
5. **Work.** Tasks people hand it when they stop for the day and fixes for failures that a test reproduces, run in sessions of its own and stopped at drafts.

#### The gate

```
on candidate item c:                         # from a session, a watcher, an obligation or a loop
  p = owner(c)                      # the graph: code owner, obligation owner or standing request
  if floor(c):                      deliver(c, channel = prefs[p].alarm_channel); return   # fixed in code
  if not prefs[p].allows(c):        ledger.silence(c, "preference"); return
  if seen(c.fingerprint, window):   ledger.silence(c, "duplicate of " + prior); return
  u = urgency(c)                    # info | attention | alarm; rules first, a model only when rules disagree
  if u == alarm and prefs[p].alarms: deliver(c, channel = prefs[p].alarm_channel); return
  if quiet(p, now):                 ledger.hold(c, until = quiet_end(p)); return
  if u == attention and budget[p].take(): deliver(c, channel = prefs[p].channel); return
  digest[p].add(c)                  # the morning or evening note
```

Urgency comes from records the anchor can check: CI state, deadlines in the graph, whether a person or session is blocked. The proposing agent's own wording never raises it, which also means an issue that says URGENT cannot page anyone. Floors run before preferences, so no proposal and no learned preference can lower them; the first floor is that a security finding always reaches the owner of the code. Every branch writes to a ledger with the item, the rule that decided and the time, so "why didn't you tell me" has an answer and so does "why did you tell me". The per-person budget (attention-level items per day) and the preferences are hive additions, kept as each person's own settings and delivered through Freyja's proposed notification fabric, whose design distinguishes info, attention and alarm (`ALWAYS-ON-PLATFORM.md` §8.8; §2.6 lists native notifications as missing today).

The gate is measured in both directions, because both failures are common. A team of agents produces more candidate items than any person can read, and each agent rates its own item as important. The opposite also happens: Freyja's four-day observation of Muse, an always-on assistant, found that its proactive composer ran 21 times and sent nothing (`SLACK-ORCHESTRATOR.md:272`). The ledger yields both rates: the share of delivered items a person acted on, and the share of held or silenced items that a later event showed should have been sent (the person asked about it, or the issue it described escalated). A silent gate also looks like a broken one, so each digest lists what the anchor checked and how many items it held, which is the report Freyja's orchestrator design already requires of every background loop (a run ends with an output or with `no_change` and what was checked, `SLACK-ORCHESTRATOR.md` §7.4). Before the gate sends anything, it runs in shadow: it writes the digests it would have sent, people mark what they wanted, and it starts sending when the missed-item rate on that record is acceptable to them.

#### Prepared work

Anthropic's Claude Duet works this way for one person: it "reads, drafts, and prepares work on its own", and before it sends a message, makes a payment, signs a document or deletes anything, it shows the exact action and waits for approval (announcement, October 21, 2026). Freyja's always-on design already runs unattended work as workflow runs with capabilities; the hive adds what a team needs around those runs.

Work reaches the anchor in two ways. A person ending the day hands a session over with Beacon's handoff brief, exactly as they would hand it to a teammate (section 10), or a watcher or obligation starts a task when a failure reproduces under test, for example a dependency pull request that fails the auth suite. The anchor runs each task in a session of its own on a clean checkout, registered in the directory as `anchor/<name>` like any other session, so teammates' agents can see it and message it.

| Tier | Actions | Rule |
|---|---|---|
| Free | read the repository and CI logs, run tests, push branches under `hive/` | no approval |
| Approved | open a pull request, comment where people will see it, roll back a deploy | a signed approval from the owner |
| Outside | change CI or repository settings, push to protected branches | never granted, so no approval can enable it |

The tiers map onto Freyja's capability model (`ALWAYS-ON-PLATFORM.md` §8.7), which denies by default. Secrets follow its rule (§7.10): injected into the run's environment only, never logged, never written to a transcript, never sent to the model. Each task carries `llm.spend` limits per run and per day, and a run that exceeds them pauses with an attention notification (§7.5). A task that reaches a decision only a person can make stops and writes the question down instead of working around it.

The result is a prepared item: the diff, the failing test it fixes, the evidence and the base commit, delivered through the gate to the owner the graph names, with three answers: approve, edit or skip. The answer is a signed `Review` (section 5). Reason Network's `Approval`, which `Review` generalizes, records the request text as shown (clipped to 500 characters) and the answer, with nothing that binds it to an exact change and no expiry (Part 1, section 12). For prepared work the `Review` also carries `diff_hash` and `base`, so one approval covers one change; if the base moves before the answer arrives, the anchor re-runs the tests and asks again. An edit is the most informative answer. The owner's version goes in `edits`, and the anchor's draft, the approved version and the difference between them go to the quiet pass as evidence for a `correction` lesson. Skips feed the nightly review of each person's preferences.

#### Cost

The most common regret in Freyja's hosting research was idle token burn: heartbeats that resend a large context every 30 minutes (`docs/research/anywhere/vps-deployment-patterns.md:111`). The anchor's tick is a deterministic loop over queues, deadlines and obligation conditions, and it costs nothing when every queue is empty. A model runs only when a trigger, a message or a pending item needs a decision, and the hourly and nightly passes run in batches with budgets.

#### Failure

If the anchor is down, messaging between online machines continues as it does today and offline mail falls back to the sender's outbox. Obligations and watchers pause, and on restart the anchor replays the logs it missed, so a condition that became true while it was down still fires (late, and marked late).

A team can run two anchors, with the roster naming one as each person's gate and the other as standby, and the standby takes over after the first has been unreachable for a set interval. That is failover without fencing: during a partition both anchors can believe they hold the gate. Every item therefore has a stable id and the two anchors replicate their delivery ledgers, so the usual cost of a split is a repeated notification, which both ledgers record. A team that cannot accept that should run one anchor and accept its downtime.

### 8. Proactivity across the team

Personal proactive agents have converged on one design. xAI's Grok Bot (August 11, 2026), Meta's Muse (September 8), OpenAI's dots (September 29) and Anthropic's Claude Duet (October 21) each give one person an agent with its own computer that reads connected sources, prepares work while its owner is away and asks before anything leaves the draft stage. The research for this report found no product that updates every repository that depends on a changed API, notices that one person is the only reviewer of a module, rescues a stuck agent session (Claude Tag offers a manual `!restart`), or earns autonomy from a record and revokes it automatically. Each of those works across people, and each needs records the hive already keeps.

This section adds six extensions on top of the graph (section 6), the gate and prepared work (section 7). All six follow constraint 8, all six run in shadow first, and the measures for all six are listed at the end.

#### Changes

**Contract index.** The anchor builds a per-repository index nightly from code evidence: exported endpoints, schema fields and public symbols, and the repositories that consume them through imports, dependency manifests and calls to known endpoints. CodeRabbit's automatic repository linking (June 2026) derives edges from the same evidence for review-time impact reports. Every edge keeps its evidence (file and line), so a wrong edge can be traced and removed.

**Symbols in presence.** A member's daemon adds the exported symbols a working diff touches to its presence (`symbols: ["acme/api:token.kid"]`), computed locally. The diff stays on the machine, as constraint 3 requires.

**Speculative merges.** For pushed branches that share a file or a contract edge, the anchor merges them in a scratch checkout, builds the result and runs the affected tests. This is Crystal's method (Brun et al., ESEC/FSE 2011). In the histories of Git, Perl5 and Voldemort, textual conflicts were 67% of all conflicts; the other third merged cleanly and then failed to build or pass tests. Only a failure produces an item, sent to both sessions at their next turn. Unpushed work keeps the file-overlap notice from section 10.

**Decision checks.** When a `Review` approves a request with a stated effect, the anchor retrieves the decisions and lessons that share an entity with it (a file, a config key, a token type) and asks a model whether they conflict. A likely conflict becomes an item for every signer involved, showing both records with their signers and dates. GroupMemBench (arXiv:2605.14498) found that group memory fails when a newer statement overwrites a conflicting older one, so the graph keeps both, attributed. Only signed decisions are checked against, so an agent's unsigned choice never acquires a person's authority.

#### Change sets

A change whose contract edges reach other repositories becomes a change set, run by one durable workflow on the anchor that sleeps between triggers.

```rust
pub struct ChangeSet {
    pub decision: String,           // the Review whose effect started it
    pub producer: Change,           // repo, branch, merged commit
    pub consumers: Vec<Change>,     // one per consuming repository, each with its own Review
    pub evidence: Option<Check>,    // e.g. tokens without a kid in the API's logs, per day
    pub closes_when: String,        // CEL over events: every change deployed and the evidence check passing
    pub reopens_when: String,       // CEL: the evidence reappears
}

pub struct Change { pub repo: String, pub branch: String, pub owner: String, pub review: Option<String>, pub deployed: Option<i64> }
```

For each consumer the anchor applies a deterministic transform when one exists (a codemod or an OpenRewrite recipe) and uses the model only for the remainder, which is Moderne's documented approach to multi-repository changes. It verifies with that repository's own build and tests against the producer's merged commit, pushes under `hive/`, and asks the owner through the gate. Pi Durable, released with Pi 1.0 on October 1, 2026, shows the runtime shape a week-long change set needs: checkpointed tasks, sleep until a time or event, and exactly-once submissions.

A change set closes on observation. Cleric's account of verifying its own fixes found two failure modes that apply here: "merged to main" was mistaken for "rolled out", and a verifier's confidence grew across recurrences with no new evidence. So `closes_when` requires a deploy event and a passing evidence check, never an agent's report, and `reopens_when` reopens the set if the evidence returns, which is how Claude Security handles the findings it tracks.

#### Obligation sources

**Dangling edges.** Each tick, the anchor queries for work in a ready state with no next actor:
- a pull request that passed CI more than a set time ago with no reviewer;
- a session that ended with unchecked plan steps and no handoff;
- an `ask_user` question past its deadline;
- an obligation whose owner is away (calendar or presence);
- a decision with no implementing commit.

Each becomes a candidate item for the next owner, with the review brief or handoff attached.

**Promises.** The quiet pass on each member's daemon proposes commitment sentences from that member's own sessions as obligations (`owner`, `condition`, `action`, a pointer to the source event). The content stays local until the owner accepts or skips the proposal in the morning digest, and skips are labels for the extractor. Agents record promises to each other with a directory tool, `owe(to, condition, action)`, so those need no extraction.

**Absences.** The anchor computes reviewer concentration per path, meaning the people who approved changes there in the last 90 days, and checks it against calendars. A path with one reviewer who is about to be away produces a handoff-brief request to that person's agent and a prepared change to the code-owners file.

**Lifetimes.** Every obligation and standing request has an expiry and lapses unless renewed, and the digest lists the ones about to lapse. Claude Tag's channel follows end after seven days of thread silence, and Claude Code's recurring tasks expire after seven days; both are precedents for expiry by default.

#### Rescue

**Signatures.** Each member's daemon computes stuck signatures from its own events and publishes them as metadata. Starting settings:
- the same command hash and exit code three times within 15 minutes, with no `file.modified` in between;
- a run of turns without file changes;
- a spend rate well above the session's median.

**Matching.** A failure signature is the normalized command, the failing test and the error class, hashed locally. The anchor matches it against:
- lessons whose evidence carries the same signature;
- sessions whose events show the signature followed by a passing run;
- live sessions touching the failing files (`who_knows`).

**Delivery.** A match goes to the stuck agent as a turn-tier message with the pointer. The person hears only past a threshold they set. No one else hears.

**Implicit standing requests.** A `who_knows(error)` query or an `ask_user` about a failure records the asker against its signature. When a merged commit makes the signature pass, the asker gets an info item.

#### Breakpoints

The gate's attention branch waits for a breakpoint, up to a limit per urgency:

```
  if u == attention and budget[p].take():
      b = next_breakpoint(p, limit = max_hold[u])   # commit, test run finished, session idle 60 s, PR opened
      deliver_at(c, b, channel = prefs[p].channel); return
```

Breakpoints come from presence. Iqbal and Bailey (CHI 2008) found that notifications deferred to task breakpoints caused less frustration and faster reactions than immediate delivery, at a mean deferral of 88.6 seconds; the limit per urgency is Horvitz's bounded deferral (UM 2005). A false breakpoint costs more than a missed one, so detection favors precision.

**Per-source shares.** Each watcher and each proposing session gets its own share of a person's budget, so one noisy source cannot spend all of it.

**Review requests** draw from the same budget, prepared items included. In a Harvard study of 718 firms using Jellyfish data, the average time to review a pull request rose 49% with AI coding tools, and the share needing changes doubled. When several items wait, the digest orders them by how much each unblocks: the number of sessions, obligations and branches in the graph that wait on it.

#### Preparation in idle time

**Candidates** are needs the graph can predict: unfinished plans in the day's sessions, requested reviews, scheduled operations such as a migration, and recurring failures.

**Scoring.** Each candidate scores P(used) × value − cost. P(used) is estimated per kind and per person from a use log, and kinds with low use rates are dropped. Sleep-time compute (Lin et al., arXiv:2504.13171) cut answer-time compute about fivefold at equal accuracy on its benchmarks, and the gain grew with how predictable the query was from the context.

**Execution** follows PASTE's rules for speculative tool calls (arXiv:2603.18897):
- results stay outside shared state until used;
- side effects run only as a dry run or against a copy, such as a migration run against a snapshot;
- jobs are preemptible and yield to real work;
- the night has a spend limit.

**Output** is session cards with evidence attached, review briefs and rehearsal reports. Each is read on demand or attached to an item the gate is already sending.

#### Standing approvals

```rust
pub struct StandingApproval {
    pub id: String,
    pub kind: String,               // "dep-bump-patch"
    pub repo: String,
    pub perimeter: Perimeter,       // paths, change types, required checks: all checked mechanically
    pub earned_from: Vec<String>,   // the Reviews that earned it
    pub sponsor: String,            // signs, renews, and is told before expiry
    pub expires: i64,
    pub uses: Vec<String>,          // each use, also listed in the sponsor's digest
    pub suspended: Option<String>,  // revert, edit request, out-of-perimeter change, failed post-merge check
    pub sig: Signature,
}
```

**Proposal.** A kind becomes eligible when its record reaches a threshold the team sets: approvals as prepared, with none edited or reverted. The anchor then proposes the approval to the code's owner.

**Use and suspension.** Each use is listed in the sponsor's digest. Any suspension reason in the struct suspends the approval on its own, and revoking it is one command.

**Floors.** A standing approval cannot cover security findings, CI or repository settings, or a change that removes a check.

**Why the perimeter is mechanical.** Anthropic's account of its auto-mode classifier gives the reason: its misses on real overeager actions came mostly from finding approval-shaped evidence without checking that the consent covered the action's blast radius. A perimeter of paths, change types and required checks leaves nothing to interpret.

**Who can change it.** Only roster people can create, renew or revoke a standing approval, and no capability tier lets an anchor session write one. That answers Rootly's question of whether the AI can alter the policy controlling its own action.

**Precedents.** Each covers one piece:
- Renovate's automerge rules: a class of updates plus passing checks.
- Entra Agent ID access packages: expiry plus a sponsor who must renew.
- Cursor's Bugbot learned rules: promotion and demotion from how people react.

#### Measures

| Area | Measure | Ledger |
|---|---|---|
| Changes | conflicts and contract breaks first seen at merge or later; time from a producer merge to every consumer updated | speculative-merge results, CI on main, change sets |
| Obligations | time from ready to the next owner acting; obligations past due | dangling-edge queries, the obligation ledger |
| Knowledge | minutes agents spend stuck on failures with a known fix; time from a fix to the askers being told | stuck signatures, rescue records |
| Attention | interruptions and review requests per person per day, against the weeks before the hive | the gate ledger |
| Compute | share of prepared work used; cost per used preparation | the use log, spend records |
| Authority | reverts of actions taken under standing approvals; time from revocation to effect | standing approvals, git |

### 9. Learning

| Loop | Runs | Inputs | Outputs | Who checks |
|---|---|---|---|---|
| Route and ask | per question, seconds | graph, presence | messages, answers, routing records | the recipient; a person via `ask_user` when a decision is needed |
| Quiet pass | hourly, on the anchor or each machine | the hour's sessions (Metadata-only on the anchor, full content locally) | proposed lessons, project-page updates | each lesson's reviewer |
| Dream | nightly, on the anchor | raw events (shared metadata, plus traces members archive there), lessons, ledgers | reconciled lessons, a short team posture, proposed routing rules, skill changes and changes to each person's interrupt preferences (from what they acted on, edited or skipped) | people, in the morning digest |
| Training | weekly | ledgers, trajectories, lesson outcomes | datasets, then router and gate checkpoints | shadow and advise metrics |

The quiet pass needs content, and content stays on its owner's machine. The pass therefore runs in two places: on each member's daemon over that member's full events, where it can draft lessons with evidence, and on the anchor over the shared metadata, where it can update project pages and notice patterns across people (two sessions hitting the same failing test). The evaluator Beacon calls today is a hosted service, but its options already accept a provider, endpoint, key and model (`EvaluatorOptions`), so a team can point it at a model it runs itself, on the anchor or locally.

The nightly pass works from raw events and lessons, never from the previous night's summary. CliffCompaction (Nguyen et al., arXiv:2609.26779) is the reason: each pass should work from original content instead of summarizing a summary.

Training data comes from four ledgers the hive writes anyway: routing records (context, candidates, choice, outcome), gate decisions with their later outcomes, lesson citations with session outcomes, and Freyja's trajectory export, whose current version 5 keeps exact tool arguments and results (`src/shared/sessionExport.ts:276`). Converters to ATIF and ShareGPT exist but accept only version 3 exports (`scripts/convert-to-atif.py:307`, `scripts/convert-to-sharegpt.py:139`), so updating them is part of the build. Preference pairs are still a planned phase in TRAJECTORY-TRAINING.md (Phase 4, pair construction). A team supplies the raw material for them naturally: when two agents attempt the same task, an outcome such as a merged change or a passing test picks the chosen trajectory without a separate labeling step.

A learned component earns authority in the three stages Freyja uses for its Jev decision models. In shadow it predicts and is scored against what happened. In advise its prediction is shown to the model that decides. In gate it handles the cases where it is confident and passes the rest on. Promotion waits for data: in Kumar and Saminathan's study of task- and session-level routing (arXiv:2608.14641), three of four production routers gave the same answer whatever the input, and a fixed mid-tier baseline matched them.

### 10. Collaboration flows

#### Asking the right session

```
alice/api-claude                 graph / directory                bob/api-codex            bob (person)
      │ who_knows("src/auth.rs", "401 invalid signature")                │                         │
      │──────────────────────────────►│                                  │                         │
      │◄── bob/api-codex: live, editing src/auth.rs, its session wrote c7f2                        │
      │ send_message(to=bob/api-codex, "...401 since your rotation...")  │                         │
      │──────────────────────────── push (codex queue) ─────────────────►│                         │
      │◄────────────── reply: "since c7f2 the verifier requires kid" ────│                         │
      │                                                                  │ ask_user(about=msg id)  │
      │                                                                  │────────────────────────►│
      │                                                                  │◄── approve + note ──────│
      │◄──── Signed<Approval> (sent by bob's daemon, not by bob's agent) ┤                         │
      │ graph: decision{approver: bob} + obligation{remove grace by 10-28, owner: bob}             │
```

Routing is the new step at the start. The transport after it exists in Reason Network today: the push into Codex, the framed reply, `ask_user` with the requester's words shown first, and the `Signed<Approval>` that the daemon sends on its own (Part 1, section 12). The decision and the obligation at the end are hive additions, the graph's record of what the approval meant; in the hive's record format the approval becomes a `Review`.

#### Two sessions on one file

When `file.modified` events for the same normalized repo and path arrive from two live sessions within an interval (30 minutes, to tune), each daemon queues a turn-tier notice for its own session naming the other session and the file. Reason Network planned this warning as M4 work, sourced from `PostToolUse` edit paths (`DESIGN.md:355`); Beacon's events add a diff hash, which shows whether two edits are identical; overlapping lines would need hunk ranges, which each daemon can share as line numbers without the text. The warning goes to the agents, not the people, since the agents can coordinate in seconds and usually should.

#### Handing a session to someone else

Beacon's handoff brief is Markdown of at most 48 KiB, redacted, with safety lines at the top. It fits inside a Reason Network message as an attachment (up to 10 files of 256 KB each and 512 KB in total, Part 1, section 13), which is authenticated with the envelope and saved on the receiving side as a non-executable file. The receiving daemon starts the new session with Beacon's resume prompt, including its handoff marker, and the sending daemon signs the marker. Beacon treats that marker today as unverified prompt text, not a verified identity; with a signature, Beacon's `session.handoff` link between the two sessions becomes verified provenance across people.

#### Starting a session in a project

A new session gets a session card at the earliest hook its harness lets the daemon add context to. Claude Code's `SessionStart` hook accepts `additionalContext`; Reason Network skips injection where a hook cannot add context (prompt-submit in Cursor, Copilot and Grok, and Grok's session start, `src/hook.rs:107-113`), and those sessions get the card at their next supported turn hook or through pull.

The card is built from the project page: conventions and gotchas ranked for the files the session opened, live sessions working nearby, recent decisions with their approvers, and obligations that touch those files. Freyja's orchestrator design uses session cards for the same reason the Delegation Danger Band paper (Hu and Ramachandran, arXiv:2610.00041) gives: selective handoff beat both full inheritance of context and a fresh start.

### 11. Trust and privacy

A hive connects agents across people, which turns every channel into a path for injected instructions. A teammate's message, a replicated lesson and a shared skill all end up in some agent's context. The ClawHavoc campaign showed what a public channel without review looks like: in February 2026, 341 of the 2,857 skills on ClawHub (11.9%) were found to be malicious. A team's channels are smaller and reviewed, and the defenses below assume a teammate's agent can be talked into forwarding hostile text.

**Inbound screening.** Every message passes Reason Network's framing and defang, which turns any bracket that could open a frame into `[`, including look-alike Unicode brackets and spellings like `r3as0n`. It then passes Beacon's prompt-injection rules, which today run only after the fact in `beacon scan` and which the daemon would run live on the message text before delivery (`prompt-injection/ignore-previous-instructions` at medium severity, `prompt-injection/exfiltrate-context-secrets` at high, and the rest of the family). A match holds the message and asks the recipient's user through `ask_user` whether to deliver it.

**Outbound screening.** The daemon records every team message it sends as an event. Beacon's `secret-read-then-egress` correlation matches a credential-file read and a `curl`, `wget` or `nc` command in either order within 120 seconds (`rules/context-exfiltration/secret-read-then-egress.rule.yaml:20-35`); the hive adds that send event as a fourth kind of egress. When the secret read comes first, the daemon holds the message for its user before it leaves; when the message comes first, the match can only arrive after the send, so it becomes a finding that the anchor escalates.

**The `/terminal` exemption.** Both the group guard and the loop guard exempt any sender whose address ends in `/terminal`, and the CLI accepts `--from <you>/terminal` from any process (`src/daemon/mail.rs:50-54`, `:127`; `src/main.rs:417-423`), so an agent with shell access can pose as the terminal. Agents run as the same OS user, so a token file would not help. The fix with the same strength as approvals is to confirm group sends and loop-guard overrides through the dialog engine `ask_user` already uses: "Send this to all four sessions in acme/api?" An agent cannot answer that dialog without controlling the machine, which is the same speed bump Reason Network accepts for approvals.

**The anchor's own work.** Overnight tasks read issues, pull requests and CI logs that strangers can write. The anchor's sessions get the same inbound screening as messages, its tokens follow Freyja's secret rule, and its capability tiers mean the most an injected instruction can produce is a draft branch that a person has to approve.

**Lessons as a delivery channel.** A poisoned lesson is worse than a poisoned message because it is retrieved again and again. Lessons therefore require a signed review by a roster person, arrive labelled with origin, reviewer and verification status, pass the same inbound screening, and can be retracted from every daemon at once. Skills generated from lessons go through git review like any other change, which keeps Beacon's layering: mesh-delivered memory is the fast, scoped tier, and committed skills are the slow, reviewed one.

**Privacy.** Presence and the shared graph use the Metadata-only projection, and file paths pass the new path filter from section 3. History on the anchor is opt-in per person. Beacon's auto-share rule applies in the hive unchanged: a repository's committed settings can turn sharing off for everyone but can turn it on only for people who enabled it themselves (Part 2), so cloning a repo never publishes anyone's sessions. One existing behaviour deserves a decision: verified sign-in proofs include the person's email, and the full proof replicates to every member machine (Part 1, section 19). The hive should strip the email from proofs before replication or state the exposure in the join flow.

### 12. Build order

Each milestone is useful before the next one exists.

| # | Milestone | Reuses | New | Measure |
|---|---|---|---|---|
| 1 | One adapter, one daemon | Beacon capture and hooks; Reason Network daemon, mesh, tiers | presence from events, hook-as-turn-tier, `files` in presence, overlap notices | cross-agent questions per week; overlaps caught before merge |
| 2 | The anchor as a member | Reason Network mesh and roster; the anchor role its design plans (`DESIGN.md:197`); Freyja's always-on platform design | `role=anchor` roster field, custody with `Signed<Message>`, opt-in history, FTS search, the notification fabric | mail delivered across non-overlapping online windows |
| 3 | Signed lessons | Beacon learning store and MCP tools; Reason Network approvals | `LessonBody`, `Review`, `Tombstone`, project membership, `hive/1` frames | lessons cited per session; retraction and correction rates |
| 4 | Graph and project pages | Beacon git notes and file events; Reason Network directory | graph tables, `who_knows`, `owed`, session cards, CEL obligations | answer rate and time for routed questions; redirect rate |
| 5 | The gate and the loops | Freyja's quiet pass, dream and tick designs | ledgers, budgets, preferences, digests, a shadow period for the gate | acted-on rate for delivered items; missed-item rate for held ones |
| 6 | Prepared work | Freyja runs, capabilities, spend limits and secrets; Beacon handoff briefs; Reason Network approvals | anchor sessions, the `hive/` branch prefix, capability tiers, diff-hash and base-commit fields on approvals, the edit-to-lesson path | share approved as prepared, edited or skipped; re-check rate |
| 7 | Work across the team | the graph, the gate and prepared work; Beacon's CEL engine; Freyja workflows | contract index, symbols in presence, speculative merges, decision checks, `ChangeSet`, obligation sources, stuck signatures, breakpoint delivery, the preparation scheduler, `StandingApproval` | the six measures in section 8 |
| 8 | Training in shadow | Freyja trajectory export; Jev promotion stages | routing and gate datasets, shadow scoring | agreement with outcomes before any promotion |

### 13. Open questions

- **Membership drift.** Project membership is explicit in this design. If confirming members becomes busywork, teams will approve everyone, and project scope will degrade into team scope.
- **Evidence portability.** A lesson's evidence points into its author's local log, which rotates within a day or two on a busy machine unless `history.db` is on. Either the anchor stores redacted trace bundles (Beacon's `beacon.trace.v1` share format) as the team's evidence layer, or old lessons lose their evidence. I lean toward the first, opt-in per person.
- **Review load.** A prepared change still needs a reviewer. If the anchor prepares more than owners can read, prepared items have to count against the same per-person budget as any other item, and the skip rate will show the problem before the backlog does.
- **Contract edges.** Code evidence misses consumers that call through configuration, reflection or another team's gateway, so a change set can miss a repository. Speculative merges and the consumer's own CI stay the backstop, and a consumer found late joins the open change set.
- **Promise precision.** I found no strong measurement of commitment extraction from agent sessions or code review. The accept and skip record in each digest is the only measure, and a person whose accept rate stays low should have extraction turned off.
- **Thresholds for standing approvals.** No evidence supports any particular number of approvals as prepared. Teams should start high, keep expiries short and watch reverts.
- **Mesh size.** Reason Network's design calls a full mesh "fine to ~50 machines" (`DESIGN.md:164`), and no test exercises that number. Past it, anchors would need to federate, and lesson replication would move from peer-to-peer sync to the anchors.
- **Router evaluation.** There is no benchmark for routing a question to the right person's session. The routing ledger is the only data, so the first learned router can be evaluated only on the hive's own history.
- **How much to keep as lessons.** In DreamBench-SWE (Singh, arXiv:2608.20664), a verbatim record of past events raised the pass rate from 11.7% to 45.6%, and the memory conditions it compared, including a hosted memory product at 53.9%, were statistically indistinguishable. If that holds for teams, the graph's evidence does most of the work, and lessons should stay few and limited to things an event record cannot express, such as decisions and preferences.
