You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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
Install a run-flagged capsule for a single principal P.
From its run loop (no tool call in flight), kv::set("k", v).
Call any tool as P that does kv::get("k") → returns empty.
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/)
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.
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.
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.
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 validScopedKvStore — 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)
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.
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.
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 amisroute, 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
HostStatebefore the guestrunexport 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):
resolve
{principal}:capsule:{id}; the run loop wrote neutral).work-state all reset. A relay-backed capsule replays from
since=0every load.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
run-flagged capsule for a single principalP.runloop (no tool call in flight),kv::set("k", v).Pthat doeskv::get("k")→ returns empty.kis 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/)Run loop invokes
runwith no overlay install.mod.rs:2334-2359: thespawned task locks
run_store, resolves the typedrunfunc (:2337), andcalls
typed.call_async(&mut *s, ())(:2354). Noinstall_principal_overlays(...)runs before it, so the guest executesagainst a
HostStatewhoseinvocation_kvis still the load-time default.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, soinvocation_kvstarts
None.effective_kv()falls back to the neutral placeholder.host_state_effective.rs(effective_kv):self.invocation_kv.as_ref().unwrap_or(&self.kv). Withinvocation_kv = Noneit returns
self.kv— theneutral.fail-closed:capsulestore(
host_state.rs:~758-761), which is in-memory and a differentnamespace than any tool read.
The owner is known but never applied to this path.
owner_principal = ctx.principalis captured atmod.rs:2376, and theinterceptor 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 validScopedKvStore— the neutral one — sokv::setsucceeds with no error channel triggered. The bytes land in thein-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) setsinvocation_kvto the caller's scope — and that scope then lingers forsubsequent 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 nevergoes through the instance pool (
mod.rs:157-164, andself.pool = Noneatmod.rs:2367-2370). It has exactly one owner —ctx.principal, the installingprincipal. The content-hash-shared, load-under-
PrincipalId::default()path that#1069 guards is the pooled / non-run path (
pool.rs:521, 606), which runcapsules 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)
Install the owner overlay as the run loop's baseline. Before
typed.call_asyncatmod.rs:2354, runinstall_principal_overlays(&mut host_state, Some(&ctx.principal))on theHostStateinsiderun_store, soinvocation_kv = Some({owner}:capsule:{id})is the standing scope for the run loop's own host-calls.
Resolve the principal-less fallback to the owner for run capsules. The
elsebranch atmod.rs:1043forces neutral. For a non-pooled runcapsule it should substitute
owner_principal(a network/timer eventcarries 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
HostStateneedsctx.principal's overlay installed, and its principal-less fallback should bethe 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:
runcapsule for principalP.K.PreadsK→ expect the value (today: empty).Ksurvives (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]+runisn't supportedend-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.