Skip to content

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 (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.
  2. 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.
  3. 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.
  4. 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.
  5. 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
A2 Claims are a state machine with fencing tokens, and the host owns the mirror. §6.3
A3 "Dead" is a committed Depart, and a retired identity's commands are rejected. §6.4
A4 Two endpoints, /ws/board (reconnect shape A) and /ws/claims (clusterClient), one identity rule each. §5.1, §5.4
A5 kuilt ships the stores; only the Quilter↔DurableStore adapter is missing. §5.4
A6 Build order: spike 3 splits around claims, spike 2 into three tasks. §12
A7 Browser identity: the name in localStorage, a replica per tab, one snapshot writer, ?screen= peers. §5.1
A8 The /clear channel: ~/.bee/run/<claudePid>.json and POST /retire. §5.2
A9 The A2UI pin is a spec version; v1.0 only if both packages export it. §5.3
A10 Waits are bounded: 45 s, at most 60 s. §5.2
A11 Writers validate, not the host. §5.2, §5.6
A12 Rga elements are write-once: append-only arrays, keyed editable state, components and data arrays stored whole, no path type changes. §5.3, §6.1
A13 Leaf writes start from the register just read; the projection is a total order. §5.3
A14 The driver diffs; it never applies deltas. §5.3

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

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.

  • 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 applications.
  • 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>

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 BoardClients 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.

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 /clearing 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'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"). 2. 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. 3. 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. 4. 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".