# bee v1 Implementation Plan

> **For agentic workers:** execute task by task under `worktree-dispatch`, one fresh worker per task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** One live board, held by every attached browser, Claude session and an always-on host, that agents redraw as A2UI surfaces. The brainstorming visual companion runs on it.

**Architecture:** Every peer holds a kuilt replica of one `BoardState` lattice. Browsers hold theirs in wasm, behind the stock A2UI Lit renderer. Claude sessions hold theirs in a JVM MCP bridge. Everyone reaches the JVM host over outbound WebSockets. The host relays the board at `/ws/board` and keeps it on disk. It is also the single Raft voter that commits claims at `/ws/claims`, and it alone writes the claim mirror onto the board.

**Tech Stack:** Kotlin Multiplatform (JVM + wasmJs), Gradle 9.4.1, JDK 25, kuilt `0.7.0-dev.1690` (`kuilt-crdt`, `kuilt-quilter`, `kuilt-websocket`, `kuilt-gossip`, `kuilt-cluster`, `kuilt-raft`, `kuilt-liveness`, `kuilt-store`, `kuilt-test`), Ktor 3.5.2, A2UI (`@a2ui/lit` + `@a2ui/web_core`, exact versions pinned by Task 1), MCP Kotlin SDK `io.modelcontextprotocol:kotlin-sdk-server:0.16.0`, Playwright.

**Spec:** [`docs/superpowers/specs/2026-11-06-bee-design.md`](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/index.md) (tractat-us/bee#1). It carries this plan's amendments, decisions and LAN pairing ruling, so executors read the spec for the design and this plan for the tasks.

## Global Constraints

- **Shell:** every command runs after `source ~/.sdkman/bin/sdkman-init.sh && sdk use java 25.0.2-tem`. Every `./gradlew`, `npm` and `npx` command is fenced with `timeout 600`.
- **Stop rules, for every task:**
- Report, and don't push on, after 3 failed red/green cycles on one step.
- Report if the work needs a file outside the task's **Files** list.
- Report at any design call the plan doesn't make. Don't make it yourself.
- **Build ownership:** Task 1 owns all build wiring: `settings.gradle.kts`, `gradle/libs.versions.toml`, `gradle.properties`, every `build.gradle.kts`, `karma.config.d/`, `kotlin-js-store/`, `e2e/package.json`, `e2e/playwright.config.mjs` and `.github/workflows/`. Later tasks touch only their own source and test files. Two named exceptions:
- Task 3 may delete `tasks.test { failOnNoDiscoveredTests = false }` from `bee-host/build.gradle.kts`, once bee-host has tests.
- Task 4 drops `--pass-with-no-tests` from the `smoke` job's `npx playwright test` in `.github/workflows/ci.yml`, once its first spec exists.
- **Seams Task 1 fixed (PR #8).** Later tasks build to these, not to their own guesses:
- `bee-host` takes `--port <n> --data-dir <dir>` on its command line. `e2e/playwright.config.mjs` starts it that way, so Task 3's `HostConfig` must accept both.
- `:bee-host:e2eSeed -Pbee.e2e.host=<url>` runs `E2eSeederMain --host <url>`. The seeder seeds every fixture by default; `--fixture <name>` narrows it to one.
- The fixture voter reports its port as the stdout line `BEE_FIXTURE_VOTER_PORT=<n>`. The build hands it to the browser test as `window.__BEE_FIXTURE_VOTER_PORT__`.
- `A2uiRenderer.render` throws `A2uiRenderException`, carrying the renderer's error objects, when the renderer reports any error. Errors are never dropped. `A2uiMessage.UpdateDataModel` deletes with a Kotlin `null` value and refuses `JsonNull`.
- `createRendererHost(rootElement, catalogs = [notionsCatalog])`. `process` returns a JSON array of the processor's errors, and `onAction` hands over each action as JSON.
- The configuration cache is on. New build logic must stay compatible with it.
- **kuilt consumption:**
- Consume kuilt by coordinates: Tigris `s3://maven/`, the `TigrisKuilt` block in `fireworks-compose/settings.gradle.kts`, `S3_BUILD_CACHE_*` credentials and `kuilt-bom` `0.7.0-dev.1690`.
- The composite build (`if (file("../kuilt").exists()) includeBuild("../kuilt")`) is active only in the main checkout. Workers in `.claude/worktrees/` and CI build against coordinates.
- **Never fork, vendor or monkey-patch kuilt.** A kuilt gap goes on the kuilt tracking issue for bee's primitives (§11), never into bee. If that issue doesn't exist yet, report the gap for the invoking session to add. A bee workaround sits behind a comment naming the gap's entry.
- **Test gates:**
- The required gate is deterministic: an in-memory loom and virtual time (`kuilt-test`, `QuilterConfig(expectVirtualTime = true)`).
- Each task adds at most one real-socket smoke, gated on `-Pbee.realsocket.tests=true` and skipping itself with `assumeTrue` otherwise. This mirrors `kuilt-cluster/build.gradle.kts`.
- CI runs the smokes and the Playwright suite in the non-required `smoke` job, outside `ci-required`.
- **Renderer:** A2UI protocol v0.9.1 (`version: "v0.9"`) from the `v0_9` entry points, unless Task 1 finds that both packages implement v1.0 (A9). The renderer is stock and never sees a CRDT, and merge rules live only in the replica.
- **Transport:** everyone talks through the host over WebSockets, outbound only. No WebRTC.
- **`Sketch`:** content runs only in `<iframe sandbox="allow-scripts">`, never `allow-same-origin`, under a CSP of `default-src 'none'`. WebRTC still reaches the network, which v1 accepts (spec §5.6, bee#49). Nothing executes surface content outside that sandbox.
- **Claims and records:**
- Nothing reassigns itself. An orphaned claim stays held and flagged until a participant decides.
- Records are addressed to a surface, never to a session instance. Handling a record goes through a claim.
- The v1 voter set is the host alone. The host votes and never claims.
- **Board content:**
- "Clear the board" closes surfaces and never deletes them.
- Bridge tools make "this goes on the shared board" an explicit act, never a side effect.
- **Surface design (A1, A12):**
- Content a basis must name, or a handler must reason about having seen, is an appended, never-edited `Rga` entry: poll options and question revisions as `JsonNode.Array` elements in a surface, and action records in the board's own `BoardState.records`. Correcting one means removing it and appending a new entry with a new identity.
- Editable state lives in an object keyed by a stable id, with its order kept separately, and no basis names it: cards, columns, components, data fields.
- **Leaf writes start from the register just read** (`leaf.register.set(replica, v)`), never `MVRegister.empty().set(…)` (A13).
- **Conventions:**
- Test names have no `test` prefix. Multi-assert tests use `us.tractat.kuilt.test.assertAll`.
- One PR per task. Never use the word "chore" in commits or PR titles. Never commit `next-plan.md` or `morning-report*.md`.

## Review Focus

1. **Malformed agent-supplied A2UI** (no `root`, a child id naming no component, a type outside the Notions catalog). The bridge refuses with an error that names the id or type, and the board is unchanged. A browser shown an invalid surface anyway renders a placeholder, not the renderer. Tests: Task 2 `SurfaceValidatorTest`, Task 7 `BeeToolsTest.openRefusesMalformedComponents`.
1. **Markup in non-`Sketch` text** (`<img onerror=…>` in a title, label or option) is shown as literal text. Test: Task 11, `notions.spec.mjs` › `markup in labels renders as text`.
1. **A wait that ends badly.** The client's tool timeout or `/clear` cancels `bee_wait_action` after it claimed a record but before it returned it. No claim is left held by a wait nobody is in. Test: Task 7, `BridgeClaimsTest.cancelledWaitReleasesItsClaim`.
1. **The host is down when someone acts.** The edit lands locally, the page says it's offline, and the edit syncs on return. A claim returns `Unavailable`, never `Won`. If that claim commits later, the client releases it. Test: Task 6, `ClaimsClientTest.lateCommitOfTimedOutClaimIsReleased`.
1. **An oversized payload.** A `Sketch` above the seam's `maxPayloadBytes` is refused with a message that states the limit, and the board keeps working. Test: Task 8, `SketchSurfaceTest.oversizedSketchIsRefusedWithItsLimit`.

## Spec amendments

Two spec reviews, a prior-art pass and the kuilt peer's findings (kuilt `280de6b49`) amended the spec after it merged. **The spec now carries every amendment**, in the section it changes. This table stays as a pointer, because tasks cite the A-numbers.

|     | Amendment                                                                                                                                                 | Spec                                                                                                                                                                                                                  |
| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A1  | Anything a basis must name is an appended `Rga` entry, never an edited leaf. The basis keys dots by array pointer. `MVRegister` dots stay private (Iain). | [§6.2](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#62-action-records)                                                                                                                   |
| A2  | Claims are a state machine with fencing tokens, and the host owns the mirror.                                                                             | [§6.3](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#63-claims-exactly-one)                                                                                                               |
| A3  | "Dead" is a committed `Depart`, and a retired identity's commands are rejected.                                                                           | [§6.4](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#64-presence-and-failure)                                                                                                             |
| A4  | Two endpoints, `/ws/board` (reconnect shape A) and `/ws/claims` (`clusterClient`), one identity rule each.                                                | [§5.1](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#51-the-bee-client-browser), [§5.4](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#54-the-bee-host)        |
| A5  | kuilt ships the stores; only the `Quilter`↔`DurableStore` adapter is missing.                                                                             | [§5.4](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#54-the-bee-host)                                                                                                                     |
| A6  | Build order: spike 3 splits around claims, spike 2 into three tasks.                                                                                      | [§12](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#12-build-order)                                                                                                                       |
| A7  | Browser identity: the name in `localStorage`, a replica per tab, one snapshot writer, `?screen=` peers.                                                   | [§5.1](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#51-the-bee-client-browser)                                                                                                           |
| A8  | The `/clear` channel: `~/.bee/run/<claudePid>.json` and `POST /retire`.                                                                                   | [§5.2](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#52-the-mcp-bridge-bee-mcp)                                                                                                           |
| A9  | The A2UI pin is a spec version; v1.0 only if both packages export it.                                                                                     | [§5.3](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#53-the-a2ui-boundary-inside-the-page)                                                                                                |
| A10 | Waits are bounded: 45 s, at most 60 s.                                                                                                                    | [§5.2](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#52-the-mcp-bridge-bee-mcp)                                                                                                           |
| A11 | Writers validate, not the host.                                                                                                                           | [§5.2](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#52-the-mcp-bridge-bee-mcp), [§5.6](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#56-the-notions-catalog) |
| A12 | `Rga` elements are write-once: append-only arrays, keyed editable state, components and data arrays stored whole, no path type changes.                   | [§5.3](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#53-the-a2ui-boundary-inside-the-page), [§6.1](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#61-surfaces) |
| A13 | Leaf writes start from the register just read; the projection is a total order.                                                                           | [§5.3](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#53-the-a2ui-boundary-inside-the-page)                                                                                                |
| A14 | The driver diffs; it never applies deltas.                                                                                                                | [§5.3](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#53-the-a2ui-boundary-inside-the-page)                                                                                                |

## Decisions this plan makes (spec §10)

- **MCP tools:**
- Board: `bee_board`, `bee_surface_open`, `bee_surface_update`, `bee_surface_close`, `bee_board_clear`.
- Records: `bee_wait_action`, `bee_list_actions`, `bee_action_done`.
- Claims and presence: `bee_claim`, `bee_release`, `bee_presence`.

Every writing tool's description starts "Puts … on the shared board, visible to everyone attached".

- **Which records a session handles:** subscription by surface. Waiting on a surface is how a session handles it, and any session may wait on any surface.
- **Quiet timeout:** 20 minutes since the bridge's last tool call. **Crash:** the agent's claims link lost for the 30 s learner grace (bee#28).
- **Layout:** open surfaces stack in `openedAt` order (ties by id). Closed surfaces sit in a collapsed history strip, newest first, and reopen with their records.
- **The A2UI mapping** (`bee-a2ui`) depends only on `kuilt-crdt`, so it can graduate. Revisit after Task 4.
- **Peer identity:** agents are `agent:<name>#<8 hex>`, fresh per bridge identity. `name` is `$BEE_SESSION_NAME`, else the working directory's basename. Humans are `human:<name>` across tabs and devices. Only agents depart.
- **CRDT authors are not peer identities.** They're `web-<uuid>` per page load, `mcp-<uuid>` per bridge process, and `host-<uuid>` per host boot.
- **Host:** port `8233` ("BEE" on a keypad), data dir `~/.bee/host/`, routes `/`, `/ws/board`, `/ws/claims`. Loopback only until Task 10. The host has no write route; test fixtures write through a JVM `BoardClient`.
- **Notions catalog id:** `https://tractat.us/bee/catalogs/notions/v1.json`. It holds the basic catalog's components plus `Sketch`, `Board` and `Poll`.
- **Projection winner:** the greatest value under a total order on `JsonValue`: `Null` < `Bool` < `Num` < `Str`, then by value, with strings compared by UTF-16 code unit. That's peer-stable, never arrival order. The full set is published at `/_bee/conflicts/<escaped pointer>`.

## Dependency graph and fan-out

```text
Task 1 (alone; precondition: tractat-us/bee#5 merged)
 ├─► Task 2 lattice + projection ─► Task 3 relay ─► Task 4 driver + e2e ─┐
 ├─► Task 5 bridge mechanics ─────────────────────────────────────────────┼─► Task 7 join (completes spikes 3–4)
 └─► Task 6 claims ───────────────────────────────────────────────────────┘        ══ STOP for Iain's review ══
                                                                                    ├─► Task 8  Sketch + companion (spike 5)
                                                                                    ├─► Task 9  durability (spike 6)
                                                                                    ├─► Task 11 Board, Poll, orphans, history
                                                                                    └─► Task 10 second screen (spike 7; LAN pairing)
```

**Fan-out safety.** After Task 1, three chains run in parallel: 2 → 3 → 4, Task 5, and Task 6. No shared build file changes after Task 1. Source files are disjoint:

- Tasks 2–4 own `bee-a2ui/*`, the `bee-core` board files, `bee-host/{Main,HostConfig,BeeHost,BoardRelay}.kt` plus `bee-host` test fixtures, the `bee-web` main sources and `e2e/*.spec.mjs`.
- Task 5 owns `bee-mcp/*`, `bin/*` and `docs/setup.md`.
- Task 6 owns the `bee-core` claim files, `bee-host/ClaimsVoter.kt`, `bee-host`'s `FixtureVoterMain.kt` and `bee-web/src/wasmJsTest/.../WasmClaimTest.kt`.

The types all three chains share (`PeerIdentity`, `PeerKind`, `DepartReason`) land in Task 1. Tasks 8, 9 and 11 run one at a time after the review, unless the dispatcher checks that their file lists are disjoint.

## kuilt#2869 checkpoint (after the plan)

Inputs: kuilt `demo/workspace/CONTRACTS.md` at `53f89b34`, and `kuilt-workspace/docs/spikes/2026-10-reconnect-shape.md`.

**What bee picks up**

- **The input-history contract.** A record is an immutable `Rga` element whose identity is its dot, so a correction is a new record. Its basis is read once, from the actor's own replica, as it acts. Capturing at the right moment is the code's obligation, so Task 2 pins it with a test that reds on late capture.
- **"A scalar input has no identity"**, adopted as #2869 ruled it (A1). Every input a basis names is an appended `Rga` entry, keyed by its array, never a register.
- **The two-armed delivery rule** (`causalDots()` ∪ `causalFloor()`) is written once, in `Basis.saw`. bee is the second consumer #2869 said `Quilted.delivered(dot)` was waiting for.
- **"Unknown, never applicable."** A handler whose replica lacks a dot the basis names gets `basisComplete = false`.
- **Reconnect shape (A)** (A4), with its easy-to-miss detail: the identity-guarded mirror. The spike's second detail, each spoke's `GossipSeam` started on the connection's child scope, is no longer needed: kuilt#2950, in the pinned kuilt, stops gossip workers on close and on tear (Task 3).
- **Not in v1:** the re-check of a shown answer (kuilt#2880). bee keeps the basis it would need.

**What #2869 could pick up from bee**

- **A target beyond the JVM.** Capture in wasm in the browser (Tasks 1–4), which CONTRACTS doesn't establish.
- **A second capture consumer.** If `Basis.capture` matches `InputCapture.capture`, that's evidence for naming the API in kuilt.
- **The "Authority" pile.** Exactly-once claims with fencing tokens through `kuilt-cluster`, a voter that never claims, and orphaned work decided by any participant (Tasks 6, 7, 11).
- **M5's question**, answered for a board with no fixed screen, including the projection rule.

## kuilt tracking-issue items

These are the gaps known now. Each goes on the kuilt tracking issue (§11), not into bee:

- **Learner membership grows without bound.** Learners are never removed from membership (`ServerCluster.kt:185-209`, `CoreLearnerAdmission.kt:106`), so per-tab and per-`/clear` identities grow the configuration forever.
- **The documented leaf write is a footgun.** `Leaf(MVRegister.empty().set(…))` creates phantom conflicts. `CrdtSamples.kt:318-340` and Writerside `crdt-mvregister.md` teach it. A `JsonCrdt.setValue(key, JsonValue)` was proposed.
- **The docs misdescribe `Rga`.** Its elements are write-once, but Writerside `crdt-jsoncrdt.md` says otherwise.
- **`Quilted.delivered(dot)`**, which #2869 CONTRACTS confirmed, with bee as the second consumer.
- **A published hub-spoke client loom.** `RelaySpokeLoom` is demo-only.
- **`QuiltedPersistence`**, the `Quilter`↔`DurableStore` adapter (Task 9), as a graduation candidate.
- **Unauthenticated learner identity.** A learner's identity comes from an unauthenticated query parameter. Task 10's pairing key gates it on the LAN, and on loopback it stays trusted.

## Spec gap for Iain

The spec now carries this ruling, in [§5.7](https://tractat-us.github.io/bee/superpowers/specs/2026-11-06-bee-design/#57-letting-a-second-screen-in-iain).

- **Access control on the LAN: ruled (Iain, 2026-11-06). A key, with Bluetooth-style pairing.** A browser that arrives over the LAN without a key shows a six-digit code, and the board asks the current participants to type it in. A matching code admits the browser and gives it a key. Task 10 carries the design. Tasks 1–9 bind loopback only.
- **Design calls:** the host binds loopback unless `--lan` is passed, and loopback connections need no key. A URL key (`?k=`) still admits, for a join link or QR code. A code expires after 2 minutes, and 3 wrong entries void it. **Only human peers may admit a device.** This departs from the orphan-decision rule on purpose: admission is access control, and a typed code proves someone can see the new screen. Iain confirmed: humans only, for now.
- **Limits:** this is an access gate, not transport security. It runs over plain HTTP, and peer kind is self-declared by trusted local peers. The spec's §9 sharing rule still governs what goes on the board.

______________________________________________________________________

### Task 1: Kotlin/wasm and the renderer in one page (spike 1), plus all build wiring

**Precondition:** tractat-us/bee#5 (CI) is merged. If it isn't, stop and report. The `S3_BUILD_CACHE_*` credentials are org secrets that bee inherits. CI checks them in Step 3, and if that check fails, stop and report.

**Files:**

- Modify: `.github/workflows/ci.yml`, `.gitignore` (stop ignoring `kotlin-js-store/`)
- Create (build): `settings.gradle.kts` (modules `bee-a2ui`, `bee-core`, `bee-web`, `bee-host`, `bee-mcp`, `bee-companion`), `build.gradle.kts`, `gradle.properties`, `gradle/libs.versions.toml`, the Gradle wrapper, a `build.gradle.kts` per module, `bee-web/karma.config.d/timeouts.js`, `kotlin-js-store/yarn.lock` (generated), `e2e/package.json`, `e2e/playwright.config.mjs`
- Create (fixtures, placeholder bodies that later tasks replace): `bee-host/src/test/kotlin/us/tractat/bee/host/fixtures/{FixtureVoterMain.kt, E2eSeederMain.kt}`, `bee-companion/src/main/kotlin/us/tractat/bee/companion/Main.kt`
- Create (spike): `bee-core/src/commonMain/kotlin/us/tractat/bee/core/PeerIdentity.kt`, `bee-a2ui/src/commonMain/kotlin/us/tractat/bee/a2ui/A2uiMessage.kt`, `bee-notions/package.json` (`"name": "@bee/notions"`, private), `bee-notions/src/renderer-host.js`, `bee-web/src/wasmJsMain/kotlin/us/tractat/bee/web/{A2uiRenderer.kt, Main.kt}`, `bee-web/src/wasmJsMain/resources/index.html`
- Tests: `bee-core/src/commonTest/.../PeerIdentityTest.kt`, `bee-a2ui/src/commonTest/.../A2uiJsonTest.kt`, `bee-web/src/wasmJsTest/kotlin/us/tractat/bee/web/RendererSeamTest.kt`
- Create: `docs/spikes/2026-11-spike-1-renderer-glue.md`

**Interfaces:**

- Produces (`us.tractat.bee.core`):

- `enum class PeerKind { Human, Agent }`.

- `data class PeerIdentity(kind: PeerKind, name: String, instance: String)`, with `toString()` (`agent:<name>#<instance>` / `human:<name>`), `companion fun agent(name: String, random: Random)`, `fun human(name: String)` and `fun parse(s: String)`.

- `enum class DepartReason { Crashed, Quiet, Cleared }`.

- Produces (`us.tractat.bee.a2ui`):

- `sealed interface A2uiMessage`: `CreateSurface(surfaceId, catalogId, sendDataModel = false)`, `UpdateComponents(surfaceId, components: List<JsonObject>)`, `UpdateDataModel(surfaceId, path: String, value: JsonElement?)` (null deletes, in the pinned wire form), `DeleteSurface(surfaceId)`.

- `data class A2uiAction(name, surfaceId, sourceComponentId, timestamp, context: JsonObject)`.

- `object A2uiJson { fun encode(messages: List<A2uiMessage>): String; fun decodeAction(json: String): A2uiAction }`.

- `const val NOTIONS_CATALOG_ID = "https://tractat.us/bee/catalogs/notions/v1.json"`.

- Produces (`bee-web`): `class A2uiRenderer(root: Element) { fun render(messages: List<A2uiMessage>); val actions: Flow<A2uiAction> }`. It tracks which surface ids exist, so a reopen becomes delete + create. `render` throws `A2uiRenderException` when the renderer reports an error. Only JSON strings cross the wasm↔JS boundary.

- Produces (`bee-notions`): `export function createRendererHost(rootElement, catalogs = [notionsCatalog])` returning `{ process(messagesJson), onAction(handler) }`, where `process` returns the processor's errors as a JSON array. It wraps web_core's `MessageProcessor` and mounts one `<a2ui-surface>` per surface. The reference host is A2UI's `samples/client/lit/shell/app.ts`.

- Produces (build, for later tasks):

- `bee-host` packs `:bee-web:wasmJsBrowserDistribution` into its classpath under `web/`.

- `-Pbee.realsocket.tests=true` is forwarded as a system property to every JVM `Test` task, with `--enable-native-access=ALL-UNNAMED` when set.

- With that flag, a Gradle `BuildService` starts `FixtureVoterMain` (bee-host test classpath) before `:bee-web:wasmJsBrowserTest`. It writes the voter's port to `bee-web/build/karma-config.d/fixture.js` as `window.__BEE_FIXTURE_VOTER_PORT__`, and stops the voter after the task.

- `:bee-host:e2eSeed` is a `JavaExec` task that runs `E2eSeederMain`. Playwright's `globalSetup` calls it to write fixture surfaces through a JVM `BoardClient`.

- **Step 1: Check the precondition.** Then pin A2UI (A9). Read both packages' `exports` and `typescript/renderers/lit`. Use v1.0 only if both packages export it, else v0.9.1, and pin exact versions in `bee-notions/package.json`.

- **Step 2: Scaffold the build.**

- Copy the wrapper (Gradle 9.4.1) and the kuilt `origin/main` versions into `libs.versions.toml`: Kotlin 2.4.20, Ktor 3.5.2, coroutines 1.11.0, serialization 1.12.0, atomicfu 0.33.0. Add `kuilt-bom` `0.7.0-dev.1690` (publish run 1690, kuilt `280de6b49`). Confirm it resolves; if it doesn't, use the newest build that does, and record its version and commit.

- Add MCP `kotlin-sdk-server` 0.16.0, Ktor server Netty and the Ktor JS client.

- Copy the `TigrisKuilt` block and `systemProp.org.gradle.s3.endpoint=https://buildcache.fly.storage.tigris.dev` from `fireworks-compose`, and add the gated `includeBuild`.

- `gradle.properties` sets `kotlin.incremental.wasm=false` (kuilt #2849). Every module uses `kotlin { jvmToolchain(25) }`. `bee-a2ui` and `bee-core` target jvm + wasmJs, and `bee-host`, `bee-mcp` and `bee-companion` are JVM `application`s.

- Model `bee-web` on kuilt `demo/web/build.gradle.kts`, but keep tests on: `testTask { useKarma { useChromeHeadless(); useConfigDirectory(file("karma.config.d")) } }`. `timeouts.js` sets the Mocha timeout to 60 s and the Karma `captureTimeout` and `browserNoActivityTimeout` to 120 s, because kuilt's equivalents are private to its convention plugin. It depends on `npm(project.file("../bee-notions"))`. Commit the generated `kotlin-js-store/yarn.lock`.

- Wire the three build products in **Interfaces**. The fixture mains are placeholders: they print `TODO(Task 6)` and `TODO(Task 4)` and exit 0.

- Pin `@playwright/test` exactly in `e2e/package.json`. `playwright.config.mjs` starts `bee-host` from `installDist` on a free port.

- **Step 3: Extend CI.**

- In `ci.yml`'s `build` job, pin `actions/setup-java` to `temurin` `25.0.2`. The `Build` step takes `S3_BUILD_CACHE_ACCESS_KEY_ID` and `S3_BUILD_CACHE_SECRET_KEY` from the org secrets in its `env`. It checks both are set, without printing them, before it runs `timeout 600 ./gradlew check`: `[ -n "$S3_BUILD_CACHE_ACCESS_KEY_ID" ] && [ -n "$S3_BUILD_CACHE_SECRET_KEY" ] || { echo "::error::S3 build-cache secret missing"; exit 1; }`. Do the same in the `smoke` job.

- Add a `smoke` job that is **not** in `ci-required.needs`. It runs:

  - `timeout 600 ./gradlew check -Pbee.realsocket.tests=true`
  - `cd e2e && timeout 600 npm ci && timeout 600 npx playwright install --with-deps chromium && timeout 600 npx playwright test`

  Keep the workflow's existing gate conventions.

  - [ ] **Step 4: Write the failing tests.**
  - `PeerIdentityTest.formatsAndParsesBothKinds`: `agent:bee#0a1b2c3d` and `human:iain` round-trip, and `agent()` mints 8 lowercase hex.
  - `A2uiJsonTest.createSurfaceEncodesTheEnvelope`: `encode(listOf(CreateSurface("s1", NOTIONS_CATALOG_ID)))` equals `[{"version":"v0.9","createSurface":{"surfaceId":"s1","catalogId":"https://tractat.us/bee/catalogs/notions/v1.json","sendDataModel":false}}]`. Use the v1.0 envelope if Step 1 chose v1.0.
  - `A2uiJsonTest.actionDecodesFromTheRendererShape`.
  - `A2uiJsonTest.deleteEncodesInThePinnedForm`: the value is omitted for v0.9.1, or `null` for v1.0.
  - [ ] **Step 5: Run them, then implement until green.** `timeout 600 ./gradlew :bee-core:jvmTest :bee-a2ui:jvmTest`.
  - [ ] **Step 6: Write the failing browser tests** `RendererSeamTest`.
  - `clickReachesKotlinAsAnAction`: render `CreateSurface("spike-1", NOTIONS_CATALOG_ID)` and a root `Column` holding `Text` "Hello from Kotlin" and `Button` id `press-button` with action `press`. Click the button inside the shadow root. With `assertAll`, the first `actions` element has `name == "press"`, `surfaceId == "spike-1"` and `sourceComponentId == "press-button"`.
  - `textRendersFromKotlin`.
  - `createOnAnExistingIdIsAnIntegrityError`: record what the processor reports.
  - `reopenAfterDeleteRendersAgain`.
  - `dataModelDeleteRemovesTheValue`.
  - [ ] **Step 7: Run them, then implement until green.** `timeout 600 ./gradlew :bee-web:wasmJsBrowserTest`. Then check by eye with `timeout 600 ./gradlew :bee-web:wasmJsBrowserDevelopmentRun`, and click.
  - [ ] **Step 8: Write `docs/spikes/2026-11-spike-1-renderer-glue.md`.** Record:
  - the pinned versions and the spec version they implement
  - the kuilt build and its commit
  - the bundling route, including how webpack took Lit's `accessor` fields
  - the bundle size
  - the integrity-error and delete behaviour
  - every workaround, with its tracking-issue entry
  - [ ] **Step 9: Commit and open the PR.** Message: "Spike 1: Kotlin/wasm drives the A2UI Lit renderer, with the build and CI".

**Stop condition (design call):** if the glue can't work, stop and report the evidence. Don't move the replica to the JVM host, and don't choose an unlisted renderer. Both are Iain's calls.

______________________________________________________________________

### Task 2: The board lattice and its projection (spike 2, part 1)

**Files:**

- Create in `bee-core/src/commonMain/kotlin/us/tractat/bee/core/`: `BoardState.kt`, `SurfaceDoc.kt`, `ActionRecord.kt`, `Basis.kt`
- Create in `bee-a2ui/src/commonMain/kotlin/us/tractat/bee/a2ui/`: `SurfaceView.kt`, `SurfaceProjection.kt`, `MessageDiff.kt`, `SurfaceValidator.kt`
- Tests: `bee-core/src/commonTest/.../{BoardStateTest.kt, BasisTest.kt}`, `bee-a2ui/src/commonTest/.../{SurfaceProjectionTest.kt, MessageDiffTest.kt, SurfaceValidatorTest.kt}`

**Interfaces:**

- Consumes: `PeerIdentity`, `DepartReason`, `A2uiMessage`, `NOTIONS_CATALOG_ID` (Task 1).
- Produces (`us.tractat.bee.core`):
- `class BoardState : Quilted<BoardState>`, componentwise over five components:
- `surfaces: ORMap<String, JsonCrdt>`
- `records: Rga<ActionRecord>`
- `presence: EphemeralMap<PresenceEntry>`
- `departed: GSet<Departure>`
- `claims: LWWMap<String, ClaimView>`

```text
It provides `EMPTY`, `serializer()` and `withReplica(r)`. `causalDots()` and `causalFloor()` delegate to `records` only (kuilt#2177).
```

- `data class PresenceEntry(identity: PeerIdentity, link: String, status: String?, lastActedAt: Instant, waiting: Boolean)`, `data class Departure(identity: PeerIdentity, reason: DepartReason, logIndex: Long)` and `data class ClaimView(status: String, holder: String?, token: String?, logIndex: Long)`. Only the host writes `departed` and `claims` (A2).
- Mutators, each returning `Patch<BoardState>`:
  - `openSurface(replica, id, meta: SurfaceMeta, components: List<JsonObject>, data: JsonObject)`, `updateComponents(replica, id, components)`, `closeSurface(replica, id, at)`, `reopenSurface(replica, id)`.
  - `updateData(replica, id, path, value: JsonElement)` writes objects as keyed objects, scalars as leaves built from the read register (A13), and JSON arrays whole as one leaf. It refuses to change a path's type.
  - `appendEntry(replica, id, path, value: JsonElement)` appends to append-only arrays (A12).
  - `appendRecord(replica, record)`.
- Reads: `openSurfaces()`, `closedSurfaces()`, `surfaceDoc(id): JsonCrdt?`, `meta(id): SurfaceMeta?` and `records(surfaceId): List<Pair<RecordId, ActionRecord>>`.
- `data class SurfaceMeta(title, catalogId, openedBy: PeerIdentity, openedAt: Instant, closedAt: Instant? = null)`. A surface doc's keys are `meta`, `components` (keyed by component id, each value one leaf of canonical component JSON) and `data`.
- `value class RecordId(val dot: Dot)` and `data class ActionRecord(surfaceId, componentId, actor: PeerIdentity, name, context: JsonObject, basis: Basis, at: Instant)`.
- `data class Basis(surfaceDots: Map<String, Set<Dot>>, surfaceFloors: Map<String, VersionVector>, recordDots: Set<Dot>, recordFloor: VersionVector)`. The two surface maps are keyed by the JSON pointer of each append-only array (A1). It has `fun saw(arrayPointer: String, dot: Dot): Boolean` and `fun sawRecord(dot: Dot): Boolean` (both the two-armed rule), and `companion fun capture(state: BoardState, surfaceId: String): Basis`. `capture` walks each `JsonNode.Array` and never calls `JsonCrdt.causalDots()`.
- Produces (`us.tractat.bee.a2ui`):
- `data class SurfaceView(id, catalogId, components: Map<String, JsonObject>, data: JsonObject, conflicts: Map<String, List<JsonElement>>)`
- `fun JsonCrdt.toSurfaceView(id: String): SurfaceView`
- `fun diff(prev: SurfaceView?, next: SurfaceView?): List<A2uiMessage>`
- `object SurfaceValidator { fun errors(view: SurfaceView): List<String> }`
- **Step 1: Write the failing lattice and basis tests** (in memory, deterministic).
- `BoardStateTest`:
  - `concurrentEditsToOneSurfaceConverge`
  - `closeKeepsTheSurfaceAndItsRecords`
  - `serializedStateRoundTripsAfterWithReplica`
  - `rewriteOfAReadLeafLeavesNoPhantomConflict` (A13)
  - `concurrentEditsOfOneKeyedItemDoNotDuplicateIt` (A12)
  - `reorderingCannotDuplicateAnItem`
  - `pathTypeChangeIsRefused`
- `BasisTest.basisNamesOnlyDotsTheActorsReplicaDelivered`: a poll option appended on B and not yet merged into A has `saw("/options", optionDot) == false` in A's basis, and `true` after the merge.
- `BasisTest.dotsOfTwoArraysNeverAlias`: two arrays in one surface mint the same `Dot(replica, 1)`. A basis that saw the first array's entry and not the second's says so per array.
- `BasisTest.flooredDotIsStillSeen`, using `Rga.dropWindow`. Deleting the floor arm must red it.
- `BasisTest.basisCapturedAtActionTimeIgnoresLaterMerges`.
- `BasisTest.recordCannotClaimAnEntryItsReplicaLacked`: capture never names an undelivered dot, and a hand-built basis that does is flagged by `basisComplete`.
- `BasisTest.questionRevisionIsAnAppendedEntry`.
- **Step 2: Write the failing projection, diff and validator tests.**
- `SurfaceProjectionTest.concurrentScalarWritesProjectOneWinnerAndAConflict`: both replicas project the same `data.answer`, and `conflicts["/answer"] == ["x","y"]`.
- `SurfaceProjectionTest.writeThatSawBothValuesClearsTheConflict`.
- `MessageDiffTest`:
  - `newSurfaceEmitsCreateComponentsAndData`
  - `changedComponentEmitsOnlyThatComponent`
  - `changedDataKeyEmitsOneUpdateDataModelAtItsPath`
  - `closedSurfaceEmitsDeleteSurface`
  - `reopenedSurfaceEmitsCreateAfterDelete`
  - `changedArrayIdsReplaceTheWholeArray`
- `SurfaceValidatorTest`: `missingRootIsAnError`, `danglingChildIsNamed`, `unknownTypeIsNamed`, `validSurfaceHasNoErrors`.
- **Step 3: Run them, then implement until green.** `timeout 600 ./gradlew :bee-core:jvmTest :bee-a2ui:jvmTest`. Point `Basis.saw` at the `Quilted.delivered` tracking-issue item in a comment.
- **Step 4: Commit and open the PR.** Message: "Spike 2a: the board lattice, the basis and the A2UI projection".

**Stop condition (design call):** as in Task 1. If `ORMap<String, JsonCrdt>` can't carry surfaces, or the projection rule can't be made deterministic, stop and report. Don't pick a different shape.

______________________________________________________________________

### Task 3: The relay (spike 2, part 2)

**Files:**

- Create: `bee-core/src/commonMain/kotlin/us/tractat/bee/core/{BoardClient.kt, SpokeLoom.kt}`, `bee-host/src/main/kotlin/us/tractat/bee/host/{Main.kt, HostConfig.kt, BeeHost.kt, BoardRelay.kt}`
- Tests: `bee-host/src/test/kotlin/us/tractat/bee/host/BoardClientTest.kt` (in-memory, deterministic; it drives `BoardRelay`, which bee-core can't see), `bee-host/src/test/kotlin/us/tractat/bee/host/RelaySmokeTest.kt` (real socket, behind the flag)
- Modify: `bee-host/build.gradle.kts`, only to delete `tasks.test { failOnNoDiscoveredTests = false }` (a named exception to the build-ownership rule)

**Interfaces:**

- Consumes: `BoardState` and its mutators (Task 2).

- Produces:

- `class BoardClient(author: ReplicaId, loom: Loom, rendezvous: Rendezvous, scope, clock: () -> Instant, initial: BoardState = BoardState.EMPTY, config: QuilterConfig = BEE_QUILTER_CONFIG)`. It exposes `state: StateFlow<BoardState>`, `online: StateFlow<Boolean>` and `link: StateFlow<String?>`, plus `suspend fun mutate(f)`, `suspend fun mutateOrSkip(f): Boolean`, `connect()`, `disconnect()` and `suspend fun close()`. `BEE_QUILTER_CONFIG = QuilterConfig(antiEntropyInterval = 1.seconds, evictionAfter = 30.seconds)`. Tests pass `expectVirtualTime = true`.

- `class SpokeLoom(httpClient, scope, peerName, random, clock) : Loom`, in the shape of kuilt demo `RelaySpokeLoom`. A comment names the tracking-issue item.

- `class BeeHost { val port: Int; val state: StateFlow<BoardState>; suspend fun mutate(f); fun close(); companion suspend fun start(scope, config: HostConfig): BeeHost }` and `HostConfig(port = 8233, bind = "127.0.0.1", dataDir: Path)`. `Main` reads `--port <n>` and `--data-dir <dir>` into it, the form Playwright starts the host with. `BoardRelay` follows kuilt `demo/relay/PatchworkRelay.kt`: `KtorConnectionSource` at `/ws/board`, `hostedOverlay`, the host `Quilter` and the merge echo. `BoardRelay` also has a constructor over any hub `Seam`, so tests can drive it in memory.

- **Step 1: Write the failing deterministic tests** `BoardClientTest`, over `InMemoryLoom` and a `BoardRelay` on an in-memory hub seam:

- `twoClientsConvergeThroughTheHub`

- `offlineEditMergesOnReconnectUnderTheStableAuthor`

- `lateEmissionFromAPartedQuilterNeverOverwritesTheHeldState` (the identity-guarded mirror)

- `reconnectLeaksNoGossipCoroutines`

- **Step 2: Write the smoke** `RelaySmokeTest.twoClientsConvergeOverRealSockets`: `assumeTrue(System.getProperty("bee.realsocket.tests") == "true")`, a real `BeeHost` on port 0, two `BoardClient`s over `SpokeLoom`.

- **Step 3: Run them, then implement until green.** `timeout 600 ./gradlew :bee-host:test`, then the same with `-Pbee.realsocket.tests=true`.

- **Step 4: Commit and open the PR.** Message: "Spike 2b: the host relays the board in reconnect shape (A)".

**Stop condition (design call):** as in Task 1. If shape (A) can't converge, stop and report. Don't switch to rooms.

______________________________________________________________________

### Task 4: The browser driver and two browsers (spike 2, part 3)

**Files:**

- Create: `bee-web/src/wasmJsMain/kotlin/us/tractat/bee/web/{BoardDriver.kt, LiveInputs.kt}`, `e2e/two-browsers.spec.mjs`
- Modify: `bee-web/.../Main.kt`, `bee-host/src/test/kotlin/us/tractat/bee/host/fixtures/E2eSeederMain.kt` (replace the placeholder), `bee-notions/src/renderer-host.js` (expose web_core's `DataModel.subscribe` for `LiveInputs`), `e2e/global-setup.mjs`, `.github/workflows/ci.yml` (only to drop `--pass-with-no-tests`)
- Create: `docs/spikes/2026-11-spike-2-crdt-behind-a2ui.md`

**Interfaces:**

- Consumes: `A2uiRenderer` (Task 1); `BoardState`, `Basis`, `diff`, `SurfaceValidator` (Task 2); `BoardClient`, `SpokeLoom`, `BeeHost` (Task 3).

- Produces:

- `class BoardDriver(client: BoardClient, renderer: A2uiRenderer, author: ReplicaId, actor: PeerIdentity)`. It validates each `SurfaceView` (an invalid one gets a placeholder `<div>` naming its errors), and renders `diff(lastRendered, view)`. It never applies a delta (A14). For each action it runs `client.mutate { it.appendRecord(author, ActionRecord(…, basis = Basis.capture(it, surfaceId), …)) }`, capturing inside the transform.

- `class LiveInputs(renderer, client)` subscribes to `DataModel.subscribe(path, …)` for every `TextField`-bound path, and writes through `updateData`, debounced to 150 ms. It ignores the echo of values the driver rendered, and holds remote changes to a focused or dirty path until blur.

- `E2eSeederMain` writes fixture surfaces through a JVM `BoardClient` (`--host`, `--fixture <name>`). The host gets no write route.

- **Step 1: Write the e2e tests** `e2e/two-browsers.spec.mjs`. Two browser contexts open `/`; `globalSetup` seeds a surface with a `TextField` bound to `/note`.

- `typing in one browser appears in the other` (within 2 s).

- `concurrent typing shows one value and a conflict marker in both`: cut each page's board socket with `page.routeWebSocket` (`context.setOffline` leaves WebSockets open, microsoft/playwright#4912), type different text in each, then restore.

- `a click becomes a record with a basis`.

- `a remote edit never overwrites the field being typed in`.

- **Step 2: Run them, then implement until green.** Run: `timeout 600 ./gradlew :bee-host:installDist && cd e2e && timeout 600 npm ci && timeout 600 npx playwright install --with-deps chromium && timeout 600 npx playwright test two-browsers.spec.mjs`. This suite runs in CI's `smoke` job, not the gate. With the first spec in place, drop `--pass-with-no-tests` from the `smoke` job, and confirm from the run's log that Playwright starts `webServer` before `globalSetup`, since the seeder needs a running host.

- **Step 3: Write `docs/spikes/2026-11-spike-2-crdt-behind-a2ui.md`.** Record:

- the projection rule's evidence

- the live-typing route

- the frame size of a one-field edit (kuilt#2469)

- how `ORMap<String, JsonCrdt>` behaved

- whether the mapping looks generic enough for kuilt

- **Step 4: Commit and open the PR.** Message: "Spike 2c: the browser driver, two browsers editing one surface".

**Stop condition (design call):** as in Task 1. If live typing can't work through the stock renderer, stop and report. Don't fork the renderer.

______________________________________________________________________

### Task 5: Bridge mechanics (spike 3, first half)

**Files:**

- Create: `bee-mcp/src/main/kotlin/us/tractat/bee/mcp/{Main.kt, BridgeIdentity.kt, ClearControl.kt, ClaudeAncestry.kt, WaitTool.kt, ActionSource.kt}`, `bin/bee-session-start-hook` (POSIX sh), `docs/setup.md` (the MCP config and hook snippet Iain installs; workers never edit `~/.claude`)
- Tests: `bee-mcp/src/test/kotlin/us/tractat/bee/mcp/{BridgeIdentityTest.kt, ClearControlTest.kt, WaitToolTest.kt, ClaudeAncestryTest.kt}`
- Create: `docs/spikes/2026-11-spike-3-bridge-mechanics.md`

**Interfaces:**

- Consumes: `PeerIdentity`, `DepartReason` (Task 1).

- Produces:

- `class BridgeIdentity(name: String, random: Random, onRetire: suspend (PeerIdentity, DepartReason) -> Unit)` with `val current: StateFlow<PeerIdentity>` and `suspend fun retire(reason: DepartReason)`. Task 7 wires `onRetire` to Raft.

- `object ClaudeAncestry { fun claudePid(ancestors: () -> List<ProcessHandle> = …): Long? }`.

- `class ClearControl(identity: BridgeIdentity, runDir: Path = ~/.bee/run)` provides the loopback `POST /retire` and the run file `<claudePid>.json` (A8), which it deletes on close.

- `interface ActionSource { suspend fun next(surfaceId: String, cursor: String?, timeout: Duration): WaitResult; fun list(surfaceId: String, since: String?): List<JsonObject> }` and `data class WaitResult(record: JsonObject?, timedOut: Boolean, cursor: String)`. Task 7 supplies the board-backed source; this task uses a fake.

- `WaitTool.register(server: Server, source: ActionSource, progressEvery: Duration = 10.seconds)` registers `bee_wait_action(surfaceId, cursor?, timeoutSeconds = 45, max 60)` with progress notifications, and `bee_list_actions(surfaceId, since?)`.

- **Step 1: Write the failing tests** (virtual time, in-process SDK client).

- `BridgeIdentityTest.retireMintsAFreshInstanceAndReportsTheOld`.

- `ClaudeAncestryTest.findsTheNearestClaudeAncestor`, `noClaudeAncestorIsNull`.

- `ClearControlTest.postRetireRetiresWithCleared`, `runFileNamesThePortAndIsRemovedOnClose`.

- `WaitToolTest`: `returnsARecordWithItsCursor`, `timesOutWithinTheBoundAndSaysSo`, `sendsProgressWhileHolding`, `refusesATimeoutAboveSixtySeconds`, `listReadsWithoutBlocking`.

- **Step 2: Run them, then implement until green.** `timeout 600 ./gradlew :bee-mcp:test`. The hook reads the hook JSON on stdin. On `source == "clear"` it finds `claudePid` by the same rule and POSTs `/retire`; otherwise it exits 0 silently.

- **Step 3: Measure, and report the results as observed.**

- Register `bee-mcp` (fake source) through a temporary `--mcp-config`. Call a diagnostic `bee_hold(seconds)` tool, removed before the PR, at 60 s and at 5 min. Run each in the background under `timeout 420 claude -p …`, and record which returns and how the client fails the other.

- **Opt-in, only if a nested session starts:** an interactive `/clear` driven through tmux, with the hook loaded through `--settings <file>`, confirming that the bridge pid is unchanged, the hook fired, and `/retire` was hit. If a nested session can't start, record it and continue; Iain runs it.

- No 30-minute hold (Iain: dropped). Bounded waits make long holds unnecessary.

- Write the results into `docs/spikes/2026-11-spike-3-bridge-mechanics.md`. If the measurements contradict A10's 45 s default, propose the change there and keep the default.

- **Step 4: Commit and open the PR.** Message: "Spike 3a: bridge lifecycle, the /clear channel and bounded waits".

______________________________________________________________________

### Task 6: Claims (spike 4)

**Files:**

- Create: `bee-core/src/commonMain/kotlin/us/tractat/bee/core/{ClaimCommand.kt, ClaimTable.kt, ClaimsClient.kt}`, `bee-host/src/main/kotlin/us/tractat/bee/host/ClaimsVoter.kt`
- Modify: `bee-host/src/test/kotlin/us/tractat/bee/host/fixtures/FixtureVoterMain.kt` (replace the placeholder)
- Tests: `bee-core/src/commonTest/.../ClaimTableTest.kt`, `bee-host/src/test/kotlin/us/tractat/bee/host/{ClaimsRaceTest.kt, ClaimsClientTest.kt, VoterRestartTest.kt, ClaimsSmokeTest.kt}`, `bee-web/src/wasmJsTest/kotlin/us/tractat/bee/web/WasmClaimTest.kt`
- Create: `docs/spikes/2026-11-spike-4-claims.md`

**Interfaces:**

- Consumes: `PeerIdentity`, `DepartReason` (Task 1). It doesn't use `BoardState`; Task 7 wires the mirror.
- Produces:
- `value class ClaimTarget(val value: String)` (`record:<replica>:<seq>` or `card:<surfaceId>/<cardId>`) and `data class ClaimToken(term: Long, index: Long)`.
- `sealed interface ClaimCommand`: `Claim(target, by)`, `Release(target, by, token?)`, `Reassign(target, to, by)`, `Complete(target, by, token)` and `Depart(identity, reason)`. Each is serializable and idempotent under replay.
- `class ClaimTable`. It folds `Committed` in three ways:
- `Entry` → apply.
- `Internal(index)` → advance `appliedIndex` only.
- `Install(snapshot)` → replace the table with `ClaimTable.decode(snapshot)`. In v1 the host publishes no snapshots, so this arrives only after a future compaction. It must still work.

```text
It provides `fun status(target): ClaimStatus`, `fun heldBy(identity): Set<ClaimTarget>`, `fun retired(identity): Boolean`, `val appliedIndex: Long` and `fun encode(): ByteArray`. `sealed interface ClaimStatus { Free; Held(holder, token); Orphaned(holder, token); Done(by) }`. The rules (A2, A3):
- The first committed `Claim` of a `Free` target wins, with token `(term, index)`, and any other `Claim` applies as rejected.
- `Release` and `Complete` need the holder and the current token. A `Release` with no token is allowed only on an `Orphaned` target.
- `Reassign` applies only to an `Orphaned` target, and mints a new token.
- `Depart` orphans every `Held` of that identity, and every later command `by` a retired identity applies as rejected.
```

- `class ClaimsClient(cluster: ClusterClient, clock)` with `val table: StateFlow<ClaimTable>`, `suspend fun claim(target, by, timeout = 10.seconds): ClaimOutcome`, and `release`, `reassign` and `complete`. `sealed interface ClaimOutcome { Won(token); Lost(holder); Unavailable }`. A claim reported `Unavailable` that later commits `Won` is released by the client.
- `class ClaimsVoter private constructor(...)` with `companion suspend fun start(scope, host: RoomHost, storeDir: File, raftConfig: RaftConfig, mirror: ClaimMirror, clock): ClaimsVoter`. It runs `val storage = DurableStoreRaftStorage.open(FileChannelDurableStore(storeDir))`, then `scope.serverCluster(host, voterIds = listOf(HOST_NODE), raftConfig, storageFactory = { storage })` (A5). Its only proposal is `suspend fun depart(identity, reason)`. On boot it re-folds the log and republishes. `fun interface ClaimMirror { suspend fun publish(table: ClaimTable) }`. Tests pass an in-memory `RoomHost` and `InMemoryDurableStore`-backed storage, and production passes `KtorRoomHost` at `/ws/claims`.
- **Step 1: Run the fit check that `kuilt-cluster` fits as-is** (§6.3, `docs/agent-cookbook/consensus.md#when-one-of-the-peers-is-a-server`). The kuilt peer answered two of its questions:
- `clusterClient` compiles for wasmJs over `KtorClientLoom`.
- A single voter commits inline and never steps down.

Still open: (a) a real WebSocket from wasm, which `WasmClaimTest` answers; (b) whether the server can propose its own `Depart`; (c) whether learners receive committed entries from index 1 or an `Install`. **If any answer fails, stop: record it in the spike doc, add it to the tracking issue, and report.** A4's fallback is Iain's call.

- [ ] **Step 2: Write the failing table tests** `ClaimTableTest`:
- `firstCommittedClaimWinsAndLaterOnesAreRejected`
- `replayedEntryChangesNothing`
- `internalAdvancesTheIndexOnly`
- `installReplacesTheTable`
- `departOrphansAndReassignsNothing`
- `staleTokenCompleteIsRejected`
- `commandFromARetiredIdentityIsRejected`
- `reassignMintsANewToken`
- `onlyOrphansAreReleasedWithoutAToken`
- [ ] **Step 3: Write the failing deterministic integration tests** over an in-memory `RoomHost` and virtual time:
- `ClaimsRaceTest.twoClientsRaceAndExactlyOneWins`: 50 rounds, exactly one `Won` per round, and the mirror names it.
- `ClaimsClientTest.claimWithoutQuorumReturnsUnavailableNotWon` and `lateCommitOfTimedOutClaimIsReleased` (Review Focus 4).
- `VoterRestartTest.committedClaimSurvivesARestart`: the same store, still `Held` with the same token.
- `VoterRestartTest.retiredIdentityStaysRetiredAfterRestart`.
- [ ] **Step 4: Write the smokes** (behind `-Pbee.realsocket.tests=true`):
- `ClaimsSmokeTest.claimOverRealSockets`.
- `WasmClaimTest.browserClaimsThroughTheVoter`: dials `window.__BEE_FIXTURE_VOTER_PORT__` over a real WebSocket and skips without it. Its `by` is the human identity, and its Raft node id is per connection.
- Replace `FixtureVoterMain` with a real voter on port 0 that prints the line `BEE_FIXTURE_VOTER_PORT=<n>` on stdout. The build reads that line and logs the rest.
- [ ] **Step 5: Run them, then implement until green.** `timeout 600 ./gradlew :bee-core:jvmTest :bee-host:test`, then `timeout 600 ./gradlew :bee-host:test :bee-web:wasmJsBrowserTest -Pbee.realsocket.tests=true`.
- [ ] **Step 6: Write `docs/spikes/2026-11-spike-4-claims.md`.** Record the fit-check answers, claim latency, and the log size after the race.
- [ ] **Step 7: Commit and open the PR.** Message: "Spike 4: exactly-once claims with fencing tokens, through a durable voter that never claims".

**Stop condition (design call):** as in Task 1, and Step 1's rule.

______________________________________________________________________

### Task 7: Join, which completes spikes 3 and 4

A session opens a surface, waits, and answers. A second session sees it. Killing, quieting or `/clear`ing a session shows on the board.

**Files:**

- Create: `bee-core/src/commonMain/kotlin/us/tractat/bee/core/Presence.kt`, `bee-host/src/main/kotlin/us/tractat/bee/host/Departures.kt`, `bee-mcp/src/main/kotlin/us/tractat/bee/mcp/{BeeBridge.kt, BeeTools.kt, BoardActionSource.kt}`
- Modify: `bee-host/.../BeeHost.kt` (start `ClaimsVoter` on a `KtorRoomHost` at `/ws/claims`; its `ClaimMirror` writes `claims` and `departed`; feed `Departures`), `bee-mcp/.../Main.kt`
- Tests: `bee-mcp/src/test/kotlin/us/tractat/bee/mcp/{BeeToolsTest.kt, BridgeClaimsTest.kt}`, `bee-host/src/test/kotlin/us/tractat/bee/host/{DeparturesTest.kt, JoinSmokeTest.kt}`

**Interfaces:**

- Consumes: Task 2's `BoardState` and `SurfaceValidator`; Task 3's `BoardClient`, `BoardRelay` and `BeeHost`; Task 5's `BridgeIdentity`, `ClearControl`, `WaitTool` and `ActionSource`; Task 6's `ClaimsClient`, `ClaimsVoter` and `ClaimTable`.

- Produces:

- `class Presence(client: BoardClient, claimsNodes, clock)`: posts `PresenceEntry` every 5 s, and reads `live()` through `EphemeralMapTracker` with a 30 s TTL.

- `class Departures(events: Flow<PartitionEvent>, presence: () -> List<PresenceEntry>, depart: suspend (PeerIdentity, DepartReason) -> Unit, quietAfter: Duration = 20.minutes, clock)`. On `PeerLost(peerId)` it departs `Crashed` the agent whose **current** presence entry still names that claims node. Past `quietAfter` it departs `Quiet` any agent whose entry isn't `waiting`. It never departs humans. `BeeHost` feeds it from the claims voter's membership: a node the voter drops after its 30 s learner grace is a `PeerLost`, and each presence entry names its peer's newest claims node ([#28](https://github.com/tractat-us/bee/issues/28)'s ruling). `depart = voter::depart`.

- `class BeeBridge(client: BoardClient, claims: ClaimsClient, identity: BridgeIdentity, presence: Presence, clock)`. Before every writing tool it checks `claims.table.value.retired(identity)`. If retired, it re-identifies and refuses the write, naming the retired identity, the reason, and the claims left orphaned.

- `BoardActionSource : ActionSource` claims each candidate (`record:<dot>`) through Raft before returning it, and adds `token` and `basisComplete`. A cancellation after `Won` releases the claim with its token.

- `BeeTools.register(server, bridge)` registers the remaining tools. `bee_action_done(recordId, token, result?)` proposes `Complete`.

- **Step 1: Write the failing deterministic tests** (in-memory host pieces, virtual time, in-process SDK client).

- `BeeToolsTest`:

  - `openPutsTheSurfaceOnTheBoardForEveryone`
  - `openRefusesMalformedComponents` (Review Focus 1)
  - `clearClosesEverySurfaceAndDeletesNone`
  - `everyWritingToolSaysItIsShared`

- `BridgeClaimsTest`:

  - `waitReturnsAClickClaimedWithItsToken`
  - `twoWaitersNeverBothGetOneClick`
  - `cancelledWaitReleasesItsClaim` (Review Focus 3)
  - `doneWithAStaleTokenIsRejected`
  - `retiredBridgeRefusesWritesAndReidentifies`
  - `basisNamingAnUndeliveredDotIsIncomplete`

- `DeparturesTest`, fed a scripted `Flow<PartitionEvent>`:

  - `lostLinkRetiresTheAgentAndOrphansItsClaims`
  - `reconnectedPeerIsNotRetired`
  - `quietAgentIsRetiredAfterTwentyMinutes`
  - `waitingAgentIsNeverQuiet`
  - `humansNeverDepart`
  - `clearRetiresWithClearedThroughRaft`

- **Step 2: Write the smoke** `JoinSmokeTest.killedBridgeShowsDepartedOverRealSockets` (behind the flag).

- **Step 3: Run them, then implement until green.** `timeout 600 ./gradlew :bee-mcp:test :bee-host:test`, then the same with `-Pbee.realsocket.tests=true`.

- **Step 4: Run the real-session check, and report it as observed.** Write a temporary `--mcp-config` file and a temporary `--settings` file holding the hook, rather than editing `docs/setup.md`.

- Session 1 (`timeout 420 claude -p --mcp-config … --settings …`) opens a surface with one button and waits.

- Click the button in a browser. Session 1 answers, and session 2's `bee_board` lists the surface.

- Kill session 1, and it shows departed `Crashed`.

- **Opt-in:** `/clear` an interactive session and see `Cleared`.

If a nested session can't start, record it and continue; Iain runs it.

- [ ] **Step 5: Commit and open the PR.** Message: "Join the bridge, the board and claims". **Then stop for Iain's review** before Task 8.

**Stop condition (design call):** as in Task 1.

______________________________________________________________________

### Task 8: `Sketch` and the companion adapter (spike 5, the acceptance test)

**Files:**

- Create: `bee-notions/src/{catalog.js, sketch.js, sketch-helper.js}`, `bee-companion/src/main/kotlin/us/tractat/bee/companion/CompanionAdapter.kt`
- Modify: `bee-notions/src/renderer-host.js`, `bee-companion/src/main/kotlin/us/tractat/bee/companion/Main.kt`
- Tests: `bee-companion/src/test/kotlin/us/tractat/bee/companion/{CompanionAdapterTest.kt, SketchSurfaceTest.kt}`, `e2e/sketch.spec.mjs`

**Interfaces:**

- Consumes: `BoardClient`, `ClaimsClient`, `BoardActionSource`, `BridgeIdentity`, `BoardState` mutators (Tasks 2–7).

- Produces:

- **`Sketch`** (properties `html`, `title?`) renders `<iframe sandbox="allow-scripts" srcdoc=…>`. The srcdoc carries the inlined helper and `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:">`. The postMessage envelope follows the MCP Apps sandbox conventions (SEP-1865, JSON-RPC 2.0); read the official spec and record the method name in the PR. A click on `[data-choice]` only proposes `{choice, text}`: the page shows it in its own bar under the frame, and records it only when the viewer clicks that bar's Send (bee#60). The parent accepts a message only when `event.source === iframe.contentWindow`.

- `class CompanionAdapter(screenDir, stateDir, frameTemplate: Path, client, claims, identity, clock)` runs as `bee-companion --screen-dir … --state-dir … --frame-template <plugin>/bin/brainstorm/frame-template.html`.

- The newest `screenDir` file (by mtime) becomes the surface `companion`, holding one `Sketch`. A fragment gets the frame template's CSS; a full document is shown as-is.

- Each new screen clears `stateDir/events`.

- Each `choice` record is claimed and appended to `stateDir/events` as `{"type":"click","choice":…,"text":…,"timestamp":<epoch s>}`.

- A `waiting*.html` screen closes the surface.

- **Step 1: Write the failing tests.**

- `CompanionAdapterTest`:

  - `newestScreenBecomesTheSketch`
  - `fragmentIsFramedAndFullDocumentIsNot`
  - `clickIsWrittenInTheCompanionsEventFormat`: with an injected clock, `{"type":"click","choice":"a","text":"Option A","timestamp":1706000101}`.
  - `newScreenClearsEvents`
  - `waitingScreenClosesTheSurface`
  - `adapterRestartLosesNothing`

- `SketchSurfaceTest.oversizedSketchIsRefusedWithItsLimit` (Review Focus 5).

- `e2e/sketch.spec.mjs`:

  - `a sketch click lands as a record with a basis`: actor, component and `basis.surfaceDots` are set.
  - `click inside the sketch reaches the events file`
  - `sketch cannot reach the parent DOM or the network`
  - `postMessage from another frame is ignored`

- **Step 2: Run them, then implement until green.** `timeout 600 ./gradlew :bee-companion:test`, then `cd e2e && timeout 600 npx playwright test sketch.spec.mjs`.

- **Step 3: Run the acceptance test, and report it as observed.**

- Point a real `brainstorming` session at `bee-companion`'s directories instead of the companion server.

- Push two screens and click. The agent reads the click from `state_dir/events`.

- Push `waiting.html`.

- Restart the host, and the board is unchanged.

If a nested session can't start, record it and continue; Iain runs it.

- [ ] **Step 4: Commit and open the PR.** Message: "Spike 5: Sketch and the brainstorming companion on bee".

______________________________________________________________________

### Task 9: Durability of the board (spike 6)

Claims durability landed in Task 6. This task covers the board, on the host and in browsers, and proves the generic `Quilter`↔`DurableStore` adapter.

**Files:**

- Create: `bee-core/src/commonMain/kotlin/us/tractat/bee/core/persist/QuiltedPersistence.kt` (imports only kuilt and kotlinx, no `us.tractat.bee` types), `bee-web/src/wasmJsMain/kotlin/us/tractat/bee/web/BrowserStore.kt`
- Modify: `bee-host/.../BeeHost.kt`, `bee-web/.../Main.kt`
- Tests: `bee-core/src/commonTest/.../persist/QuiltedPersistenceTest.kt`, `bee-host/src/test/kotlin/us/tractat/bee/host/HostRestartTest.kt`, `e2e/durability.spec.mjs`

**Interfaces:**

- Consumes: `BoardClient(initial = …)`, `BoardState.serializer()`, `withReplica`, `BeeHost`, `ClaimsVoter` (Tasks 2, 3, 6, 7).
- Produces: `class QuiltedPersistence<S : Quilted<S>>(store: DurableStore, key: StoreKey, serializer: KSerializer<S>, clock)` with two calls:
- `suspend fun load(restore: (S) -> S): S?`
- `fun saveOnChange(state: StateFlow<S>, scope, prepare: (S) -> S = { it })`, debounced to at most every 2 s and skipped when `prepare(state)` is unchanged.

The board uses `StoreKey("board/state")`, `restore = { it.withReplica(freshAuthor) }` and a `prepare` that strips presence. The host's store is `FileChannelDurableStore(File(dataDir, "board"))`, and a browser's is `IndexedDbDurableStore.open("bee-board")`, saved only by the tab holding the Web Lock `bee-board-store` (A7). Add `QuiltedPersistence` to the tracking issue's candidate list.

- **Step 1: Write the failing tests.**
- `QuiltedPersistenceTest` (`InMemoryDurableStore`, a kuilt `GSet`): `loadRestoresAMutableStateUnderTheNewAuthor`, `preparedFieldsAreNotSaved`, `unchangedStateIsNotRewritten`.
- `HostRestartTest` (in-memory stores, the same instance across the restart): `surfacesRecordsAndClaimsSurviveARestart`, `restartedHostMintsUnderAFreshAuthor`.
- `e2e/durability.spec.mjs`:
  - `reload while the host is down shows the cached board`: stop the host process; don't use `setOffline`.
  - `closing every browser loses nothing`
  - `only one tab writes the snapshot`
- **Step 2: Run them, then implement until green.** `timeout 600 ./gradlew :bee-core:jvmTest :bee-host:test`, then `cd e2e && timeout 600 npx playwright test durability.spec.mjs`.
- **Step 3: Commit and open the PR.** Message: "Spike 6: the board survives host restarts and closed browsers".

______________________________________________________________________

### Task 10: A second screen (spike 7)

**Iain's ruling (see "Spec gap for Iain"): a key, with Bluetooth-style passkey pairing.** Only human peers admit a device. A device with no key gets no board state, no presence and no claims until a human admits it.

**Flow.**

1. A browser loads the page from a non-loopback address with no key. It opens a pairing with a `POST` to `/pair`, an endpoint that carries no board or claims traffic, and then polls `/pair/poll` with the token it got back. (#42 moved pairing from `/ws/pair` to an event stream at `/pair`; #210 and #211 replaced the stream with polling.) The host mints a 6-digit code and a pairing id and sends both to that browser only. The browser shows the code full screen ("Enter this code on the board to join").
1. The host writes a join-request surface onto the board: "A device wants to join: enter the code it shows". The code itself is never written to the board. The surface holds the pairing id, the device's address and its `?screen=` name if it gave one.
1. A participant types the code into that surface. The input arrives as an action record (§6.2). The host checks four things: the actor is a human peer (A7), the code matches, the pairing hasn't expired (2 minutes), and fewer than 3 wrong entries have been made. On a match it mints a key, answers it to the browser's next poll, and closes the request surface with "admitted by ". A wrong entry updates the attempt count, and a third voids the pairing. An agent's entry is refused and noted on the surface.
1. The browser stores the key in `sessionStorage` and dials `/ws/board` and `/ws/claims` with it. With `--lan` on, both endpoints refuse a non-loopback connection that has no valid key, and so does `?k=` with a wrong key. Keys live in host memory, so a host restart re-pairs every LAN device.

**Files:**

- Modify: `bee-host/.../{HostConfig.kt, Main.kt}` (`--lan` binds `0.0.0.0` and prints `http://tractatus.local:8233/` plus a `?k=` join link), `bee-host/.../BeeHost.kt` (key check on `/ws/board` and `/ws/claims` for non-loopback peers)

- Create: `bee-host/.../Pairing.kt` (pure state machine: mint, attempt, expire, admit; takes a `Clock` and a code source), `bee-host/.../PairingEndpoint.kt` (`/pair`), `bee-host/.../JoinRequestSurface.kt` (writes and closes the board surface, handles its action records)

- Modify: `bee-web/.../Main.kt` (WebSocket URLs derive from `location`; with no key on a non-loopback origin, show the pairing screen; `?screen=<name>` makes a no-claim human peer, A7), `docs/setup.md`

- Tests: `bee-host/src/test/kotlin/us/tractat/bee/host/{HostConfigTest.kt, PairingTest.kt, LanKeyGateTest.kt}`, `e2e/second-screen.spec.mjs`

- **Step 1: Write the failing tests.**

- `HostConfigTest.defaultBindsLoopbackOnly`, `lanFlagBindsAllInterfaces`.

- `PairingTest`, deterministic with an injected clock and code source:

  - `matchingCodeFromHumanAdmits`
  - `agentEntryNeverAdmits`
  - `codeExpiresAfterTwoMinutes`
  - `thirdWrongEntryVoidsPairing`
  - `admittedKeyIsSingleDevice`: a second pairing mints a different key.

- `LanKeyGateTest` (in-memory, deterministic): `nonLoopbackWithoutKeyIsRefusedOnBoardAndClaims`, `loopbackNeedsNoKey`, `urlKeyAdmits`, `wrongUrlKeyIsRefused`.

- `second-screen.spec.mjs` (smoke job):

  - `keyless remote page shows a code and no board`
  - `typing the code on the laptop admits the remote page`
  - `page reached by a non-localhost name connects back to that name`
  - `a screen peer never proposes a claim`

- **Step 2: Run them, then implement until green.** `timeout 600 ./gradlew :bee-host:test`, then `cd e2e && timeout 600 npx playwright test second-screen.spec.mjs`.

- **Step 3: Run the hand check, and report it as observed.** It's opt-in, and Iain runs it if the worker can't reach a second device. Another device opens `http://tractatus.local:8233/?screen=room` with nothing installed. It shows a code, and Iain types the code on the laptop's board. The device then sees the board, and a vote cast there shows on the laptop.

- **Step 4: Commit and open the PR.** Message: "Spike 7: a second screen pairs and joins over the local network".

______________________________________________________________________

### Task 11: Working-day surfaces: `Board`, `Poll`, orphan decisions, history strip

This covers §5.6's other components, §6.4's decision surface and §6.1's history strip. It's what §3's secondary test needs.

**Before a `Reassign` can name an agent, the bridge needs provenance (bee#31).** The bridge's sweep (spec §5.2) gives back every claim held as its identity that no caller of it received. A `Reassign` of an orphan to an agent session looks just like that to its bridge: held as it, received by nobody. So the sweep would release it at once. This task must tell a reassigned claim from a stranded late win, for example from the `Reassign` entry the table folds, and hand the reassigned claim to the session, not give it back. Add a test that a reassigned claim survives its bridge's sweep.

**Files:**

- Create: `bee-notions/src/{board.js, poll.js}`, `bee-web/src/wasmJsMain/kotlin/us/tractat/bee/web/HistoryStrip.kt`, `bee-host/src/main/kotlin/us/tractat/bee/host/OrphanDecisions.kt`
- Modify: `bee-notions/src/catalog.js`, `bee-web/.../BoardDriver.kt` (adds a browser `ClaimsClient` for decisions), `bee-host/.../BeeHost.kt`
- Tests: `bee-host/src/test/kotlin/us/tractat/bee/host/OrphanDecisionsTest.kt`, `e2e/notions.spec.mjs`

**Interfaces:**

- Consumes: `ClaimsClient`, `ClaimStatus`, `BoardState` mutators, `Basis`, `Presence`, `A2uiRenderer` (Tasks 1–7).

- Produces:

- **`Poll`**: `question` (a data path to an append-only array of revisions; the last one shows, and revising appends), `options` (an append-only `JsonNode.Array`; an edited option is a removal plus a new option, A1/A12), and `answers` (an object keyed by `PeerIdentity.toString()`). Answering writes `answers[<me>]` from the read register and appends a record, so one person counts once.

- **`Board`**: `columns` (an object keyed by column id: `{title, owner?}`) and `cards` (an object keyed by card id: `{label, column}`), each with an `order` array of ids (A12). A drop writes the card's `column` leaf from the read register and appends a `drop` record.

- `OrphanDecisions`: for each `Orphaned` target, the host opens `orphan-<target>`, a `Poll` with options "Reassign to ", "Release" and "Wait". The answerer's own client proposes `Reassign` or `Release`. "Wait" proposes nothing, and screen peers can't answer. The host closes the surface once the target isn't `Orphaned`, and itself proposes only `Depart`.

- `HistoryStrip`: closed surfaces, newest first, collapsed. A click runs `reopenSurface`.

- **Step 1: Write the failing tests.**

- `OrphanDecisionsTest` (deterministic): `orphanOpensOneDecisionSurface`, `answerReassignCommitsThroughRaft`, `waitLeavesTheClaimOrphaned`, `resolvedOrphanClosesItsSurface`, `hostProposesNothingButDepart`.

- `e2e/notions.spec.mjs`:

  - `twoTabsOfOnePersonCountOnce`
  - `a vote cast before an option was added says so in its basis`: `saw("/options", dot)` is false.
  - `dropping a card moves it in every browser`
  - `concurrent drops never duplicate a card`
  - `clear the board then reopen from history keeps the votes`
  - `markup in labels renders as text` (Review Focus 2): `<img src=x onerror=window.pwned=1>` as an option and as a label shows literally, and `window.pwned` stays undefined.

- **Step 2: Run them, then implement until green.** `timeout 600 ./gradlew :bee-host:test`, then `cd e2e && timeout 600 npx playwright test notions.spec.mjs`. Lit binds text with `${}` only. Never use `unsafeHTML` or `innerHTML` outside `Sketch`.

- **Step 3: Run the secondary test, and report it as observed.** Two Claude sessions and Iain share one board for a stretch. One session asks a `Poll` question and Iain answers it. One claims a card, the other sees the claim, and killing the claimant brings up the orphan decision. If a nested session can't start, record it and continue; Iain runs it.

- **Step 4: Commit and open the PR.** Message: "Notions Board and Poll, orphan decisions and the history strip".
