From 5bf1c5bb556b634cdabab43fa8c4745361faa544 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 08:31:53 +0000 Subject: [PATCH 1/7] docs: add master spec for Willow App Runtime Reframes the WASM-plugin discussion as a small kernel hosting typed, capability-mediated, content-addressed P2P apps. Chat becomes one app among many, the UI becomes one app among many, and workers become commodity peer hosts. Captures the agreed framing as of 2026-04-27 and seeds a directory for child specs to refine sub-systems incrementally. This is exploratory and long-horizon; nothing here is committed code. --- .../specs/2026-04-27-willow-runtime/README.md | 333 ++++++++++++++++++ 1 file changed, 333 insertions(+) create mode 100644 docs/specs/2026-04-27-willow-runtime/README.md diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md new file mode 100644 index 00000000..b54fd24b --- /dev/null +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -0,0 +1,333 @@ +# Willow App Runtime — master design + +**Date:** 2026-04-27 +**Status:** draft, exploratory — no timeline, iterations expected +**Branch:** `claude/wasm-plugin-system-WyY1p` + +## Purpose + +This folder describes a long-horizon architectural direction for Willow: +becoming a small **kernel** that hosts **typed, capability-mediated, +content-addressed P2P apps**, of which today's chat product is one. + +It is a *destination*, not a description of current state. The existing +codebase ships a chat-specific peer with a fixed `ServerState`, a Leptos web +UI bound to chat semantics, and worker binaries (`replay`, `storage`) that +materialize chat state. The architecture below treats chat as one application +among many, the UI as one app among many, and workers as commodity peer hosts +that don't know what application they're hosting. + +This is a master spec. Child specs in this directory will refine specific +sub-systems as we iterate (kernel boundaries, WIT interface design, capability +model, app SDK, distribution, chat-server migration, etc). This document is +deliberately light on implementation detail — focus is on **what we are +building and why it is the right shape**, not on which files change. + +## Reframe: from "WASM plugin system" to "P2P app runtime" + +The conversation that produced this spec started as "a WASM plugin system for +Willow." That framing is too small. A plugin system implies a host application +with a fixed feature set, optionally extensible. What we want is the inverse: +a small kernel where **the application itself is a composition of typed, +sandboxed, content-addressed components**. + +Chat is not a feature of Willow that has plugins. Chat is one app running on +Willow. Wikis are another. So is a kanban board. So is whatever someone +builds in two years. The kernel does not know what any of them are. + +That is not a plugin system. It is an **app runtime**. + +## Core idea + +Willow's kernel provides exactly what every P2P app needs and no more: + +- **Identity & signatures.** Ed25519 keypairs. Private keys live only in the + kernel; components describe events and the kernel signs. +- **Peer protocol.** iroh, gossip, blob fetch, topic membership. +- **Event/DAG primitives.** `Event { author, prev, deps, payload, sig }`, + `EventDag

` generic over opaque payload bytes, `PendingBuffer`, sync + summaries, HLC. +- **Component loader & capability arbiter.** Instantiates WASM components, + brokers every inter-component call, enforces capability declarations. +- **Narrow native imports.** DOM, network egress, persistent storage — + bound only to specific component classes that have the capability. + +Everything else is a component. The chat semantics are a component. The UI is +a component. Themes, integrations, bridges, even a future "the server has +roles" feature — components. + +A **peer** is a process running the kernel plus whatever components it has +chosen to instantiate based on what topics it has joined and what +participation modes it has elected. There is no client/server distinction in +the kernel. A laptop running a UI, a worker running headless, an MCP agent, +and a future native desktop client are all just "the kernel + a different mix +of components." + +## Three runtime profiles for components + +Different components have fundamentally different needs. The kernel +distinguishes three profiles, with very different host imports and execution +policies: + +| Profile | Determinism | Imports | Where it runs | Examples | +|---|---|---|---|---| +| **State** | **Required** — bit-identical across peers | `host.log` only | Every peer materializing the topic | chat-server-state, wiki-state, polls-state | +| **Interaction** | Not required | `host.broadcast`, `host.subscribe`, `host.kv`, `host.user-prompt`, UI app's `ui:*` | Any peer with a UI / agent host | chat-server-interaction, wiki-interaction | +| **Behavior** | Not required | + `host.http`, `host.timer`, `host.identity` (own keypair, gated) | Designated peer(s) | bridges, automod, archivers, bots | + +All three are loaded by the same kernel through the same WIT-typed interface. +The difference is *which host imports each profile is permitted to bind* and +*which fuel/time policy applies*. Determinism is enforced for state +components by the absence of any non-deterministic host import — there is +nothing to call. + +## Apps as bundles of components + +An **app** is the user-facing distribution unit. A bundle on iroh-blobs, +hash-pinned and signed by the author: + +``` +chat-server/ (the bundle) +├── manifest.toml (version, hashes, capabilities, interfaces) +├── state.wasm (deterministic; required by any materializing peer) +├── interaction.wasm (typed view + commands; loaded if peer has a UI) +├── behavior-discord-bridge.wasm (optional; loaded by peers offering this capability) +└── schema.wit (interface contract, used by tooling) +``` + +Apps can ship state-only (a pure semantics package), interaction-only +(an alternative UI for someone else's state app), state+interaction (the +common case), or any combination. A peer fetches the bundle by hash and +instantiates only the components it needs for what it intends to do. + +## UI is an app + +The Leptos web client is, in this model, **the default UI app**. It exports +a set of `ui:*` interfaces — `ui:panel`, `ui:list`, `ui:message`, `ui:form`, +`ui:menu`, etc. — that other apps' interaction components import. It is not +privileged in the kernel; it is bound to DOM imports as a capability, +shipped in-tree for convenience, but architecturally indistinguishable from +a third-party UI. + +Other UI apps are possible at different levels of effort: + +- **`willow-ui-tui`** — terminal, ratatui rendering, same WIT contract. +- **`willow-ui-mcp`** — agent host, structured-data rendering for an LLM. +- **`willow-ui-mobile-native`** — Compose / SwiftUI shell, future. +- **`willow-ui-dioxus`** — once Dioxus Blitz is mature, replaces Leptos. + +App authors target the WIT contract, not a specific UI. Their interaction +components work against any UI app that exports the interfaces they import. + +## Inter-component composition + +Components compose by importing each other's exposed interfaces, mediated by +the kernel: + +- A translation utility component imports `ui:context-menu` and `chat:message` + to add "translate this message" entries on chat surfaces. The kernel wires + the imports if the user has granted the capability. +- An emoji-picker utility exports `emoji:pick`; chat-interaction imports it + if the user has installed the picker. +- A theme-pack imports `ui:theme` and provides token overrides. + +Cross-component calls always go through the kernel. The kernel is the +capability arbiter, the call broker, and the resource-handle resolver. There +is no direct memory-shared linkage between components; every interaction is +typed, bounded, and refusable. + +## Determinism, in detail (for state components) + +State components are pure functions of their inputs. The kernel passes the +event author, the event hash, the HLC, and the event payload. The component +returns a mutated state (held in linear memory) and optionally a snapshot. + +To preserve cross-peer determinism, state components have: + +- No wall clock. HLC bytes only. +- No randomness. Hash-derived if needed. +- No network, no filesystem, no environment access. +- No threads. +- A deterministic fuel budget (instruction count, not wall time). Running out + of fuel terminates uniformly across peers. +- Spec-deterministic floats (the WASM spec pins these), with a strong + recommendation to ban them in v1 anyway to avoid review pain. + +The kernel verifies cross-peer convergence by hashing snapshots and gossiping +state hashes. Mismatches surface as bugs (or, if signed, as proof of a +malicious or buggy component). + +## Capability model + +Every component runs sandboxed by default. It can: + +- Make outbound calls only to interfaces its manifest declares as imports. +- Receive inbound calls only on interfaces its manifest declares as exports. +- Use host imports only as listed in the manifest's `capabilities` block. + +The user is the trust root for their own peer. Installing an app prompts a +capability summary the kernel can render: "this app wants to broadcast +events on topic X, store ≤ 1 MB locally, send HTTP requests to discord.com." +Granted capabilities are bound at instantiate time; they cannot escalate +later without re-prompting. + +State components have a deliberately *empty* capability surface beyond +`host.log`. There is nothing to grant; nothing to leak. + +## What stays the same about Willow + +- Event-sourced per-author Merkle DAG with prev/deps causal links. +- Identity rooted in Ed25519 signatures. +- iroh for transport (gossip + blob fetch). +- Relays remain dumb topic-bridges; they do not materialize state. +- Workers (`replay`, `storage`) remain peers, just generalized to host + arbitrary state components instead of being chat-specific. +- The dual-target (native + WASM) compilation discipline maps directly to + the runtime's two backends. +- The existing capability/permission ideas from `willow-state` generalize: + each app defines its own permission set, the kernel does not. + +## What changes about Willow + +- `willow-state` splits. The kernel half (`Event`, `EventDag

`, + `PendingBuffer`, sync, HLC) stays. The chat half (`EventKind`, + `ServerState`, `apply_event`, `required_permission`) becomes the + `chat-server` app, eventually shipped in-tree at `crates/apps/chat-server/`. +- `willow-web` becomes the default UI app, shipped in-tree at + `crates/apps/ui-leptos/`. Its bindings to chat semantics route through + the kernel and the chat-server interaction component, not through direct + Rust imports. +- `replay` and `storage` workers become generic peer hosts that load + state components for any topic they are subscribed to. +- A new top-level crate `willow-kernel` (or similar) gathers what the kernel + contains. A new `willow-app-sdk` crate is what app authors use. + +These are *consequences* of the design, not v1 work items. Migration is its +own multi-spec effort and will be planned separately. + +## ABI commitments + +We commit to **WIT-shaped semantics** as the eventual interface ABI. We have +not yet committed to a v1 implementation path. Two candidates: + +- **(A) Full WebAssembly Component Model from day one.** wit-bindgen, + wasmtime native, jco-transpiled glue + core wasm in browser. Ecosystem-aligned. + Cost: heavier toolchain, browser CM is still maturing, ~350 KB JS shim + floor in browser, no async on the browser side. +- **(B) Extism for v1, WIT-shaped subset.** Ship faster on a simpler runtime; + every component call has a WIT-expressible signature; migrate to full + Component Model when browser tooling is mature. Cost: known migration + later. Reward: faster v1, real-world component authoring before the ABI is + locked. + +Tentative lean: (B). Decision will be settled in a child spec on ABI & +runtime backends. + +## Constraints we accept + +- **All cross-component calls go through the kernel.** Runtime composition + in WASM is host-mediated; this aligns with our capability model anyway. +- **Coarse-grained interfaces only.** No tight inner-loop callbacks across + component boundaries. Interaction components return whole view models per + state change; behavior components observe and emit in batches. +- **Sync ABI at v1.** Browser jco does not support async. State components + are sync by definition; the rest fit. +- **Opaque IDs, not typed resource handles, between components.** Until + wit-bindgen unifies imported and exported resource types, components pass + string/u64 IDs and the kernel resolves them. +- **Two runtime backends in the kernel.** wasmtime native, jco-transpiled + web. Same host interface so app authors target one ABI. +- **Deterministic-by-omission for state.** No host imports = no + non-determinism. We do not implement runtime checks for nondeterminism + because the absence of imports is the proof. + +## Lineage and influences + +The design draws on a recognizable tradition of ambitious systems: + +- **Holochain** — P2P apps as deterministic state machines (DNAs) plus UIs. +- **Urbit** — personal computer as peer, deterministic state, content-addressed. +- **AT Protocol** — apps composing on a shared identity + repo protocol. +- **Spritely / OCapN** — capability-secure distributed objects. +- **WebAssembly Component Model** — the typed composition substrate. +- **Erlang / OTP** — supervised lightweight processes, message passing. +- **Slack / Discord apps** — the user-facing "install an app, grant capabilities" model. + +What is novel about Willow's combination is the marriage of (a) a +content-addressed, signature-rooted, gossip-synced DAG kernel with +(b) WASM Component Model semantics for typed composition, on (c) iroh as +the transport. None of the influences above ship all three together. + +## MVP, in spirit (not in detail) + +The smallest end-to-end demonstration that the runtime is real: + +1. The kernel can load and instantiate a WASM state component from a bundle + fetched via iroh-blobs. +2. The component applies events deterministically; multiple peers running + the same component bytes converge to the same state hash. +3. A UI app can load an interaction component for that state, project a view, + submit a command, observe the resulting state change. +4. A second app instance (different state component, different topic) + coexists on the same peer; events do not cross. +5. Capability declarations actually gate access — a component cannot import + an interface its manifest does not declare. +6. A behavior component can run on a designated peer, observe events, and + emit events that propagate to other peers. + +What demo app proves this is an open child-spec question. Candidates: a tiny +shared-counter app (~50 lines of state, ~100 lines of interaction); a +single-channel chat that doesn't reuse `ServerState`; a real-time poll. The +toy app's job is to be irrelevant to chat — proving the kernel doesn't know +about chat — while still exercising the determinism + interaction loop. + +## Child specs (planned) + +To be written incrementally. Anticipated topics, in roughly the order they +become useful: + +- **Kernel boundary** — what stays in `willow-kernel` vs becomes an app, exact + trait surface, what's privileged. +- **ABI & runtime backends** — the (A) vs (B) decision; Extism integration + details if (B); WIT-shaped contract design. +- **WIT interfaces** — `ui:*`, `state:*`, `behavior:*`, `host:*` interfaces; + versioning policy; resource/handle conventions. +- **Capability model & install UX** — manifest format, default-deny, prompts, + scoped grants. +- **Distribution, signing & versioning** — bundle format, hash chain, + signatures, manifest evolution, multi-target artifacts (native + web + transpiled). +- **App SDK ergonomics** — Rust macros, dual-build native/WASM, test harness, + scaffolding CLI. +- **Determinism enforcement** — fuel policy, cross-impl verification, + state-hash gossip. +- **State materialization on workers** — how `replay` and `storage` become + generic; bandwidth/latency tradeoffs; snapshot custody. +- **MVP demo app** — what it is, what it proves, what it doesn't have to. +- **chat-server migration** (much later) — extracting today's `ServerState` + into the `chat-server` app on top of the runtime. + +## Open questions deferred to child specs + +- v1 ABI path: full Component Model now, or Extism with WIT-shaped subset + and migrate later? +- Topic root: how is the (state-component-hash, genesis-hash) tuple pinned — + encoded in the topic ID directly, or in a `PinComponent` event? +- Behavior component identity: own keypair, granted permissions via the + state component's permission system (i.e. "bot user")? +- Cross-app authority composition: out of scope for v1, but what shape + should the v2 hooks take? +- Resource limits: per-instance fuel and memory budgets — what defaults? +- Hot reload: deferred. Component update is restart for v1. + +## Status + +This spec is exploratory and will iterate. It is the agreed framing as of +2026-04-27 between the human author and an AI brainstorming session that +included four parallel research agents (WIT toolchain maturity, browser +Component Model state, Rust UI framework survey, Bevy specifics). The +decision to capture this as a runtime — not a plugin system — was made late +in that conversation and is the load-bearing reframing. + +Nothing here is committed code. The first concrete step is whichever child +spec we write next. From 8ffe3f580efd47a2881040862f21721dc66bdebb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 08:44:45 +0000 Subject: [PATCH 2/7] docs(runtime): apply pass-1 review fixes Pass-1 review surfaced ten major coherence issues. All applied: - Split state component into apply (deterministic) and propose (non-deterministic, originating peer only); update profile table. - Add "Crypto and key custody" section placing keys, sealing, MLS on the kernel side via host.seal/host.open/host.mls bound to opaque handles. Components hold handles; kernel custodies bytes. - Add "Runtime and actors" section: each component instance is owned by exactly one actor; runtime is a typed-sandboxing layer above the existing concurrency model, not a replacement. - Soften "UI is an app": the default UI app is privileged with a broad platform surface; ui:* is an interaction contract, not a portable substrate. - Tighten ABI option B: Extism is not a WIT subset; cross-component composition in v1 is kernel-brokered RPC by opaque ID; migration to Component Model is a real refactor for app authors. - Add "worker trust model shifts" paragraph: workers move from trusted-Rust to untrusted-WASM execution host; DoS, fuel, fair-share are load-bearing. - Acknowledge per-app permission system delegates pre-check to app code; a precise responsibility shifts to authors and chat-server migration must address it directly. - Promote behavior identity from open question to constraint: kernel-custodied keypair, app-defined permission grants. Weaken MVP item 6 to "observe and log" only; emit-events is post-MVP. - Soften view-model coarse-grain claim: per-surface, version-tagged, paged for large lists; diff strategy for child spec. - Fix dual-target compilation framing: kernel keeps dual-target discipline; app code is built once to wasm. Also adds three new child specs to the planned list (worker as untrusted-WASM host, crypto boundaries, actor coexistence). --- .../specs/2026-04-27-willow-runtime/README.md | 237 +++++++++++++++--- 1 file changed, 196 insertions(+), 41 deletions(-) diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md index b54fd24b..5fd6855d 100644 --- a/docs/specs/2026-04-27-willow-runtime/README.md +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -63,7 +63,7 @@ the kernel. A laptop running a UI, a worker running headless, an MCP agent, and a future native desktop client are all just "the kernel + a different mix of components." -## Three runtime profiles for components +## Runtime profiles for components Different components have fundamentally different needs. The kernel distinguishes three profiles, with very different host imports and execution @@ -71,15 +71,20 @@ policies: | Profile | Determinism | Imports | Where it runs | Examples | |---|---|---|---|---| -| **State** | **Required** — bit-identical across peers | `host.log` only | Every peer materializing the topic | chat-server-state, wiki-state, polls-state | +| **State / `apply`** | **Required** — bit-identical across peers | `host.log` only | Every peer materializing the topic | chat-server-state apply, wiki-state apply | +| **State / `propose`** | Not required (runs once, on the authoring peer) | `host.hlc`, `host.random`, `host.seal` (capability-gated), `host.log` | The peer that originates the event | chat-server-state propose | | **Interaction** | Not required | `host.broadcast`, `host.subscribe`, `host.kv`, `host.user-prompt`, UI app's `ui:*` | Any peer with a UI / agent host | chat-server-interaction, wiki-interaction | | **Behavior** | Not required | + `host.http`, `host.timer`, `host.identity` (own keypair, gated) | Designated peer(s) | bridges, automod, archivers, bots | -All three are loaded by the same kernel through the same WIT-typed interface. +All four are loaded by the same kernel through the same WIT-typed interface. The difference is *which host imports each profile is permitted to bind* and -*which fuel/time policy applies*. Determinism is enforced for state -components by the absence of any non-deterministic host import — there is -nothing to call. +*which fuel/time policy applies*. State components have two entry points: +**`apply`** runs everywhere, deterministically, with no non-deterministic +imports; **`propose`** runs only on the originating peer to construct an +event payload (it is allowed to consult the clock, generate randomness, and +seal content), after which the kernel signs and broadcasts. Determinism is +enforced on the `apply` path by the absence of any non-deterministic host +import — there is nothing to call. ## Apps as bundles of components @@ -104,20 +109,39 @@ instantiates only the components it needs for what it intends to do. The Leptos web client is, in this model, **the default UI app**. It exports a set of `ui:*` interfaces — `ui:panel`, `ui:list`, `ui:message`, `ui:form`, -`ui:menu`, etc. — that other apps' interaction components import. It is not -privileged in the kernel; it is bound to DOM imports as a capability, -shipped in-tree for convenience, but architecturally indistinguishable from -a third-party UI. - -Other UI apps are possible at different levels of effort: - -- **`willow-ui-tui`** — terminal, ratatui rendering, same WIT contract. +`ui:menu`, etc. — that other apps' interaction components import. + +The honest framing: a real UI on any platform requires a broad and unstable +capability surface (DOM + focus/IME, clipboard, file pickers, navigation, +viewport/media queries, push, IndexedDB, service workers, drag-and-drop on +web; the equivalent set on each native platform). The kernel does not try +to abstract that surface. **The default UI app is privileged to bind a +broad, browser-shaped capability surface**; it is shipped in-tree as one +app, but it is not architecturally identical to a third-party +interaction-only utility — its capability set is much larger, and it is +trusted by the user accordingly. + +The `ui:*` WIT interfaces are therefore **an interaction contract for +app-to-UI integration**, not a portable UI substrate. They define how an +interaction component declares the views it wants rendered, the commands +it accepts, and the contextual integration points it offers — not how +those views are painted. Each UI app implements the contract in its own +idiom; reusing one set of interaction components across UIs is the goal, +but each UI app is a substantial standalone project, not a recompile. + +Plausible UI apps over time: + +- **`willow-ui-tui`** — terminal, ratatui rendering, the chat-shaped + subset of `ui:*`. - **`willow-ui-mcp`** — agent host, structured-data rendering for an LLM. -- **`willow-ui-mobile-native`** — Compose / SwiftUI shell, future. -- **`willow-ui-dioxus`** — once Dioxus Blitz is mature, replaces Leptos. +- **`willow-ui-mobile-native`** — Compose / SwiftUI shell, far-future. +- **`willow-ui-dioxus`** — once Dioxus Blitz is mature, candidate + replacement for Leptos. -App authors target the WIT contract, not a specific UI. Their interaction -components work against any UI app that exports the interfaces they import. +App authors target the WIT contract. Their interaction components work +against any UI app that exports the interfaces they import. UI apps that +do not export an interface (e.g. a TUI without `ui:rich-card`) cause +graceful degradation, not breakage. ## Inter-component composition @@ -136,13 +160,21 @@ capability arbiter, the call broker, and the resource-handle resolver. There is no direct memory-shared linkage between components; every interaction is typed, bounded, and refusable. -## Determinism, in detail (for state components) +## Determinism, in detail (for state-`apply`) -State components are pure functions of their inputs. The kernel passes the -event author, the event hash, the HLC, and the event payload. The component -returns a mutated state (held in linear memory) and optionally a snapshot. +The deterministic constraint applies only to the `apply` entry point of a +state component — the function every peer runs against every event. The +`propose` entry point runs once on the originating peer and is intentionally +non-deterministic; its output is an event payload that the kernel then signs +and broadcasts, after which all peers (including the originator) replay +through `apply`. -To preserve cross-peer determinism, state components have: +State-`apply` is a pure function of its inputs. The kernel passes the event +author, the event hash, the HLC encoded in the event, and the event payload. +The component mutates state held in its linear memory and optionally emits +a snapshot. + +To preserve cross-peer determinism, state-`apply` has: - No wall clock. HLC bytes only. - No randomness. Hash-derived if needed. @@ -157,6 +189,44 @@ The kernel verifies cross-peer convergence by hashing snapshots and gossiping state hashes. Mismatches surface as bugs (or, if signed, as proof of a malicious or buggy component). +## Crypto and key custody + +Encryption is load-bearing today (`willow-crypto`, `seal_content`, +the epoch-rotation spec, the deferred MLS-over-Willow spec) and the runtime +has to place it explicitly, not silently. The chosen split: + +- **Private signing keys live only in the kernel.** No component sees them. + Components describe events; the kernel signs. +- **Symmetric channel/group keys, ratchets, and MLS group state are kernel-custodied as well**, but on behalf of an app instance. Apps refer to them by app-declared key handles (opaque IDs). +- **The kernel exposes a typed `host.seal` / `host.open` capability** + bound to a key handle. State-`propose` may call `host.seal` to produce + ciphertext; interaction components may call `host.open` to decrypt for + display. State-`apply` does not see plaintext — it sees only sealed + payloads it stores or forwards. +- **Key generation and rotation events are app-defined.** A chat-server-style + app defines its own `RotateChannelKeyV2`-equivalent events; the state-`apply` + function records the new key handle in materialized state; the kernel + binds the new handle to the underlying key material it just generated on + behalf of the propose call. The kernel does *not* know what + "channel" or "epoch" mean; it only knows about handles and the + permissions to seal/open under them. +- **MLS group state**, when we adopt MLS, lives on the kernel side of the + boundary as a typed capability surface (`host.mls`) bound to an app's + group handle. The app emits MLS Welcome / Commit / Application events + through ordinary state propose; the kernel-side MLS engine processes + them under the requesting peer's identity. + +The principle is consistent: **secrets do not enter component memory in +their raw form**. Components hold handles; the kernel custodies bytes. An +app-defined permission that gates `Rotate*` events is an app-level pre-check +in state-`apply` (see the capability model section); the kernel only enforces +that the seal/open call presented an authorized handle. + +The exact `host.seal` / `host.open` / `host.mls` interface, key-derivation +strategy, and persistence story belong in a child spec dedicated to crypto +boundaries. What this section commits to is the placement: **encryption is +a kernel capability bound to opaque key handles**, not an app concern. + ## Capability model Every component runs sandboxed by default. It can: @@ -171,8 +241,11 @@ events on topic X, store ≤ 1 MB locally, send HTTP requests to discord.com." Granted capabilities are bound at instantiate time; they cannot escalate later without re-prompting. -State components have a deliberately *empty* capability surface beyond -`host.log`. There is nothing to grant; nothing to leak. +State-`apply` has a deliberately *empty* capability surface beyond +`host.log`. There is nothing to grant; nothing to leak. State-`propose` +has the small set listed in the runtime-profiles table (`host.hlc`, +`host.random`, capability-gated `host.seal`); these are bound only when a +peer is actually originating an event, never during replay. ## What stays the same about Willow @@ -182,10 +255,53 @@ State components have a deliberately *empty* capability surface beyond - Relays remain dumb topic-bridges; they do not materialize state. - Workers (`replay`, `storage`) remain peers, just generalized to host arbitrary state components instead of being chat-specific. -- The dual-target (native + WASM) compilation discipline maps directly to - the runtime's two backends. -- The existing capability/permission ideas from `willow-state` generalize: - each app defines its own permission set, the kernel does not. +- The dual-target (native + WASM) compilation discipline survives at the + *kernel* layer — the kernel itself compiles to both targets, the native + build using wasmtime and the web build using a jco-transpiled host. For + *application code*, the discipline is replaced: an app component is + built once to wasm and is loaded by whichever kernel a peer is running. +- The existing capability/permission ideas from `willow-state` generalize, + with one new responsibility: each app defines its own permission set, but + also supplies the *pre-check* code that gates event creation. Today's + centralized `required_permission()` table runs in trusted in-process Rust; + under the runtime the kernel calls into the app's state component to ask + "may this author emit this event under the current state?" before signing. + This shifts a precise, audit-friendly responsibility onto app authors; + bugs in app pre-check code admit invalid events that other peers will + reject at apply, accumulating in the DAG as the existing authority spec + warns against. The chat-server-migration spec must address this directly, + not defer it. + +## Runtime and actors + +Willow's existing actor framework (`willow-actor`) and the +`docs/specs/2026-04-26-state-management-model-design.md` discipline — all +shared mutable state in lib crates lives inside an actor — do not go away. +The runtime sits *underneath* that model, not in place of it. + +The intended mapping: + +- **Each component instance is owned by exactly one actor in the host.** + The actor's mailbox serializes calls into the component's WASM instance. + Component instances are the unit of *typed sandboxing*; actors remain the + unit of *concurrency*. +- The kernel itself is composed of actors: a loader actor, a per-topic + state-materialization actor (which owns one state component instance and + calls `apply` on each event), interaction actors per active interaction + component, behavior actors per behavior instance. +- Lock-vs-actor decisions in *kernel code* still follow the existing + decision tree. Components never see locks; they see only the actor's + mailbox semantics, surfaced as synchronous WIT calls into and out of the + instance. +- Persistence is owned by the host's actors, not by components. A state + component returns updated state in its linear memory; the kernel-side + materialization actor decides when to snapshot, when to write to the + storage backend, and how to coordinate with sync. + +This means: the actor framework is one of the things that stays. The +runtime adds a layer above it for typed sandboxing, content-addressed +distribution, and capability arbitration. It does not replace the +host-side concurrency model. ## What changes about Willow @@ -199,6 +315,13 @@ State components have a deliberately *empty* capability surface beyond Rust imports. - `replay` and `storage` workers become generic peer hosts that load state components for any topic they are subscribed to. +- **Worker trust model shifts.** Today's workers run trusted in-tree Rust; + under the runtime, a worker subscribed to N topics may be executing N + distinct, third-party-authored, attacker-influenceable WASM state + components simultaneously. DoS resistance, fuel scheduling, per-instance + memory caps, fair-share between topics, and operator-level deny-lists are + load-bearing operational concerns, not bandwidth/latency tuning. + Operators must be able to constrain which apps a worker will host. - A new top-level crate `willow-kernel` (or similar) gathers what the kernel contains. A new `willow-app-sdk` crate is what app authors use. @@ -214,24 +337,47 @@ not yet committed to a v1 implementation path. Two candidates: wasmtime native, jco-transpiled glue + core wasm in browser. Ecosystem-aligned. Cost: heavier toolchain, browser CM is still maturing, ~350 KB JS shim floor in browser, no async on the browser side. -- **(B) Extism for v1, WIT-shaped subset.** Ship faster on a simpler runtime; - every component call has a WIT-expressible signature; migrate to full - Component Model when browser tooling is mature. Cost: known migration - later. Reward: faster v1, real-world component authoring before the ABI is - locked. +- **(B) Extism for v1, WIT-shaped where possible.** Ship faster on a simpler + runtime. Every *host-call* signature is chosen to be WIT-expressible + (records, variants, lists, strings, integers). Cross-component composition + in v1 is **kernel-brokered RPC by opaque ID only** — Extism has no notion + of imported/exported resource handles, borrowed lifetimes, world + composition, or futures/streams, and we do not pretend it does. Migration + to full Component Model later is a real refactor for app authors (resource + handles replace ID lookups, imported interfaces replace kernel-broker calls, + borrows replace clone-and-pass), not a regenerate-bindings event. + +The migration story is therefore: (a) *host-side* signatures we design today +will translate mechanically; (b) *cross-component composition* will be +rewritten when we move to Component Model; (c) any v1 plugin author should +expect to update their code at the migration boundary, but not redesign their +state machine or domain model. Tentative lean: (B). Decision will be settled in a child spec on ABI & -runtime backends. +runtime backends, including an explicit table of which v1 conveniences will +require app-author refactor at migration time. ## Constraints we accept - **All cross-component calls go through the kernel.** Runtime composition in WASM is host-mediated; this aligns with our capability model anyway. -- **Coarse-grained interfaces only.** No tight inner-loop callbacks across - component boundaries. Interaction components return whole view models per - state change; behavior components observe and emit in batches. +- **Coarse-grained interfaces.** No tight inner-loop callbacks across + component boundaries. Interaction components return view models in + per-surface units (e.g. one channel timeline, one member list, one + composer state) — not per-element callbacks, but also not "the whole + app's view." Returns are version-tagged so the host can skip + recomposition on no-op state changes; large lists (timelines, member + rosters) are paged. Behavior components observe and emit in batches. + Exact diffing/paging strategy is for the WIT-interfaces child spec. - **Sync ABI at v1.** Browser jco does not support async. State components are sync by definition; the rest fit. +- **Behavior components run under their own kernel-custodied identity.** + A behavior component instance is associated with an Ed25519 keypair + custodied by the kernel; events authored through `host.broadcast` are + signed under that identity, not the user's. Permission to act under that + identity is granted in-band by app-defined events (the "bot user" + pattern), enforced by the app's own state-`apply` pre-check. Behavior + components never see private keys. - **Opaque IDs, not typed resource handles, between components.** Until wit-bindgen unifies imported and exported resource types, components pass string/u64 IDs and the kernel resolves them. @@ -273,7 +419,9 @@ The smallest end-to-end demonstration that the runtime is real: 5. Capability declarations actually gate access — a component cannot import an interface its manifest does not declare. 6. A behavior component can run on a designated peer, observe events, and - emit events that propagate to other peers. + log them. Emitting events under a kernel-custodied behavior identity is + the next milestone after MVP, blocked on the capability model + identity + custody child specs landing first. What demo app proves this is an open child-spec question. Candidates: a tiny shared-counter app (~50 lines of state, ~100 lines of interaction); a @@ -303,6 +451,15 @@ become useful: state-hash gossip. - **State materialization on workers** — how `replay` and `storage` become generic; bandwidth/latency tradeoffs; snapshot custody. +- **Worker as untrusted-WASM execution host** — fuel scheduling, fair-share + across topics, per-instance resource caps, operator deny-lists, abuse + surfaces. Distinct from materialization, which is about correctness; + this is about operating workers safely at scale. +- **Crypto and key custody boundaries** — the `host.seal` / `host.open` / + `host.mls` interface, key-derivation strategy, persistence story, app + ↔ kernel responsibility split for rotation. +- **Runtime and actor coexistence** — exact actor topology, mailbox + semantics across the WIT boundary, lock/actor decision tree updates. - **MVP demo app** — what it is, what it proves, what it doesn't have to. - **chat-server migration** (much later) — extracting today's `ServerState` into the `chat-server` app on top of the runtime. @@ -313,8 +470,6 @@ become useful: and migrate later? - Topic root: how is the (state-component-hash, genesis-hash) tuple pinned — encoded in the topic ID directly, or in a `PinComponent` event? -- Behavior component identity: own keypair, granted permissions via the - state component's permission system (i.e. "bot user")? - Cross-app authority composition: out of scope for v1, but what shape should the v2 hooks take? - Resource limits: per-instance fuel and memory budgets — what defaults? From bcfae20f6bdc69b5b6e1cdee28eab5f84885fe83 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 08:53:21 +0000 Subject: [PATCH 3/7] docs(runtime): apply pass-2 review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass-2 review surfaced eight major issues. Three must-fix were soundness-level: state-apply needs deterministic crypto helpers (the absence-of-imports proof was too strong); behavior identity needed a concrete per-(peer, instance) shape; key-distribution apply had no import path to install received keys. All applied: - Replace "no imports = proof" with "imports must be pure functions of inputs." Enumerate the deterministic helper set apply may import: verify-signature, verify-seal-envelope, hash, install-key, now-hlc-from-event. Update profile table and capability model. - Fix the crypto section's contradiction by adding the apply-side helpers (verify-seal-envelope, install-key) to the typed crypto host imports list. - Commit to behavior identity being per-(peer, behavior-instance) with no kernel-mediated cross-peer migration. Apps that need stable bot identity define an in-band registration event mapping the per-peer keypair to an app-level role. - Add the constraint that an app's pre-check and apply MUST share the authority decision (typically the same WIT-exposed function) — drift is a soundness bug the kernel cannot detect. Add open question for pre-check failure-mode handling on the originating peer. - Tighten "actor owns one component instance" to make the per-peer scope explicit; the runtime makes no claim about cross-peer actor topology. - Soften kernel dual-target claim: subsystems like MLS engine and persistent key storage may need platform-specific backends behind a stable trait; cataloguing belongs in the crypto child spec. - Reconcile relay role with topic-ID rotation: app-defined rotations must publish post-rotation IDs on a stable, public discovery channel the relay can subscribe to; relays remain transport-only. - Soften "What changes about Willow" framing to responsibility-level rather than file-tree level, matching the spec's stated scope. --- .../specs/2026-04-27-willow-runtime/README.md | 187 ++++++++++++------ 1 file changed, 127 insertions(+), 60 deletions(-) diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md index 5fd6855d..69c87d9f 100644 --- a/docs/specs/2026-04-27-willow-runtime/README.md +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -71,7 +71,7 @@ policies: | Profile | Determinism | Imports | Where it runs | Examples | |---|---|---|---|---| -| **State / `apply`** | **Required** — bit-identical across peers | `host.log` only | Every peer materializing the topic | chat-server-state apply, wiki-state apply | +| **State / `apply`** | **Required** — bit-identical across peers | `host.log`, plus the **deterministic helper set**: signature verification, seal-envelope authenticity check, content hashing, and key installation from a sealed key-distribution payload | Every peer materializing the topic | chat-server-state apply, wiki-state apply | | **State / `propose`** | Not required (runs once, on the authoring peer) | `host.hlc`, `host.random`, `host.seal` (capability-gated), `host.log` | The peer that originates the event | chat-server-state propose | | **Interaction** | Not required | `host.broadcast`, `host.subscribe`, `host.kv`, `host.user-prompt`, UI app's `ui:*` | Any peer with a UI / agent host | chat-server-interaction, wiki-interaction | | **Behavior** | Not required | + `host.http`, `host.timer`, `host.identity` (own keypair, gated) | Designated peer(s) | bridges, automod, archivers, bots | @@ -169,22 +169,50 @@ non-deterministic; its output is an event payload that the kernel then signs and broadcasts, after which all peers (including the originator) replay through `apply`. -State-`apply` is a pure function of its inputs. The kernel passes the event -author, the event hash, the HLC encoded in the event, and the event payload. -The component mutates state held in its linear memory and optionally emits -a snapshot. - -To preserve cross-peer determinism, state-`apply` has: - -- No wall clock. HLC bytes only. -- No randomness. Hash-derived if needed. -- No network, no filesystem, no environment access. +State-`apply` is a pure function of its inputs *and the kernel-side +deterministic helper set*. The kernel passes the event author, the event +hash, the HLC encoded in the event, and the event payload. The component +mutates state held in its linear memory and optionally emits a snapshot. + +The rule is **`apply` may import only host functions whose output is a +pure function of their inputs** — not "no imports." Useful kernel-side +helpers `apply` legitimately needs are deterministic by construction: + +- `host.verify-signature(pubkey, msg, sig)` — Ed25519 verification. +- `host.verify-seal-envelope(envelope, pubkey)` — AEAD-tag check on a + sealed payload, without revealing plaintext. +- `host.hash(bytes)` — blake3 / sha256. +- `host.install-key(handle, sealed)` — accept a sealed key-distribution + blob addressed to *this peer*, unwrap it with the kernel-custodied + recipient key, install the resulting symmetric key under the + app-supplied opaque handle. The unwrapped bytes never enter component + memory; the function returns success/failure and the handle namespace + the app chose. Output depends on the local peer's private key, but + the *failure-or-handle-bound-success* outcome is deterministic from + the app's point of view because if the blob does not address the + local peer the call deterministically fails — no peer-specific bytes + flow back. +- `host.now-hlc-from-event(event)` — extract HLC bytes from the event + envelope (no wall clock). + +What `apply` continues to be denied: + +- No wall clock. No randomness. No network, no filesystem, no environment. - No threads. -- A deterministic fuel budget (instruction count, not wall time). Running out - of fuel terminates uniformly across peers. +- A deterministic fuel budget (instruction count, not wall time). Running + out of fuel terminates uniformly across peers. - Spec-deterministic floats (the WASM spec pins these), with a strong recommendation to ban them in v1 anyway to avoid review pain. +The determinism proof is therefore: **every host import bound to `apply` +returns a pure function of its inputs given the event payload alone**. +`host.install-key` is the only edge case — it is deterministic *modulo +local key custody*, and behaves uniformly (succeeds, or deterministically +fails with no observable difference) on peers that are or are not the +intended recipient. The exact list of deterministic helpers belongs in +the crypto-and-key-custody child spec; the master spec commits to the +shape. + The kernel verifies cross-peer convergence by hashing snapshots and gossiping state hashes. Mismatches surface as bugs (or, if signed, as proof of a malicious or buggy component). @@ -198,11 +226,17 @@ has to place it explicitly, not silently. The chosen split: - **Private signing keys live only in the kernel.** No component sees them. Components describe events; the kernel signs. - **Symmetric channel/group keys, ratchets, and MLS group state are kernel-custodied as well**, but on behalf of an app instance. Apps refer to them by app-declared key handles (opaque IDs). -- **The kernel exposes a typed `host.seal` / `host.open` capability** - bound to a key handle. State-`propose` may call `host.seal` to produce - ciphertext; interaction components may call `host.open` to decrypt for - display. State-`apply` does not see plaintext — it sees only sealed - payloads it stores or forwards. +- **The kernel exposes typed crypto host imports** bound to key handles: + - `host.seal(handle, plaintext)` on state-`propose` and behavior + profiles only — produces ciphertext under the named key. + - `host.open(handle, ciphertext)` on interaction profile only — + decrypts for display. + - `host.verify-seal-envelope(envelope, signer-pubkey)` and + `host.install-key(handle, sealed-distribution-blob)` on state-`apply` + — deterministic helpers (see "Determinism, in detail"). State-`apply` + never sees plaintext message content; it sees sealed payloads, can + verify their authenticity, and can install keys distributed in the + DAG into the kernel's custody under app-named handles. - **Key generation and rotation events are app-defined.** A chat-server-style app defines its own `RotateChannelKeyV2`-equivalent events; the state-`apply` function records the new key handle in materialized state; the kernel @@ -241,8 +275,9 @@ events on topic X, store ≤ 1 MB locally, send HTTP requests to discord.com." Granted capabilities are bound at instantiate time; they cannot escalate later without re-prompting. -State-`apply` has a deliberately *empty* capability surface beyond -`host.log`. There is nothing to grant; nothing to leak. State-`propose` +State-`apply` is bound only to the deterministic helper set +(see "Determinism, in detail" for the full list). There is no +non-deterministic capability to grant; nothing to leak. State-`propose` has the small set listed in the runtime-profiles table (`host.hlc`, `host.random`, capability-gated `host.seal`); these are bound only when a peer is actually originating an event, never during replay. @@ -255,11 +290,17 @@ peer is actually originating an event, never during replay. - Relays remain dumb topic-bridges; they do not materialize state. - Workers (`replay`, `storage`) remain peers, just generalized to host arbitrary state components instead of being chat-specific. -- The dual-target (native + WASM) compilation discipline survives at the - *kernel* layer — the kernel itself compiles to both targets, the native - build using wasmtime and the web build using a jco-transpiled host. For - *application code*, the discipline is replaced: an app component is - built once to wasm and is loaded by whichever kernel a peer is running. +- The dual-target (native + WASM) compilation discipline is *intended* to + survive at the *kernel* layer — the kernel compiles to both targets, the + native build using wasmtime and the web build using a jco-transpiled + host. Concrete kernel subsystems that have historically been native-only + (the MLS engine when adopted, persistent key storage, full-fat blob + store) may require platform-specific backends behind a stable + kernel-internal trait; cataloguing those backends and confirming each + one survives jco transpilation is part of the crypto-and-key-custody + child spec. For *application code*, the discipline is replaced: an app + component is built once to wasm and is loaded by whichever kernel a + peer is running. - The existing capability/permission ideas from `willow-state` generalize, with one new responsibility: each app defines its own permission set, but also supplies the *pre-check* code that gates event creation. Today's @@ -269,8 +310,13 @@ peer is actually originating an event, never during replay. This shifts a precise, audit-friendly responsibility onto app authors; bugs in app pre-check code admit invalid events that other peers will reject at apply, accumulating in the DAG as the existing authority spec - warns against. The chat-server-migration spec must address this directly, - not defer it. + warns against. **An app's pre-check and apply MUST share the authority + decision** — typically the same WIT-exposed function called in two + different modes — so that drift between "would this be accepted" and + "is this accepted" is impossible by construction. Diverging pre-check + from apply is a soundness bug the kernel cannot detect; the + chat-server-migration spec specifies the shared-code pattern app authors + are expected to use. ## Runtime and actors @@ -281,10 +327,13 @@ The runtime sits *underneath* that model, not in place of it. The intended mapping: -- **Each component instance is owned by exactly one actor in the host.** - The actor's mailbox serializes calls into the component's WASM instance. - Component instances are the unit of *typed sandboxing*; actors remain the - unit of *concurrency*. +- **On any one peer, each component instance is owned by exactly one + actor.** The actor's mailbox serializes calls into the component's WASM + instance. Component instances are the unit of *typed sandboxing*; actors + remain the unit of *concurrency*. Different peers materializing the same + topic each instantiate the same component code in their own actor; the + runtime makes no claim about cross-peer actor topology — that is + emergent from the gossip protocol, not coordinated by the kernel. - The kernel itself is composed of actors: a loader actor, a per-topic state-materialization actor (which owns one state component instance and calls `apply` on each event), interaction actors per active interaction @@ -305,28 +354,32 @@ host-side concurrency model. ## What changes about Willow -- `willow-state` splits. The kernel half (`Event`, `EventDag

`, - `PendingBuffer`, sync, HLC) stays. The chat half (`EventKind`, - `ServerState`, `apply_event`, `required_permission`) becomes the - `chat-server` app, eventually shipped in-tree at `crates/apps/chat-server/`. -- `willow-web` becomes the default UI app, shipped in-tree at - `crates/apps/ui-leptos/`. Its bindings to chat semantics route through - the kernel and the chat-server interaction component, not through direct - Rust imports. -- `replay` and `storage` workers become generic peer hosts that load - state components for any topic they are subscribed to. -- **Worker trust model shifts.** Today's workers run trusted in-tree Rust; - under the runtime, a worker subscribed to N topics may be executing N - distinct, third-party-authored, attacker-influenceable WASM state - components simultaneously. DoS resistance, fuel scheduling, per-instance - memory caps, fair-share between topics, and operator-level deny-lists are - load-bearing operational concerns, not bandwidth/latency tuning. - Operators must be able to constrain which apps a worker will host. -- A new top-level crate `willow-kernel` (or similar) gathers what the kernel - contains. A new `willow-app-sdk` crate is what app authors use. - -These are *consequences* of the design, not v1 work items. Migration is its -own multi-spec effort and will be planned separately. +These are *consequences* of the design, named at the level of +responsibility rather than file layout. Exact crate boundaries, names, +and migration mechanics are child-spec concerns. + +- **`willow-state` splits.** A payload-agnostic kernel half (events, + DAG, sync primitives, HLC) stays as kernel. The chat-specific half + (`EventKind`, `ServerState`, `apply_event`, `required_permission`) + becomes the `chat-server` app. +- **The web client becomes the default UI app.** Its bindings to chat + semantics route through the kernel and the chat-server interaction + component rather than through direct Rust imports of chat types. +- **Workers become generic peer hosts** that load state components for + any topic they are subscribed to. +- **Worker trust model shifts.** Today's workers run trusted in-tree + Rust; under the runtime, a worker subscribed to N topics may be + executing N distinct, third-party-authored, attacker-influenceable + WASM state components simultaneously. DoS resistance, fuel scheduling, + per-instance memory caps, fair-share between topics, and operator-level + deny-lists are load-bearing operational concerns, not bandwidth/latency + tuning. Operators must be able to constrain which apps a worker will host. +- **A kernel crate emerges** gathering the privileged subsystems described + above; an app-SDK crate emerges as the authoring surface for app + components. + +Migration from today's codebase to this layout is its own multi-spec +effort and will be planned separately. ## ABI commitments @@ -371,18 +424,28 @@ require app-author refactor at migration time. Exact diffing/paging strategy is for the WIT-interfaces child spec. - **Sync ABI at v1.** Browser jco does not support async. State components are sync by definition; the rest fit. -- **Behavior components run under their own kernel-custodied identity.** - A behavior component instance is associated with an Ed25519 keypair - custodied by the kernel; events authored through `host.broadcast` are - signed under that identity, not the user's. Permission to act under that - identity is granted in-band by app-defined events (the "bot user" - pattern), enforced by the app's own state-`apply` pre-check. Behavior - components never see private keys. +- **Behavior identity is per-(peer, behavior-instance).** When a peer + enables a behavior, the kernel generates and custodies a fresh Ed25519 + keypair scoped to that peer and that instance. Events authored through + `host.broadcast` are signed under that identity, not the user's. The + runtime does *not* migrate behavior keypairs between peers; cross-peer + behavior continuity is an app-level concern. Apps that need a stable + "bot identity" across peers define an in-band registration event + mapping a peer-side behavior keypair to an app-level role + (the "bot user" pattern), enforced by the app's own state-`apply` + pre-check. Behavior components never see private keys; key custody is + identical to the user-identity custody story. - **Opaque IDs, not typed resource handles, between components.** Until wit-bindgen unifies imported and exported resource types, components pass string/u64 IDs and the kernel resolves them. - **Two runtime backends in the kernel.** wasmtime native, jco-transpiled web. Same host interface so app authors target one ABI. +- **Relays are gossip-driven, not state-driven.** The relay never inspects + app payloads, never materializes state, and never runs WASM. App-defined + topic-ID rotation (as used today by the epoch-rotation spec) must + publish post-rotation topic IDs on a stable, public discovery channel + the relay can subscribe to without app knowledge. Topic discovery at + the relay remains a transport-layer concern. - **Deterministic-by-omission for state.** No host imports = no non-determinism. We do not implement runtime checks for nondeterminism because the absence of imports is the proof. @@ -473,6 +536,10 @@ become useful: - Cross-app authority composition: out of scope for v1, but what shape should the v2 hooks take? - Resource limits: per-instance fuel and memory budgets — what defaults? +- Pre-check failure modes: on the originating peer, when an app's + pre-check panics, exhausts fuel, or loops unbounded, does the kernel + fail closed (reject the user action) or fail open (let `apply` reject + on every peer)? - Hot reload: deferred. Component update is restart for v1. ## Status From ebda01ac17ec6a9163e3ebc4e8c65dc616f31267 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 09:01:48 +0000 Subject: [PATCH 4/7] docs(runtime): apply pass-3 review fixes Pass-3 caught two contradictions pass-2 introduced and one soundness gap I missed in pass-2's helper-set design. Contradictions resolved: - The "no host imports = no non-determinism" constraint bullet still said the old thing while the determinism section was reframed in pass-2. Aligned both: deterministic-by-construction, only non-deterministic imports are forbidden. - The "stable public discovery channel" requirement for relay topic-ID rotation directly contradicted the epoch-rotation spec's whole point (future topic IDs unpredictable to non-members). Weakened to a child-spec deferral with a hint at the likely member-announced shape. Soundness: - host.install-key returns success/failure based on local key custody, which is observable. Apps could branch on the return and diverge the snapshot. Added an explicit MUST: the return is informational about local capability only and apps MUST NOT incorporate it into any state included in the cross-peer snapshot hash. Cross-peer state-hash gossip is the conformance check. Wording and placement: - Pre-check now explicitly placed under the state-apply runtime profile, same deterministic helper set, same fuel posture. This is what makes the pre-check/apply shared-decision MUST mechanically possible. - "App-level pre-check in state-apply" sentence in the crypto section was collapsing pre-check into apply; rephrased to match pass-2's careful separation. - Renamed verify-seal-envelope to verify-payload-mac to avoid collision with the rejected seal-gift-wrap design. Clarified the authenticity property (key possession, not author identity), and that MLS application messages do not flow through the DAG so are not what apply verifies. - "Nothing to leak" overclaim softened to acknowledge resource consumption (handle namespace, key-store, fuel) is bounded by the worker child spec. Open-question list expanded with four decisions the spec is now silently assuming: pre-check fuel budget, handle namespace ownership, snapshot portability across component-version upgrades, multi-peer behavior coordination (leader election / dedup). Added relay-and- topic-rotation as a planned child spec. --- .../specs/2026-04-27-willow-runtime/README.md | 114 ++++++++++++------ 1 file changed, 80 insertions(+), 34 deletions(-) diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md index 69c87d9f..236b56ee 100644 --- a/docs/specs/2026-04-27-willow-runtime/README.md +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -71,7 +71,7 @@ policies: | Profile | Determinism | Imports | Where it runs | Examples | |---|---|---|---|---| -| **State / `apply`** | **Required** — bit-identical across peers | `host.log`, plus the **deterministic helper set**: signature verification, seal-envelope authenticity check, content hashing, and key installation from a sealed key-distribution payload | Every peer materializing the topic | chat-server-state apply, wiki-state apply | +| **State / `apply`** | **Required** — bit-identical across peers | `host.log`, plus the **deterministic helper set**: signature verification, payload-MAC verification, content hashing, key installation from a sealed key-distribution payload, HLC extraction | Every peer materializing the topic | chat-server-state apply, wiki-state apply | | **State / `propose`** | Not required (runs once, on the authoring peer) | `host.hlc`, `host.random`, `host.seal` (capability-gated), `host.log` | The peer that originates the event | chat-server-state propose | | **Interaction** | Not required | `host.broadcast`, `host.subscribe`, `host.kv`, `host.user-prompt`, UI app's `ui:*` | Any peer with a UI / agent host | chat-server-interaction, wiki-interaction | | **Behavior** | Not required | + `host.http`, `host.timer`, `host.identity` (own keypair, gated) | Designated peer(s) | bridges, automod, archivers, bots | @@ -179,19 +179,31 @@ pure function of their inputs** — not "no imports." Useful kernel-side helpers `apply` legitimately needs are deterministic by construction: - `host.verify-signature(pubkey, msg, sig)` — Ed25519 verification. -- `host.verify-seal-envelope(envelope, pubkey)` — AEAD-tag check on a - sealed payload, without revealing plaintext. +- `host.verify-payload-mac(envelope, key-handle)` — authenticity check + on a sealed payload without revealing plaintext, proving "some holder + of the key bound to this handle sealed this." Note this proves *key + possession*, not *author identity* — author identity comes from the + outer Ed25519 signature on the event itself. The exact set of envelope + formats the helper accepts (current `seal_content`, future MLS + Welcome / Commit, etc.) is the crypto-and-key-custody child spec's + responsibility; per the seal-gift-wrap deferral spec, MLS *application* + messages do not flow through the DAG and are therefore not what + `apply` verifies. - `host.hash(bytes)` — blake3 / sha256. - `host.install-key(handle, sealed)` — accept a sealed key-distribution blob addressed to *this peer*, unwrap it with the kernel-custodied recipient key, install the resulting symmetric key under the app-supplied opaque handle. The unwrapped bytes never enter component - memory; the function returns success/failure and the handle namespace - the app chose. Output depends on the local peer's private key, but - the *failure-or-handle-bound-success* outcome is deterministic from - the app's point of view because if the blob does not address the - local peer the call deterministically fails — no peer-specific bytes - flow back. + memory; the function returns success/failure based on whether the + blob addressed the local peer. **The return value is informational + about local capability only, and an app's `apply` MUST NOT + incorporate it into any state included in the cross-peer snapshot + hash.** A conformant state component records the key handle in + materialized state regardless of install outcome (every peer learns + "channel epoch N exists, handle is `chN-eN`"); the kernel separately + knows which handles this peer can actually open. The cross-peer + state-hash gossip is the conformance check — apps that branch + visibly on install-key return values fail it immediately. - `host.now-hlc-from-event(event)` — extract HLC bytes from the event envelope (no wall clock). @@ -206,12 +218,11 @@ What `apply` continues to be denied: The determinism proof is therefore: **every host import bound to `apply` returns a pure function of its inputs given the event payload alone**. -`host.install-key` is the only edge case — it is deterministic *modulo -local key custody*, and behaves uniformly (succeeds, or deterministically -fails with no observable difference) on peers that are or are not the -intended recipient. The exact list of deterministic helpers belongs in -the crypto-and-key-custody child spec; the master spec commits to the -shape. +`host.install-key` is the one helper whose return value is *peer-local*, +which is why apps are required to treat that return as informational +about local capability only (see above). The exact list of deterministic +helpers belongs in the crypto-and-key-custody child spec; the master +spec commits to the shape. The kernel verifies cross-peer convergence by hashing snapshots and gossiping state hashes. Mismatches surface as bugs (or, if signed, as proof of a @@ -231,12 +242,13 @@ has to place it explicitly, not silently. The chosen split: profiles only — produces ciphertext under the named key. - `host.open(handle, ciphertext)` on interaction profile only — decrypts for display. - - `host.verify-seal-envelope(envelope, signer-pubkey)` and + - `host.verify-payload-mac(envelope, key-handle)` and `host.install-key(handle, sealed-distribution-blob)` on state-`apply` — deterministic helpers (see "Determinism, in detail"). State-`apply` never sees plaintext message content; it sees sealed payloads, can - verify their authenticity, and can install keys distributed in the - DAG into the kernel's custody under app-named handles. + verify "some holder of this key handle sealed this," and can install + keys distributed in the DAG into the kernel's custody under app-named + handles. - **Key generation and rotation events are app-defined.** A chat-server-style app defines its own `RotateChannelKeyV2`-equivalent events; the state-`apply` function records the new key handle in materialized state; the kernel @@ -252,9 +264,10 @@ has to place it explicitly, not silently. The chosen split: The principle is consistent: **secrets do not enter component memory in their raw form**. Components hold handles; the kernel custodies bytes. An -app-defined permission that gates `Rotate*` events is an app-level pre-check -in state-`apply` (see the capability model section); the kernel only enforces -that the seal/open call presented an authorized handle. +app-defined permission that gates `Rotate*` events is enforced by the +app's pre-check function (which shares its decision logic with apply, see +the capability model section); the kernel only enforces that the seal/open +call presented an authorized handle. The exact `host.seal` / `host.open` / `host.mls` interface, key-derivation strategy, and persistence story belong in a child spec dedicated to crypto @@ -277,7 +290,9 @@ later without re-prompting. State-`apply` is bound only to the deterministic helper set (see "Determinism, in detail" for the full list). There is no -non-deterministic capability to grant; nothing to leak. State-`propose` +non-deterministic capability to grant and no information leak surface; +resource consumption — handle namespace, key-store size, fuel — is bounded +by per-instance caps defined in the worker child spec. State-`propose` has the small set listed in the runtime-profiles table (`host.hlc`, `host.random`, capability-gated `host.seal`); these are bound only when a peer is actually originating an event, never during replay. @@ -313,10 +328,14 @@ peer is actually originating an event, never during replay. warns against. **An app's pre-check and apply MUST share the authority decision** — typically the same WIT-exposed function called in two different modes — so that drift between "would this be accepted" and - "is this accepted" is impossible by construction. Diverging pre-check - from apply is a soundness bug the kernel cannot detect; the - chat-server-migration spec specifies the shared-code pattern app authors - are expected to use. + "is this accepted" is impossible by construction. **Pre-check executes + under the state-`apply` runtime profile** — same deterministic helper + set, same fuel posture, same denied non-deterministic imports — even + though it is invoked from the originating peer's propose flow with the + proposed event payload. This is what makes "shared decision" mechanically + possible. Diverging pre-check from apply is a soundness bug the kernel + cannot detect; the chat-server-migration spec specifies the shared-code + pattern app authors are expected to use. ## Runtime and actors @@ -441,14 +460,22 @@ require app-author refactor at migration time. - **Two runtime backends in the kernel.** wasmtime native, jco-transpiled web. Same host interface so app authors target one ABI. - **Relays are gossip-driven, not state-driven.** The relay never inspects - app payloads, never materializes state, and never runs WASM. App-defined - topic-ID rotation (as used today by the epoch-rotation spec) must - publish post-rotation topic IDs on a stable, public discovery channel - the relay can subscribe to without app knowledge. Topic discovery at - the relay remains a transport-layer concern. -- **Deterministic-by-omission for state.** No host imports = no - non-determinism. We do not implement runtime checks for nondeterminism - because the absence of imports is the proof. + app payloads, never materializes state, and never runs WASM. Topic + discovery at the relay remains a transport-layer concern. App-defined + topic-ID rotation (as used today by the epoch-rotation spec, where + future topic IDs are intentionally unpredictable to non-members) must + bridge a relay across rotations *without* publishing rotated IDs on a + public channel — naive public discovery would defeat the rotation's + unlinkability property. The likely shape is members announcing the + next topic to the existing relay session before rotation, but the + exact protocol is deferred to a relay-and-rotation child spec; the + master-spec commitment is only that the kernel is not in this loop. +- **Deterministic-by-construction for state-`apply`.** The only host + imports bound to `apply` are the deterministic helper set (signature + verification, hash, payload-MAC verification, key installation, HLC + extraction, log). Each is a pure function of its inputs given the + event payload. Determinism is proven by the absence of any + *non-deterministic* import — not by the absence of imports altogether. ## Lineage and influences @@ -518,6 +545,10 @@ become useful: across topics, per-instance resource caps, operator deny-lists, abuse surfaces. Distinct from materialization, which is about correctness; this is about operating workers safely at scale. +- **Relay and topic-ID rotation** — how a relay continues to bridge a + topic across an app-driven rotation without the kernel knowing what an + epoch is and without leaking rotation linkability to the public + network. Likely member-announced via the pre-rotation session. - **Crypto and key custody boundaries** — the `host.seal` / `host.open` / `host.mls` interface, key-derivation strategy, persistence story, app ↔ kernel responsibility split for rotation. @@ -540,6 +571,21 @@ become useful: pre-check panics, exhausts fuel, or loops unbounded, does the kernel fail closed (reject the user action) or fail open (let `apply` reject on every peer)? +- Pre-check fuel budget: pre-check runs under the `apply` profile, but + it runs *only on the originating peer*; does it share `apply`'s + per-event fuel cap, or is it budgeted separately? +- Handle namespace ownership: two apps installing keys under the same + opaque handle on one peer — collision, namespacing per-app instance, + or kernel-arbitrated allocation? +- Snapshot portability across component-version upgrades: when an app's + state component is updated, do existing snapshots remain valid? What + is the migration story? +- Multi-peer behavior coordination: when two peers run instances of the + same behavior for redundancy, dedup of emitted events and leader + election are app-level concerns; the runtime offers no kernel-level + coordination primitive. Apps that need single-emitter semantics + implement leader election in their own state component. Should the + runtime offer a shared primitive, or stay strict? - Hot reload: deferred. Component update is restart for v1. ## Status From cfba5f52ea38969472587f814929147f124e5726 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Apr 2026 09:11:27 +0000 Subject: [PATCH 5/7] docs(runtime): apply cold-read review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cold-read pass (no briefing on prior fixes) caught two soundness issues the prior passes missed and several real should-fixes. Soundness fixes: - host.install-key now returns (), not success/failure. The kernel records the (handle, blob) pair on every peer; whether THIS peer can unwrap is kernel-local, never visible to apply. Eliminates the peer-local-return carve-out entirely; apply is bit-identical across peers by construction. Interaction profile asks separately (host.can-open / host.open error). Stronger than the "MUST-NOT-branch-on-return" rule pass-3 added. - Pre-check is now mechanically the same WASM function as apply's authority verdict — kernel calls it in dry-run mode against a scratch post-state. "Shared decision" is no longer a convention app authors must follow; it's a structural property of the WIT contract because pre-check and apply are the same export. Master-level commitments added: - Pre-check fails closed: panic, fuel exhaustion, trap, or unbounded loop within budget rejects the user action and does not sign. Failing open is forbidden because rejected events accumulate in the per-author DAG. Adversarial-app self-DoS is detectable and recoverable. - Sync ABI uses submit-and-poll for inherently-async surfaces: components call sync host functions returning request-tokens; the kernel re-enters the component via on-completion handlers when ops finish. Ergonomic cost flagged. - Snapshot convergence is via app-exported canonical state-digest(), not raw linear-memory hashing. Canonical encoding (postcard with sorted collections per existing precedent) is for the determinism child spec. - ui:* calls that proxy privileged platform surfaces (clipboard, file pickers, navigation, push) are capability-checked PER CALL against the calling component's manifest, not just at import-binding. Prevents composed components from social-engineering the broadly- privileged UI app. Cross-spec links: - Behavior identity custody is structurally the same problem as multi-device user identity; flagged as something that should share one kernel mechanism, not be invented twice. - The in-flight epoch-rotation work (2026-04-24) needs to land in the new shape: relay no longer told "this is a rotation, here's the next topic id" by app code. Open questions added: worker capability advertisement (parallel to existing relay capability document). --- .../specs/2026-04-27-willow-runtime/README.md | 156 ++++++++++++------ 1 file changed, 104 insertions(+), 52 deletions(-) diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md index 236b56ee..6b026ede 100644 --- a/docs/specs/2026-04-27-willow-runtime/README.md +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -190,20 +190,19 @@ helpers `apply` legitimately needs are deterministic by construction: messages do not flow through the DAG and are therefore not what `apply` verifies. - `host.hash(bytes)` — blake3 / sha256. -- `host.install-key(handle, sealed)` — accept a sealed key-distribution - blob addressed to *this peer*, unwrap it with the kernel-custodied - recipient key, install the resulting symmetric key under the - app-supplied opaque handle. The unwrapped bytes never enter component - memory; the function returns success/failure based on whether the - blob addressed the local peer. **The return value is informational - about local capability only, and an app's `apply` MUST NOT - incorporate it into any state included in the cross-peer snapshot - hash.** A conformant state component records the key handle in - materialized state regardless of install outcome (every peer learns - "channel epoch N exists, handle is `chN-eN`"); the kernel separately - knows which handles this peer can actually open. The cross-peer - state-hash gossip is the conformance check — apps that branch - visibly on install-key return values fail it immediately. +- `host.install-key(handle, sealed-distribution-blob) -> ()` — register + that a key-distribution blob exists for this app handle. The kernel + records the (handle, blob) pair under the app's namespace on every + peer; whether *this* peer can actually unwrap the blob with its own + X25519 key is recorded **only in kernel-local custody, never visible + to `apply`**. From `apply`'s point of view, the call is a pure + recording of "this app declares handle H, gated by this distribution + blob," with no return value to branch on. State-`apply` is therefore + bit-identical across peers regardless of who can decrypt. The + *interaction* profile asks the kernel separately (`host.can-open(handle)` + or by attempting `host.open` and getting an error) whether this peer + can use the key to read messages. There is no observable peer-local + return on the `apply` path. - `host.now-hlc-from-event(event)` — extract HLC bytes from the event envelope (no wall clock). @@ -218,14 +217,22 @@ What `apply` continues to be denied: The determinism proof is therefore: **every host import bound to `apply` returns a pure function of its inputs given the event payload alone**. -`host.install-key` is the one helper whose return value is *peer-local*, -which is why apps are required to treat that return as informational -about local capability only (see above). The exact list of deterministic -helpers belongs in the crypto-and-key-custody child spec; the master -spec commits to the shape. - -The kernel verifies cross-peer convergence by hashing snapshots and gossiping -state hashes. Mismatches surface as bugs (or, if signed, as proof of a +There are no peer-local return values; whether-this-peer-can-decrypt is +an interaction-profile concern, not an `apply` concern. The exact list +of deterministic helpers belongs in the crypto-and-key-custody child +spec; the master spec commits to the shape. + +The kernel verifies cross-peer convergence by hashing a **canonical +state digest** the app exports — *not* a hash of WASM linear memory, +which would diverge trivially across peers due to allocator behavior, +struct field padding, or `HashMap` iteration order. Apps export a +`state-digest()` function (or equivalent) that returns canonical bytes +under a deterministic encoding (postcard with sorted collections is the +existing-codebase precedent); the kernel hashes the result and gossips +the hash. The exact encoding rules belong in the +determinism-enforcement child spec; the master commitment is that +convergence is checked against an app-canonical digest, not memory +bytes. Mismatches surface as bugs (or, if signed, as proof of a malicious or buggy component). ## Crypto and key custody @@ -243,12 +250,13 @@ has to place it explicitly, not silently. The chosen split: - `host.open(handle, ciphertext)` on interaction profile only — decrypts for display. - `host.verify-payload-mac(envelope, key-handle)` and - `host.install-key(handle, sealed-distribution-blob)` on state-`apply` - — deterministic helpers (see "Determinism, in detail"). State-`apply` - never sees plaintext message content; it sees sealed payloads, can - verify "some holder of this key handle sealed this," and can install - keys distributed in the DAG into the kernel's custody under app-named - handles. + `host.install-key(handle, sealed-distribution-blob) -> ()` on + state-`apply` — deterministic helpers (see "Determinism, in detail"). + State-`apply` never sees plaintext message content, never sees a + return value indicating local decryption capability; it records that + the handle exists and lets the kernel custody the per-peer + decryptability privately. Whether *this* peer can actually use a + handle is a separate interaction-profile query. - **Key generation and rotation events are app-defined.** A chat-server-style app defines its own `RotateChannelKeyV2`-equivalent events; the state-`apply` function records the new key handle in materialized state; the kernel @@ -288,6 +296,17 @@ events on topic X, store ≤ 1 MB locally, send HTTP requests to discord.com." Granted capabilities are bound at instantiate time; they cannot escalate later without re-prompting. +**`ui:*` calls that proxy privileged platform surfaces are +capability-checked per call, not just per import-binding.** Clipboard +writes, file pickers, top-level navigation, push-notification +registration, and similar — each call is gated by the *calling +component's* manifest, not the UI app's broad surface. This prevents a +malicious or compromised interaction component composed inside the UI +app from socially-engineering the UI into doing things the calling +component was never granted. The UI app is in the TCB for its own +chrome and its own DOM; it is not in the TCB for arbitrary callers' +intents. + State-`apply` is bound only to the deterministic helper set (see "Determinism, in detail" for the full list). There is no non-deterministic capability to grant and no information leak surface; @@ -322,20 +341,21 @@ peer is actually originating an event, never during replay. centralized `required_permission()` table runs in trusted in-process Rust; under the runtime the kernel calls into the app's state component to ask "may this author emit this event under the current state?" before signing. - This shifts a precise, audit-friendly responsibility onto app authors; - bugs in app pre-check code admit invalid events that other peers will - reject at apply, accumulating in the DAG as the existing authority spec - warns against. **An app's pre-check and apply MUST share the authority - decision** — typically the same WIT-exposed function called in two - different modes — so that drift between "would this be accepted" and - "is this accepted" is impossible by construction. **Pre-check executes - under the state-`apply` runtime profile** — same deterministic helper - set, same fuel posture, same denied non-deterministic imports — even - though it is invoked from the originating peer's propose flow with the - proposed event payload. This is what makes "shared decision" mechanically - possible. Diverging pre-check from apply is a soundness bug the kernel - cannot detect; the chat-server-migration spec specifies the shared-code - pattern app authors are expected to use. + This shifts a precise, audit-friendly responsibility onto app authors, + but the runtime makes drift impossible by construction: **pre-check is + not "shared logic by convention" — it is mechanically the same WASM + function as `apply`'s authority verdict, called by the kernel in + dry-run mode against a hypothetical post-state.** Apps export one + authority predicate; the kernel calls it once before signing on the + originator (with the proposed event applied to a scratch copy of state) + and again on every peer during real `apply`. Compare-acceptance is + enforced because it is the same export. Pre-check therefore runs under + the state-`apply` runtime profile — same deterministic helper set, same + fuel posture, same denied non-deterministic imports. The exact dry-run + protocol (scratch state ownership, rollback semantics) is deferred to + the chat-server-migration / WIT-interfaces child spec; the master spec + commits to the *property* that pre-check and apply cannot diverge + because they are not separate code paths. ## Runtime and actors @@ -441,8 +461,30 @@ require app-author refactor at migration time. recomposition on no-op state changes; large lists (timelines, member rosters) are paged. Behavior components observe and emit in batches. Exact diffing/paging strategy is for the WIT-interfaces child spec. -- **Sync ABI at v1.** Browser jco does not support async. State components - are sync by definition; the rest fit. +- **Sync ABI at v1, with kernel-side async bridged via tokens.** Browser + jco does not support async. State `apply` is sync by definition. + Kernel calls that wrap inherently async surfaces (gossip broadcast, + blob fetch, HTTP, persistent KV, timers) follow a *submit-and-poll* + pattern: the component calls a sync host function that returns a + `request-token`, then the kernel later re-enters the component (via + an exported `on-completion(token, result)` handler in the appropriate + profile) when the operation finishes. This keeps the WIT surface sync + while preserving back-pressure: a slow blob fetch does not stall the + component's actor mailbox, because the originating call returned + immediately. The ergonomic cost is real — apps cannot use familiar + `async`/`await` flow control, and SDK macros are expected to hide the + token-juggling for common patterns. Exact handler-method shape is for + the WIT-interfaces child spec. +- **Pre-check fails closed.** When the kernel's dry-run pre-check panics, + exhausts fuel, traps, or loops up to the deterministic budget, the + user-action that triggered it is rejected and the event is *not* + signed. Failing open (admitting an event that every peer rejects at + `apply`) is forbidden because rejected events accumulate in the + per-author DAG and cannot be removed without breaking the chain — the + exact failure mode the existing authority spec was designed to make + impossible. Adversarial app components that always-fail pre-check + produce a self-DoS of the user's own ability to act in that app, which + is detectable and recoverable by uninstalling the app. - **Behavior identity is per-(peer, behavior-instance).** When a peer enables a behavior, the kernel generates and custodies a fresh Ed25519 keypair scoped to that peer and that instance. Events authored through @@ -451,9 +493,13 @@ require app-author refactor at migration time. behavior continuity is an app-level concern. Apps that need a stable "bot identity" across peers define an in-band registration event mapping a peer-side behavior keypair to an app-level role - (the "bot user" pattern), enforced by the app's own state-`apply` - pre-check. Behavior components never see private keys; key custody is - identical to the user-identity custody story. + (the "bot user" pattern), enforced by the app's own pre-check. + Behavior components never see private keys; key custody is + identical to the user-identity custody story. **This is structurally + the same problem as multi-device user identity** (long-term identity, + short-lived per-device signing key) which the seal-gift-wrap deferral + spec calls out as non-negotiable: both should share a kernel-level + mechanism rather than be invented twice. - **Opaque IDs, not typed resource handles, between components.** Until wit-bindgen unifies imported and exported resource types, components pass string/u64 IDs and the kernel resolves them. @@ -470,6 +516,11 @@ require app-author refactor at migration time. next topic to the existing relay session before rotation, but the exact protocol is deferred to a relay-and-rotation child spec; the master-spec commitment is only that the kernel is not in this loop. + Practical consequence: the in-flight epoch-rotation work + (`docs/specs/2026-04-24-epoch-key-rotation.md`) needs to land in this + new shape — the relay will no longer be told "this is a rotation + event, here's the next topic id" by app code, because the relay no + longer runs app code. - **Deterministic-by-construction for state-`apply`.** The only host imports bound to `apply` are the deterministic helper set (signature verification, hash, payload-MAC verification, key installation, HLC @@ -567,13 +618,14 @@ become useful: - Cross-app authority composition: out of scope for v1, but what shape should the v2 hooks take? - Resource limits: per-instance fuel and memory budgets — what defaults? -- Pre-check failure modes: on the originating peer, when an app's - pre-check panics, exhausts fuel, or loops unbounded, does the kernel - fail closed (reject the user action) or fail open (let `apply` reject - on every peer)? +- Worker capability advertisement: parallel to the existing relay + capability document, should workers advertise which app-component + hashes they host, so peers can discover "a worker that materializes + my chat-server app" without out-of-band config? Or stays operator-config? - Pre-check fuel budget: pre-check runs under the `apply` profile, but it runs *only on the originating peer*; does it share `apply`'s - per-event fuel cap, or is it budgeted separately? + per-event fuel cap, or is it budgeted separately? (The polarity is + master-level: pre-check fails closed — see "Constraints we accept".) - Handle namespace ownership: two apps installing keys under the same opaque handle on one peer — collision, namespacing per-app instance, or kernel-arbitrated allocation? From e4e208b62831fe3dd7ac480ff8f1deb7b76cf937 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 01:05:04 +0000 Subject: [PATCH 6/7] docs(runtime): research notes for distributed maintenance / participation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the reframe from earlier conversation: maintenance is not a separate work-tracking concept but a fourth class of components alongside state/interaction/behavior. Persister, snapshot provider, sync provider, replay buffer all become components in an app's bundle. Scaling = more peers running an app = more maintenance capacity, automatically. The participation/free-rider problem under Sybil is load-bearing for what the master spec will eventually commit to here, and is research- heavy. Captures pointers into prior art across: - Free-rider quantification (Adar/Huberman) - Tit-for-tat reciprocity (BitTorrent choking, Bitswap) - Reputation aggregation (EigenTrust, BarterCast) - BAR (Byzantine/Altruistic/Rational) game-theoretic frame - Sybil resistance without proof-of-work (SybilGuard, Whanau) - Storage proofs (Filecoin, Storj) - Holochain validator selection / DHT responsibility - IPFS pinning economics Notes the unique advantage Willow has — the existing permission/invite system gives us a social trust graph for free, which generic P2P systems had to bootstrap. Master-spec section deferred until the next session can read the relevant prior art and pick a model (likely hybrid). --- .../research-notes-distributed-maintenance.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/specs/2026-04-27-willow-runtime/research-notes-distributed-maintenance.md diff --git a/docs/specs/2026-04-27-willow-runtime/research-notes-distributed-maintenance.md b/docs/specs/2026-04-27-willow-runtime/research-notes-distributed-maintenance.md new file mode 100644 index 00000000..5a70e2c9 --- /dev/null +++ b/docs/specs/2026-04-27-willow-runtime/research-notes-distributed-maintenance.md @@ -0,0 +1,157 @@ +# Research notes — distributed maintenance and participation + +**Date:** 2026-04-27 +**Status:** notes for a future session, not a spec +**Parent:** [README.md](README.md) + +## Reframe captured here, not in the master spec yet + +Maintenance work for an app is not a separate runtime concept; it is +**a fourth class of components** alongside state / interaction / behavior: + +- **Maintenance components** — persister, snapshot provider, sync provider, + replay buffer. Optional in any app's bundle. Loaded by peers that opt + to contribute, with kernel-known capacity hints. + +A peer's "participation" is just the set of maintenance components it has +instantiated and the capacity it has declared for them. Scaling = more +peers running an app → more maintenance-component instances → more +maintenance capacity, automatically. There is no separate work-tracking +subsystem; the runtime model already supports this. + +Default client behavior: opt-in to cheap maintenance roles by default, +expose a "how much do you want to contribute" UI for expensive ones +(disk-heavy persister, bandwidth-heavy sync provider). + +The master spec should grow a section reflecting this when the next +session draft lands. **Do not draft it yet** — the participation / +free-rider problem below is load-bearing for what that section will +commit to, and it's research-heavy. + +## The hard problem: participation enforcement under Sybil + +A custom client that does not run maintenance components, multiplied by +spinning up many identities, free-rides on honest participants. The +honest peers' load grows with the cheaters' identity count. + +User's first-cut proposal: + +- Self-reported participation is gameable — can't be the input. +- Community-tracked participation: peer A observes peer B's contributions + to A during interaction. Local view, gossip-aggregated. +- Refusal to serve non-participants becomes the enforcement primitive. +- Sybil resistance is the hard part — without identity cost, the metric + is gameable by minting more identities. + +This is the right family of solutions; the literature has 20+ years of +specific designs. The next session should start from prior art. + +## Research topics for the next session + +### A. Free-rider quantification in real P2P systems + +- **Adar & Huberman, "Free Riding on Gnutella" (2000)** — the canonical + measurement paper. ~70% of Gnutella users contributed nothing. + Establishes the problem. +- **Hughes, Coulson, Walkerdine (2005)** on Gnutella free-riding evolution. +- BitTorrent measurement studies — why tit-for-tat reduced but did not + eliminate free-riding. + +### B. Tit-for-tat and reciprocity (no global identity needed) + +- **BitTorrent's choking algorithm (Cohen 2003)** — local pairwise + reciprocity. The most successful deployed answer. Sybil-tolerant + because it's per-connection: cheating identities each get nothing + individually until they upload. +- **PropShare** and other BitTorrent variants — refinements with + formal analysis. +- Limitation: works for symmetric workloads (you have what I want, I + have what you want). Maintenance work isn't symmetric — a snapshot + provider isn't asking the joiner for anything in return. Pure tit-for-tat + doesn't fit cleanly. + +### C. Reputation and trust aggregation + +- **EigenTrust (Kamvar, Schlosser, Garcia-Molina 2003)** — global trust + scores via gossip eigenvector. Sybil-vulnerable but the canonical + reference. Many later systems build on it. +- **PowerTrust, PeerTrust** — variants. Some Sybil-hardening. +- **Tribler's BarterCast** — local-view reputation in a real deployed + P2P system. Practical lessons about gossip-aggregated reputation. + +### D. BAR (Byzantine, Altruistic, Rational) game-theoretic models + +- **BAR Gossip (Aiyer, Alvisi, Clement, Cowling, Dahlin, Riché 2005)** — + framework for protocols that work even when some peers are Byzantine + and others are merely rational (selfish). Directly relevant to "honest + peers + free-riders + actively-malicious peers." +- **FlightPath, BAR Fault Tolerance** — followups. +- BAR is the right academic frame for our problem. + +### E. Sybil resistance without proof-of-work + +- **Castro, Druschel, Ganesh, Rowstron, Wallach, "Secure routing for + structured P2P overlays" (2002)** — early Sybil mitigations. +- **SybilGuard, SybilLimit (Yu et al. 2006, 2008)** — social-graph-based + Sybil resistance. Relevant if we're willing to use trust links between + identities (who-trusts-whom data Willow already has via the existing + permission/invite model). +- **Whanau (Lesniewski-Laas 2010)** — Sybil-proof DHT. Cleaner technique. +- Identity-cost approaches (proof-of-stake, proof-of-storage) — likely + too heavy for chat-shape apps; flag as out-of-scope unless we change + our minds. + +### F. Storage proofs (relevant for persister role) + +- **Filecoin proofs of replication / proofs of spacetime** — + cryptographically verifiable storage. Heavy machinery; only relevant + if we want strong durability guarantees. +- **Storj, Sia** — practical storage-proof systems. +- **Audit-style schemes** (challenge-response over stored data) — much + cheaper. May be the right level for our case. + +### G. Holochain's validator-selection / DHT-responsibility model + +- **Holochain RFCs on validation responsibility** — every node validates + entries it's "responsible for" in the DHT. Coordinated allocation + (Pattern B in our earlier discussion). Closest existing system to + what we're building, structurally. +- Worth a real read; lessons probably translate directly. + +### H. IPFS / libp2p + +- **Bitswap protocol** — ledger-based reciprocity for block exchange. + Closer in spirit to BitTorrent than to a reputation system. +- **IPFS pinning economics** (Filecoin, Pinata, Web3.Storage) — what + happens when a popular thing falls out of cache? Lessons about + voluntary maintenance failure modes. + +## What the next session should produce + +1. A read-through of (B), (D), (E), and (G) — those are the closest fits. +2. A decision on which model Willow adopts: tit-for-tat (Bitswap-ish), + reputation (EigenTrust-ish), social-graph Sybil resistance + (SybilGuard-ish), or DHT-responsibility (Holochain-ish). Likely a + hybrid. +3. An explicit decision on whether we use the existing permission/invite + trust graph as the social-graph input — this is a unique advantage + we have over generic P2P systems and may simplify Sybil resistance + substantially. +4. A draft section for the master spec naming maintenance components + and the participation primitive at master level, deferring the + protocol details to a child spec. + +## Notes captured for context + +- The runtime model already supports maintenance-as-component without + any kernel changes. The participation question is *not* about whether + the runtime can express it; it's about what the protocol layer that + enforces "refuse to serve non-participants" looks like. +- The existing permission/invite system means we already have a + Sybil-relevant trust graph for free; this is genuinely an advantage + over BitTorrent / IPFS / Gnutella, which had to bootstrap social + graphs they didn't have. Worth surfacing in the next-session decision. +- Free-rider tolerance for chat-shape apps is high — most users contribute + little, the cheap roles are nearly free, and the only adversary that + matters is an automated client doing it at scale. This narrows the + threat model usefully. From 76eb91dd195cd48c41c734e8ccf8d327ad79a441 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Apr 2026 01:08:07 +0000 Subject: [PATCH 7/7] docs(runtime): coverage gaps from conversation audit Light audit against the conversation thread surfaced two referenced ideas missing from the master spec. Both added at the appropriate section, kept brief. - Custom-pixel UI escape hatch: surfaces like whiteboard, code editor, 3D voice room, network-graph visualizer aren't in the ui:* contract. On web they're sandboxed iframes the default UI app embeds, with postMessage as a kernel-mediated capability. Bevy slots in here as a far-future GPU surface plugin once its web tooling matures (~2027-2028), not as a default-UI replacement. TUI/MCP hosts render unavailable rather than fall back. - Lazy loading: components are loaded on first use and hash-cached. A user in five apps doesn't instantiate all five interaction components at startup. State components materialize on subscribe so events can be applied; other profiles load on demand. Worker-computed snapshots cover the warm-up window. --- .../specs/2026-04-27-willow-runtime/README.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/docs/specs/2026-04-27-willow-runtime/README.md b/docs/specs/2026-04-27-willow-runtime/README.md index 6b026ede..b69690e6 100644 --- a/docs/specs/2026-04-27-willow-runtime/README.md +++ b/docs/specs/2026-04-27-willow-runtime/README.md @@ -105,6 +105,15 @@ Apps can ship state-only (a pure semantics package), interaction-only common case), or any combination. A peer fetches the bundle by hash and instantiates only the components it needs for what it intends to do. +**Components are lazy-loaded and hash-cached.** A user in five servers +across five different apps does not instantiate all five interaction +components at startup; the kernel loads each component on first use and +caches by hash thereafter. State components materialize as soon as the +peer subscribes to a topic (so it can apply incoming events); other +components instantiate on demand. Worker-computed snapshots can carry +peers through the warm-up so the UI stays responsive even before all +interaction components have downloaded. + ## UI is an app The Leptos web client is, in this model, **the default UI app**. It exports @@ -143,6 +152,18 @@ against any UI app that exports the interfaces they import. UI apps that do not export an interface (e.g. a TUI without `ui:rich-card`) cause graceful degradation, not breakage. +**Custom-pixel surfaces (whiteboard, code editor, network-graph +visualizer, 3D voice room) are an explicit out-of-band escape hatch**, +not part of the `ui:*` contract. On web, these surface as sandboxed +iframes the default UI app embeds; the iframe communicates with the +parent through a postMessage protocol that is itself a kernel-mediated +capability. On other platforms, the equivalent is platform-specific. +GPU-driven UI substrates (e.g. Bevy as a surface plugin once its web +tooling matures around 2027–2028) would compose here, not as a +replacement for the default UI app. The escape hatch is browser/native +shaped on purpose; a TUI host or an MCP host renders these as +"unavailable on this surface" rather than attempting fallback. + ## Inter-component composition Components compose by importing each other's exposed interfaces, mediated by