Skip to content

Run-loop capsule KV writes land in the neutral store instead of the owning principal's KV #1197

Description

@jvsteiner

Repo: unicity-astrid @ 6ea3489 · Runtime: astrid 0.9.4 / sdk 0.7.1
Type: bug · Component: crates/astrid-capsule/src/engine/wasm/
Supersedes: the earlier 2026-07-12-capsule-scoped-kv.md proposal — that
asked for a new capsule-scoped store; this is the smaller, correct fix. A
run-flagged capsule already runs one persistent instance per principal with
KV bound to that principal, so no new store is needed — the binding just isn't
reaching the run loop's own execution.

Summary

A run-flagged (run-loop) capsule's background work writes KV to the neutral,
in-memory fail-closed placeholder
rather than the owning principal's
{principal}:capsule:{id} store. The write succeeds silently — it is a
misroute, not a failure — so the data is invisible to that principal's tool
calls and lost on reload. Root cause: nothing installs the owner's KV overlay on
the run loop's HostState before the guest run export executes.

Impact

For a resident run-loop capsule (e.g. an inbox that receives messages over a
network transport and stores them for the operator's tools to read):

  • Inbound events the run loop stores are unreadable by every tool call (tools
    resolve {principal}:capsule:{id}; the run loop wrote neutral).
  • Nothing the run loop persists survives a reload — checkpoints, cursors,
    work-state all reset. A relay-backed capsule replays from since=0 every load.
  • No error is surfaced to the capsule, so it looks like data silently vanishes.

This is the whole value proposition of a resident capsule (accumulate state from
background events), so for run-flagged capsules it's effectively load-bearing.

Repro

  1. Install a run-flagged capsule for a single principal P.
  2. From its run loop (no tool call in flight), kv::set("k", v).
  3. Call any tool as P that does kv::get("k") → returns empty.
  4. Reload the capsule → k is gone.

Observed on the live daemon: the store call returns success and logs, but the
value never surfaces to a tool read and does not survive reload.

Root cause (code path, all in crates/astrid-capsule/src/engine/wasm/)

  1. Run loop invokes run with no overlay install. mod.rs:2334-2359: the
    spawned task locks run_store, resolves the typed run func (:2337), and
    calls typed.call_async(&mut *s, ()) (:2354). No
    install_principal_overlays(...) runs before it
    , so the guest executes
    against a HostState whose invocation_kv is still the load-time default.

  2. Load-time default is None → neutral. install_principal_overlays_sync
    (mod.rs:1025) forces the principal-less branch to neutral:
    let Some(p) = principal else { state.invocation_kv = None; … return false; }
    (:1043-1046). At load there is no invoking principal, so invocation_kv
    starts None.

  3. effective_kv() falls back to the neutral placeholder.
    host_state_effective.rs (effective_kv):
    self.invocation_kv.as_ref().unwrap_or(&self.kv). With invocation_kv = None
    it returns self.kv — the neutral.fail-closed:capsule store
    (host_state.rs:~758-761), which is in-memory and a different
    namespace
    than any tool read.

  4. The owner is known but never applied to this path.
    owner_principal = ctx.principal is captured at mod.rs:2376, and the
    interceptor path already falls back to it
    (mod.rs:2487, mod.rs:2613: …or_else(|| self.owner_principal.clone())).
    The run loop's own path simply never installs or falls back to it.

Why it's silent (not fail-closing internally)

effective_kv() returns a valid ScopedKvStore — the neutral one — so
kv::set succeeds with no error channel triggered. The bytes land in the
in-memory neutral store that is never persisted and never read by the
principal's tools. From the capsule's point of view the data simply disappears.

Related secondary symptom (possible context leak)

Once a tool-call IPC message is received, its recv-installed invocation context
(host_state_invocation.rs, the per-publisher overlay install) sets
invocation_kv to the caller's scope — and that scope then lingers for
subsequent run-loop host-calls in the same locked-store tick
. So events the run
loop processes after a tool call land in that caller's real KV, while events
before it go to neutral. That's the only reason any inbox data ever survives
today, and it's arguably its own cross-context bleed worth a look.

Why this is a run-loop-only fix (and preserves #1069)

A run-flagged capsule is not pooled: it keeps one dedicated Store and never
goes through the instance pool (mod.rs:157-164, and self.pool = None at
mod.rs:2367-2370). It has exactly one owner — ctx.principal, the installing
principal. The content-hash-shared, load-under-PrincipalId::default() path that
#1069 guards is the pooled / non-run path (pool.rs:521, 606), which run
capsules don't take. So binding the run loop's own work to its single owner does
not touch the multi-principal isolation the neutral floor exists to protect.

Suggested fix (two parts)

  1. Install the owner overlay as the run loop's baseline. Before
    typed.call_async at mod.rs:2354, run
    install_principal_overlays(&mut host_state, Some(&ctx.principal)) on the
    HostState inside run_store, so invocation_kv = Some({owner}:capsule:{id})
    is the standing scope for the run loop's own host-calls.

  2. Resolve the principal-less fallback to the owner for run capsules. The
    else branch at mod.rs:1043 forces neutral. For a non-pooled run
    capsule
    it should substitute owner_principal (a network/timer event
    carries no principal, but there's exactly one legitimate owner). Keep neutral
    for pooled / non-run capsules — that's the genuine Per-principal capsule loading (content-addressed, off per-principal meta.json) — full principal isolation of the capsule/tool surface #1069 case. A real
    tool-call recv with its own principal must still scope to that principal;
    only the principal-less fallback changes.

(Exact placement is yours — the seam is "the run loop's HostState needs
ctx.principal's overlay installed, and its principal-less fallback should be
the owner rather than neutral.")

Missing test (acknowledged as an untested path)

No e2e covers a run-flagged capsule's run loop writing KV and reading it back.
Minimal regression:

  • Install a run capsule for principal P.
  • Run loop writes key K.
  • (a) A tool call as P reads K → expect the value (today: empty).
  • (b) Reload the capsule → K survives (today: gone).

Note on the reporting environment

The capsule that surfaced this runs a hand-rolled dual-role workaround (tools
serviced manually from run(), since #[astrid::tool] + run isn't supported
end-to-end — separate finding). That workaround does not cause this: the
neutral write happens on the pure inbound path before any tool dispatch, and
the root cause is the missing overlay install on the run loop's HostState,
which is independent of how tools are serviced.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/capsuleastrid-capsule: WASM sandboxing and executionarea/runtimeastrid-runtime: agent execution enginearea/storageastrid-storage: persistence layerbugSomething isn't workingp1High — blocks meaningful progresssecuritySecurity issue or hardening

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions