# ADR 0002: Notify-pull receive path (watch → list → read)

## Status

Accepted — notify-pull receive path implemented. Reliability gaps and harness-neutral follow-up are tracked by ADR 0003 and ADR 0004.

## Context

### Current receive path

The bridge ingests mail with a polling loop + destructive bulk read:

```text
waitForMessages: poll `amq list --new` every ~15s
  → drainInbox:  `amq drain --include-body`   (full bodies, moves new → cur, emits receipts)
  → inbox.json   append/merge, dedup by id, cap 100
  → inject       all bodies into agent context (followUp, triggerTurn: true)
```

### Problems (dogfood + expert review findings)

1. **Context pollution** — full bodies injected; N messages → N turns, each carrying prior context.
2. **Consumed-before-notify** — `drain` moves `new → cur` and writes a receipt before the agent opens the message. Notification == consumption; an ignored follow-up can never be re-notified.
3. **Crash window** — mail drained before buffer write; crash between them loses the batch (at-most-once gap).
4. **Reinvented state** — `inbox.json` + `injected.json` re-implement read/unread and dedupe.
5. **Reply-latest hack** — defaults to latest actionable; needs ambiguous-error path. Explicit targeting required (ADR 0001 §5, expert-review blocker #1).
6. **No priority** — urgent and FYI both trigger full turns. `priority` exists on `amq send`, bridge never sets or filters it.
7. **Polling cost** — 500ms/15s poll runs when idle.

### Verified CLI facts (amq 0.49.10, live-tested)

| Primitive | Verified behavior |
|---|---|
| `amq watch --json --timeout 0` | **One-shot, not streaming.** Emits at most one event then exits 0. Empty inbox → blocks until first arrival → `{"event":"new_message","messages":[envelope]}` (single message, not batch). Backlog at start → `{"event":"existing","messages":[envelopes...]}` (full backlog) then immediate exit. 3–5 rapid sends → exactly one event. `--poll` identical semantics. Idle cost ≈ 0 (fsnotify, no CPU). Envelope only, no body. |
| `amq list --new/--cur --json` | Envelope only: `id, from, subject, thread, created, box, path, priority, kind`. No `to`, no `root`. Filters: `--from`, `--kind`, `--priority`, `--labels`, `--limit`, `--offset`. After `read`, message leaves `--new`; `--cur` shows history. |
| `amq read --me --id --json` | Body + full header (header has `to`). Moves `new → cur` only after parse/header validation; corrupt → DLQ. Re-readable from `cur`. **Writes no receipt** (only `drain` does). |
| `amq reply --id` | `--id` required; bare error `--id is required`. Auto thread/refs. |
| `amq send --priority` | `urgent \| normal \| low`, default `normal`. `list --priority` filter works. Valid `--kind`: `brainstorm, review_request, review_response, question, answer, decision, status, todo`. **No `fyi` kind.** |
| `--session` + `--root` | **Mutually exclusive** (verified error). `--session` = AMQ coop session subdir of cwd, not a Pi session. Bridge must scope via `--root` + `--me`. |

Real-email model: server pushes a small notification, client pulls the body when opened, unread lives server-side. The CLI is the server. The bridge is the client.

## Decision

### 1. Receive = notify-pull, not push-drain

```text
watch child:  `amq watch --json --timeout 0`   → one event (existing | new_message), exit 0
respawn loop: bridge immediately respawns watch after every event   [steady state]
envelope:     `amq list --new --json`           → envelope batch (no bodies)
inject:       envelopes only, never bodies      → agent context (policy-gated)
fetch:        `amq_bridge_read <id>`            → `amq read --id` body on demand
```

The watch child is **one-shot by design**: it signals "maildir changed", then the loop re-lists. This is the main loop, not an error path. Two event types, both handled:

- `new_message` — first arrival while watching. Trigger re-list.
- `existing` — backlog present when watch starts (normal after every respawn with pending mail; also the restart/crash-recovery path). Trigger re-list.

Respawn discipline:

- Respawn immediately after every event (watch exit 0 is normal).
- If the resulting re-list yields **no new ids** (e.g. a peer's message consumed by someone else between event and list), debounce the next respawn (e.g. 1s) to avoid backlog spin — the old inbox-loop max-no-new-message guard (ADR 0001 §6) applies. After N consecutive no-new-id cycles (bound set in implementation, e.g. 5), extend the debounce (e.g. 5s): with unread-but-processed mail present, steady state is respawn → `existing` → no new ids → debounce, and the guard must prevent ~1Hz churn.
- Backoff (1s/5s/30s cap) only on non-zero exit / CLI errors.
- `--poll` flag exposed for network filesystems.

Mail stays in `new/` until the agent actually reads it. Unread and crash-safety fall out of the maildir, not the bridge.

### 2. Injection dedupe: maildir is not enough

> **Superseded (location only):** §8d #1 replaces per-session state with one shared per-`(root, me)` state file (`~/.amq-bridge/state/<root-hash>-<me>.json`, v3). The dedupe rationale below stands; the schema shown is historical.

`new/cur` dedupes **reads**, not **injections** — injection does not move `new → cur`. A crash after inject-before-read, or a message the agent ignores, stays in `new/` and would re-inject on restart. Therefore the bridge keeps a bounded **processed-ids record** (injected ids, cap 500) in bridge state. This satisfies ADR 0001 §6 ("processed message ids", "must not inject it again"). Amends the earlier "no injected.json" idea: the file is replaced by `processedIds` in bridge state, not deleted.

```ts
// HISTORICAL — superseded by §8d #1 (shared per-(root,me) state file, v3).
// bridge session state (pi custom entry, survives /reload per ADR 0001)
{
  version: 2,
  attached: boolean,
  self?, root?,
  peers: {...},                        // ADR 0001 roster
  primaryPeer?,
  processedIds: string[],              // NEW: injected (dedupe for re-inject on restart)
  activeId?: string,                   // NEW: scheduler active actionable (see §7)
  updatedAt
}
```

`processedIds` never moves mail; it only prevents re-injection. Read/unread truth remains the maildir. `processedIds` records **every** injected envelope — actionable or status — so status/context-appended messages are not re-appended on every watch event.

### 3. Tool surface

| Tool | Change |
|---|---|
| `amq_bridge_inbox` | `list --new` envelope view. `--all` shows `--cur` history. No drain, no bodies. Optional `--limit`. |
| `amq_bridge_read` (new) | `amq read --id`. Returns body + header. Marks read (new→cur). Re-readable. |
| `amq_bridge_reply` | Always requires `messageId` (CLI enforces). Drop reply-latest + ambiguous path. On missing id: bridge returns pending envelope list (id required), not bare CLI error. |
| `amq_bridge_resolve` (new) | Mark message handled/answered without replying (ignore, done, closed). **Also reads the message (new→cur)** so maildir truth and `list --new` reflect resolution; otherwise resolved mail stays `new/` forever and re-lists each watch cycle. Removes from actionable set; triggers dequeue (§7). No watch event generated by resolve — dequeue is bridge-side re-list. |
| `amq_bridge_send` | Gains `priority` (urgent/normal/low, default normal). |
| `amq_bridge_status` | ADR 0001 output + pending count (`list --new` length) + active id. |

### 4. Injection policy (bridge owns policy, CLI owns transport)

- Envelopes injected as `amq-bridge-inbox` context (body field absent).
- `triggerTurn: true` only when the batch contains ≥1 **actionable** message not already in `processedIds`. Actionable kinds: `question`, `decision`, `review_request`, `review_response`, `answer`, `todo`, `brainstorm`. FYI kind: `status` (verified: no `fyi` kind exists). `brainstorm` was originally resolved as FYI, then re-classified actionable (8c fix, 2026-08-12: brainstorm must wake the peer — see §Open questions).
- **One turn per batch, one active actionable at a time** (decision-log, expert-review #4). If the batch has several actionable, the oldest unprocessed becomes `activeId`; the rest are listed as queued context, not separately turned. After the active is replied or resolved, the bridge re-lists `new/` and activates the next unprocessed actionable (dequeue trigger).
- Status-only / already-processed batches append to context, no turn.
- **Urgent wake trust gate.** `priority` is sender-controlled. Wake-trust is decided by roster source — one vocabulary, verified against code: trusted = source `attach` (explicit attach) or `manual` (user peer-add) or live `presence`-connected; **never** trusted = source `handshake`/`message` (auto-added from housekeeping) or `discover`; legacy roster entries with **no source field** are untrusted until re-confirmed by attach. Housekeeping spoof vector: `addExtraPeer` auto-adds any sender of an `attached` message on `bridge/attach/*` with `source: 'handshake'` (pi-extension.ts:230-236, outside the 0001 enum) — that path must not grant wake-trust or primary-target status; align the source enum (ADR 0001 + code) to this vocabulary.
- TUI-gated injection unchanged: non-TUI gets tools + context, no auto-inject.

### 5. Watch lifecycle

- One watch child per attached session, keyed **`${root}:${me}`** (global root is the common case — cwd-keyed watchers would let two sessions in different cwds watch the same mailbox, contradicting the shared-mailbox rule). Same `(root, me)` in a second session → its watcher is skipped; the **session owning the `(root:me)` watcher is the only one that injects** (attach-time backlog and watch events). Secondary sessions get tools + read-only status with pending count, no injection. Read races are safe (CLI moves atomically; re-read from `cur` works).
- Scoped via `--root` + `--me` only (`--session` is not usable with `--root`).
- Respawn-per-event loop (§1); abort on detach and `session_shutdown`.
- One-shot respawn means no persistent child while idle: spawn → block → event → exit → respawn. Idle cost ≈ one blocked fsnotify process per attached session.

### 6. Evidence

Envelope fields available at list/watch stage: `id, from, subject, thread, created, priority, kind`. `to` and `root` are **not** in envelope output — bridge synthesizes `root` and treats `to` as known (peer) or defers to `read` header. Read output adds `body` + full header (incl. `to`).

Read-state evidence for the sender (`amq receipts`) is unavailable via `read` (verified: sender sees 0 receipts after a read; the reader's own receipts dir grows with a `drained`-stage receipt, sender-side visibility none). Sender-side read receipts are declared a **non-goal** for this ADR; delivery evidence = bridge send output + receipts where CLI emits them.

### 7. Scheduler state

> **Location superseded by §8d #1:** processedIds/activeId live in the shared per-`(root, me)` state file (`~/.amq-bridge/state/<root-hash>-<me>.json`, v3), not in per-session custom state. The lifecycle below stands.

Per decision-log ("bridge owns pending queue / message lifecycle") the bridge persists:

- `processedIds` — injected (dedupe, cap 500)
- `activeId` — current actionable awaiting response

Lifecycle: `queued (new/) → injected (processedIds) → active (activeId) → answered (reply or resolve, removed from actionable)`. `resolve` and `reply` both trigger dequeue (re-list `new/` → next active). Messages in `cur/` that were never injected pre-upgrade are handled by migration (§8).

### 8b. Transport boundary (decoupling from the CLI)

`amq` CLI quirks must not leak past the transport adapter. The contract is **semantic**, not CLI-shaped; a future transport (real streaming bus, other queue, network root) implements the same surface and the bridge code does not change.

`watchInbox` semantics: the adapter presents a continuous async stream where **each `WatchEvent.messages` is the re-listed envelope batch** (adapter runs `listInbox` after each CLI event and yields the result). The bridge consumes watch events directly — it never polls or re-lists inside the watch ingestion pipeline. Bridge-owned lifecycle operations may re-list deliberately: `dequeueNext` after reply/resolve and `list --cur` during migration notes; tool views also list explicitly.

Watch child exit-code mapping (adapter-internal): exit 0 = event emitted → respawn; **exit 4 = bounded-timeout no-op** (`--timeout` expiring with no mail) → respawn, no backoff; exit 3 = missing mailbox → real error, surface clearly, no infinite backoff; killed by signal = abort requested → stop. `--poll` fallback exposed via an optional `poll` option on `watchInbox` (and `send`/`reply` keep no such option).

### Contract (required surface)

```ts
interface BridgeTransport {
  initMailbox({ root, agents? }): void;
  sendMessage({ root, me, to, body, subject?, kind?, priority?, thread? }): Message;
  listInbox({ root, me, box?: 'new' | 'cur', from?, kind?, priority?, limit?, offset? }): Envelope[];
  readMessage({ root, me, id }): Message;   // body + header; marks read (new → cur)
  watchInbox({ root, me, signal? }): AsyncIterable<WatchEvent>;  // continuous stream
  replyTo({ root, me, id, body, subject?, kind?, priority? }): Message; // id always required
  formatMessage(m?): string;
  hasAmq(): boolean;
}

type WatchEvent = { type: 'new_message' | 'existing'; messages: Envelope[] };
type Envelope = { id, from, subject, thread, created, priority, kind, ... };
```

### What the adapter absorbs (never the bridge)

- **One-shot watch → stream**: `amq watch` emits one event then exits 0. The adapter's `watchInbox` presents a **continuous async stream** and internally: respawns after every event, converts `existing`/`new_message` to typed events, re-lists before respawn and debounces (1s; extend after N no-new-id cycles) so backlog does not spin, backs off only on non-zero exits, uses `--poll` on network FS, honors the `AbortSignal`.
- `--session`/`--root` exclusivity, `--poll`, `existing` event naming, receipt quirks — all CLI-local, resolved inside the adapter.

### What the bridge owns (policy, transport-agnostic)

- injection policy and wake gating (priority trust gate, one active actionable)
- `processedIds` / `activeId` bookkeeping, resolve semantics (= `readMessage` + processed record)
- migration note, formatting, roster (ADR 0001)

### Contract changes vs today

- `watchInbox` replaces `waitForMessages` (poll).
- `drainInbox` leaves the required contract (no ingestion path uses it); may remain exported in the client as a compat helper.
- `readMessage` moves from optional (currently only in `.d.mts`) to required.
- `listInbox` gains `box`/`priority`/`kind`/`limit` filters.
- `sendMessage`/`replyTo` gain `priority`.
- No `resolveMessage` in the contract — resolve is bridge policy over `readMessage`; inventing a CLI resolve would couple us to a CLI feature that does not exist.

### 8c. Implementation clarifications (review 3 — must-fix before coding)

1. **Watcher key = `${root}:${me}`, not `${cwd}:${sessionKey}`.** Global root is the common case; cwd-keyed watchers allow two sessions in different cwds to watch the same mailbox, breaking the shared-mailbox rule. The session owning the `(root:me)` watcher is the only injector (attach backlog + watch events); secondary sessions are read-only status. Resolves the §5 contradiction and criterion 13 mechanism in one rule.
2. **Wake-trust vocabulary (one map):** trusted = source `attach` | `manual` | live presence-connected; untrusted = `handshake` | `message` | `discover` | legacy no-source. Applies to urgent wake and primary-target grant. `addExtraPeer` (handshake) keeps discovery only.
3. **Scope:** ADR 0002 receive path = pi-extension only. `sidecar.mjs`/`echo-peer.mjs` (non-Pi adapters) keep legacy drain compat; migration is a follow-up. Criterion 11 worded accordingly.
4. **processedIds/activeId persistence:** ~~pi session custom state (`amq-bridge-state`, survives /reload, cap 500)~~ **superseded by §8d #1** — shared per-`(root, me)` state file. Persist **before** `triggerTurn` (crash window = inject without record = re-inject risk; order persist → trigger).
5. **Display order:** CLI lists oldest-first; bridge reverses for display (newest-first per ADR 0001 §6).
6. **Migration marker** lives at bridge-dir level (outside `sessions/<key>/`, which detach `rmSync`s); `N` for the `list --cur` note = 20. ~~Marker = note gate only~~ **superseded by §8d #7/§8e #2:** durable gate = `migrated0002` flag in shared state; the legacy marker file is still read on first claim (import → skip note).
7. **WatchEvent.messages = re-listed envelopes** (adapter re-lists after each CLI event; bridge consumes directly, never re-lists in its pipeline). Exit codes: 0 = event → respawn; 4 = timeout no-op → respawn no backoff; 3 = missing mailbox → real error; signal = abort.
8. **`formatMessage`** renders envelope-only (id, from, subject, thread, priority, kind); body absent → omit. `docs/message-kinds.md` claims kind/priority formatting that does not exist — align during implementation. ✅ Aligned in T8 (envelope-only `formatMessage`; message-kinds.md rewritten).
9. **Deliverables include** `integrations/pi-skill/SKILL.md` updates (watch-driven wording, read/resolve/priority tools) — currently says "auto-polls inbox".

10. **Detach semantics (owner handoff):** watcher aborted by new `${root}:${me}` key; AbortSignal must SIGTERM the watch child (no zombie respawn). Detach keeps `processedIds` (no re-inject on re-attach), resets `activeId` (agent gone; next attach activates oldest pending fresh). `migrated-0002` marker lives at bridge-dir level, outside detach's `rmSync` scope (sessions/ + attachments/ only). When the owner session detaches while secondary sessions remain attached, **mail waits in `new/` and is delivered on the next attach** (`existing` event) — no watcher promotion; secondary sessions stay read-only.

11. **Owner arbitration (startup + attach + connect):** ~~persisted in a marker file `.amq-bridge/watchers/<root-hash>-<me>.owner`~~ **superseded by §8d #1** — owner lives in the shared per-`(root, me)` state file, claimed under O_EXCL lock. First session to claim (session_start restore or attach/connect) becomes owner; others attach read-only (secondary). Stale owner (dead pid) → takeover. `attach` and `connect` share one `startWatch()` helper: owner claim → migration note check → watchInbox stream → backlog inject. `connect` no longer skips migration/backlog.
12. **Reply records processed id:** reply may happen without `read` — the original stays in `new/` and would re-inject. `amq_bridge_reply` adds the target id to `processedIds` and triggers dequeue, exactly like `resolve`.
13. **Contract assert is additive-safe:** `assertBridgeTransport` checks the required subset; `waitForMessages`/`drainInbox` remain exported extra methods on the transport object (non-Pi runtimes sidecar/echo-peer keep calling them). Shrinking the required list must not break the non-Pi legacy path.
14. **session_shutdown** aborts by the same `(root, me)` key and releases owner in the shared state file if self (~~removes owner marker~~ — superseded by §8d #2).

### 8d. Lifecycle fixes (review 4 — supersedes conflicting 8c items)

Review 4 (lifecycle-targeted) found 3 blockers + 7 majors. Consolidated resolution:

**1. One shared per-(root,me) state file.** Replace per-session `processedIds`/`activeId` (8c #4) and the separate owner marker (8c #11) with a single file at global bridge dir, keyed by root-hash:

```
~/.amq-bridge/state/<root-hash>-<me>.json
{
  "version": 3,
  "owner": { "pid": number, "startedAt": string, "sessionKey": string } | null,
  "processedIds": string[],      // cap 500, injection dedupe (shared across sessions)
  "activeId": string | null,
  "migrated0002": boolean
}
```

Why: dedupe must be shared across sessions (only the owner injects, but ownership hands off; per-session processedIds would re-inject after handoff — review-3 blocker). Root-hash keying fixes the cross-cwd double-claim (blocker: marker was cwd-relative). Atomic write (tmp+rename). Owner claim = O_EXCL lock file (`.owner.lock`) or pid+startedAt staleness check; **pid+startedAt** (never bare pid — pid reuse blocks takeover forever).

**2. Detach releases owner, never loses processedIds.** `detach` clears `owner` (or sets null), keeps `processedIds`/`activeId` in the shared file. Marker cleanup is **detach + session_shutdown** both (8c #14 extended). Attachments cleanup only when this session is/was owner. Re-attach: claim as owner, keep processedIds → no re-inject; `activeId` reset only when agent no longer present (detach) — re-attach continues if still set? No: detach resets `activeId` to null (agent gone), next attach activates oldest pending fresh.

**3. Claim only when a watcher will run.** Non-TUI session or detached state → no claim (no watcher). Prevents non-injecting owner blocking a TUI injector (review-3 major). Rule: claim happens inside `startWatch()` (TUI + attached), never at generic restore.

**4. One watcher per session, abort old handles.** Attach/connect with a new `self` aborts any watchers this session owns for the same root (all `(root, *)` keys) before claiming — no stale-identity double injector (review-3 major).

**5. Secondary sessions are read-only.** Owner is the only session that may `send`/`reply`/`resolve` (owner owns `activeId`; secondary reply breaks the one-active invariant). Secondary: `inbox`/`read`/`status`/`attach`/`detach` allowed; `send`/`reply`/`resolve` blocked with explicit error. Detached session: `send`/`inbox`/`read` work against explicit root+self (existing behavior); `resolve` without active id errors.

**6. Reply records processed BEFORE sending.** Order: add id to `processedIds` + persist (atomic write), then CLI send. Crash between → re-inject allowed (at-least-once replies, consistent with ADR stance); never duplicate-reply-while-unrecorded.

**7. Migration check runs on watcher start** (owner claim), covering restore-while-attached — the common post-upgrade path review-3 flagged. Flag lives in the shared state file (`migrated0002`), root-scoped → fires once per root, not per cwd.

**8. Fatal watch error (exit 3):** notify once, stop watch loop, **release owner claim** → next `session_start`/attach re-tries or another session takes over. No live-pid-with-dead-watcher black hole.

**9. Accepted residue (follow-ups, not blockers):** kill -9 orphans the blocked watch child (exits on next event; per-crash leak); same-session-resumed-in-two-processes marker check = sessionKey + pid + startedAt; connect-from-secondary promotion = connect promotes to owner (explicit, allowed).

Supersedes: 8c #4 (processedIds location), #6 (migration marker path), #11 (owner arbitration mechanism), #14 (cleanup points). 8c #1/#2/#3/#5/#7/#8/#9/#10/#12/#13 stand.

### 8e. §8d protocol completion (review 5 — must-fix, supersedes §8d wording)

Model validated sound. Exact claim/release protocol + upgrade bootstrap + takeover semantics:

**1. Claim/release protocol (O_EXCL lock; staleness ≠ mutual exclusion — both needed).**

```
state dir : ~/.amq-bridge/state/            (mkdir -p on first claim)
state file: <sha256(root) hex>-<me>.json     (M4: sha256, never lossy char-map)
lock file : <sha256(root) hex>-<me>.owner.lock = {pid, startedAt, sessionKey}

claim(me):
  1. open(lock,'wx'); write identity; close            // O_EXCL
  2. EEXIST → read lock:
       lock.pid dead → unlink(lock); retry 1          // stale lock (crash between
                                                      //  lock and state write)
       lock.pid alive → sleep 50ms, retry ≤2s; then:
                         state.owner alive → SECONDARY
                         state.owner stale → keep waiting (never unlink live-pid lock)
  3. st = readState()                                  // fresh, under lock
  4. st.owner alive → unlink(lock); SECONDARY (defensive)
     st.owner.sessionKey ≠ my sessionKey → st.activeId = null   // cross-session takeover (M1)
     st.owner = {pid, startedAt, sessionKey}
  5. writeState: tmp = SIBLING "<file>.tmp" in state dir; rename   // same FS, no EXDEV
  6. unlink(lock) AFTER write                          // order mandatory: unlink-then-write
                                                        //  lets next claimer read pre-write state
  7. migration check (§8d #7); backlog inject (processedIds-filtered)

release() [detach | session_shutdown | exit-3]:
  lock; st = readState();
  if st.owner?.sessionKey == my sessionKey:            // CONDITIONAL clear — never blanket
     st.owner = null;  (detach also st.activeId = null)
  write; unlink

appendProcessedIds / setActiveId (owner only):
  lock-free read + mutate + tmp+rename   // single-writer invariant: secondary blocked
                                          //  from send/reply/resolve (§8d #5), so appends safe
```

Lock scope = ownership transitions + migration-flag set only. Append paths lock-free by single-writer invariant (state it). Claim-crash: dead-pid lock + stale owner → next claimer unlinks, claims. Detach-vs-claim race serializes on lock; loser may end secondary-while-owner-null → mail waits per §8c #10 (consistent; optional short re-check after claim failure).

**2. Upgrade bootstrap (B1/B2).** On FIRST claim after upgrade: union legacy per-session `injected.json` ids for `(root, me)` into shared `processedIds` (prevents re-inject of pre-upgrade injected-but-unread mail); if legacy `.amq-bridge/migrated-0002` marker exists → set `migrated0002`, skip note (no double migration). Create-on-claim with defaults; corrupt/unknown-version file → back up + recreate (ids are dedupe hints, not mail — safe).

**3. Memory = cache, shared file = truth.** In-memory `activeId`/`processedIds` recomputed from shared file on claim and after any ownership change.

**4. Connect promotion (M3):** promotion = allowed to claim; succeeds **only when owner gone/stale**. Never displaces a live owner (would create two live watchers + cross-process abort impossible).

**5. Accepted residuals (recorded, not blockers):** kill-9 watch-child orphan (C12 scoped to graceful restart); crash between processedIds persist and inject → recorded-but-never-injected (at-least-once injection not guaranteed; dedupe-first direction); resume/fork shutdown-release vs new-claim race → new session may land secondary until re-attach; no fsync (survives kill-9, not power loss).

Supersedes: §8d #2 (claim detail), #9 promotion wording; §8d #1 protocol detail. Stale §2/§7/§8-main/What-dies/Open-questions text referencing session-state processedIds / cwd-scoped marker / attach-handler migration: **later-section-wins** (cleanup pass during implementation).

## 8. Migration, compatibility, rollback

- **Migration (one-time, on first owner claim after upgrade — supersedes "attach handler" wording):** bridge emits a context note listing recent `cur/` messages (last N = 20 via `list --cur`) so pre-upgrade drained-but-unanswered mail is surfaced, not silently dropped (expert-review #3: never truncate pending work silently). Durable gate = `migrated0002` flag in the shared per-`(root, me)` state file (§8d #7, §8e #2); the legacy `.amq-bridge/migrated-0002` marker file is still read on first claim (import → skip note). Runs on watcher start (owner claim), not at generic attach.
- **Compatibility:** tool names preserved; `amq_bridge_read`/`amq_bridge_resolve` additive. `/amq-bridge inbox` shows envelopes; `read <id>`, `resolve <id>` added. Reply without id → pending envelope list error.
- **`/amq-bridge send` default kind changes from `status` to `question`** (actionable). Today's `status` default means every `/send` lands non-actionable and the peer never wakes. `status` remains available explicitly for FYI. Deviation from current behavior, flagged for ux-review note.
- **Reply strictness:** always require `messageId` (stricter than decision-log's ≤1-pending implicit rule; safe direction, documented deviation).
- **Rollback:** previous behavior is a git revert; watch never moves mail, so `new/` messages are untouched. Pre-upgrade messages already in `cur/` remain visible via `--all`/migration note. No new data-loss vector.

## What dies

- `waitForMessages` poll loop
- `drainInbox` full-body ingestion
- `inbox.json` buffer (read/write/append-dedupe)
- `injected.json` file — replaced by `processedIds` in the shared per-`(root, me)` state file (§8d #1); the legacy reader survives only inside `src/migration.mjs` for upgrade bootstrap (§8e #2)
- `unseenActionableMessages` / `markInjected` — replaced by `processedIds` check
- `ambiguousReplyText` / reply-latest default

## Acceptance criteria

1. Given peer A sends to attached B, when watch fires, then B's context receives **envelope(s) only** (id, from, subject, thread, priority, kind) and no body, **within ≤2s on local FS** (watch latency bound).
2. Given envelope injected, when agent calls `amq_bridge_read <id>`, then body + header return, `list --new` no longer contains that id, and re-read returns body from `cur`.
3. Given 5 pending messages, `amq_bridge_inbox` returns 5 envelopes with ids, no bodies.
4. Given urgent message from roster peer (source attach/manual/connected), agent turn triggers. Given status-only batch, no turn (assert with ≤5s observation window).
5. Given urgent message from handshake/unknown sender, no urgent wake; envelope visible in inbox.
6. Given bridge restart (including kill -9) while attached with unread pending, then pending messages are injected **at most once** (processedIds), and watch respawn re-lists without spin (backlog `existing` handled).
7. Given detach then re-attach, pending batch injects once, no duplicate (processedIds), no re-injection of already-processed ids.
8. Given reply or resolve on the active message, then dequeue activates the next unprocessed actionable (≤2s), and resolve produces no watch event / no duplicate turn.
9. Given reply without messageId, tool fails with pending envelope list. With messageId, reply targets that thread.
10. Filesystem: no `inbox.json` / `injected.json` writes after migration; migration note fires exactly once (marker).
11. No `waitForMessages` / poll path remains in the **Pi receive path** (pi-extension). `sidecar.mjs` / `echo-peer.mjs` (non-Pi runtime adapters) keep the legacy drain path as compat for now; migrating them to watch/read is a follow-up, not part of this ADR's scope.
12. Watch respawn: with empty inbox, exactly one blocked watch process (graceful restart); after event, process exits and is respawned (observable, no accumulation of zombie processes). Kill -9 orphans are an accepted residual (§8e #5), excluded from this criterion.
13. Multi-session: two sessions attached to the same `(root, me)` mailbox — pending mail injects once (into the session owning the `(root:me)` watcher), no duplicate injection at second attach; secondary sessions show read-only status.
14. Concurrent claim: two sessions attach simultaneously — exactly one becomes owner, one injector; the other lands secondary.
15. Owner kill-9 while secondary attached: next claim/connect takes over (stale owner), single inject, `activeId` reset (cross-session takeover).
16. Upgrade boundary: post-upgrade first attach does NOT re-inject pre-upgrade injected ids (legacy injected.json unioned into processedIds), and migration note fires exactly once (legacy marker imported, no double note).

## Consequences

Positive:

- durable pending (mail survives crash; notification consumes nothing)
- real unread/read model; re-notify possible for unprocessed mail
- context cost ≈ envelopes, not bodies
- event-driven; idle cost ≈ one blocked fsnotify process
- priority-aware wake with roster trust gate (handshake never grants wake)
- explicit reply targeting; resolve tool closes the lifecycle
- removes buffer/dedupe bug surface; processedIds is a bounded set, not a mirror of mail

Costs:

- watch child respawn loop (one-shot semantics must be modeled; respawn is the main loop)
- fsnotify depends on local FS; network FS needs `--poll` (slower)
- processedIds + activeId state must persist in the shared per-`(root, me)` state file (`~/.amq-bridge/state/`, §8d #1)
- tests update: mailbox fake-amq contract moves from drain/list to watch/list/read; runtime-pi E2E assertions switch from bodies to envelopes
- one-time migration note for pre-upgrade `cur/` mail; marker file

## Open questions (status: resolved during implementation unless marked)

- Watch respawn debounce bound when re-list yields no new ids (proposal: 1s; max-no-new-message guard count?). → **Resolved (8c #7):** 1s debounce; extend after N no-new-id cycles (implementation default 5 → 5s cap).
- `brainstorm` kind classification: actionable or FYI? → **Originally resolved:** FYI (context-only), see §4. **Superseded 2026-08-12:** re-classified actionable — `brainstorm` requests engagement (e.g. design input) and must wake the peer; only `status` stays context-only. Applied in `ACTIONABLE_KINDS` (`src/receive-policy.mjs`).
- Scheduler persistence: processedIds/activeId live in pi session custom state (survives /reload) vs separate state file — confirm pi custom-state cap/size constraints. → **Resolved (§8d #1):** separate shared per-`(root, me)` state file (`~/.amq-bridge/state/`, cap 500).
- Multiple sessions sharing `(root, me)`: ... Rule needed: shared-`(root,me)` attach injects only into the first attached session (or processedIds must be shared/locked). → **Resolved (§8d #1):** processedIds are shared in the single state file; only the owner injects, ownership hands off without re-inject.
- Non-TUI sessions: tools + context, no auto-inject (unchanged). Badge pending count there too? → **Open (unchanged);** `amq_bridge_status` reports pending count in every mode.
- Body preview in inbox list: envelopes only by default; optional lazy `--preview` (read last N) needed? → **Not implemented:** inbox stays envelope-only with optional `--limit`; bodies via `amq_bridge_read`. Drop unless a user asks.
- Receipts: sender-side read evidence remains non-goal — confirm acceptable vs decision-log "users need delivery/read/pending evidence". → **Confirmed non-goal (§6).**
