Captured Effects: Virtual Filesystem, Crypto, and Timers
Implementation status (Phases 1–4 landed)
This design is implemented. It originally shipped on the QuickJS path; the
pure-Rust chidori-js engine now runs the same surface (node: crypto/fs/timers,
TextEncoder, Web Crypto, virtual timers), reusing the same shim sources and
polyfills and capturing through the same RuntimeContext call log + VFS — so a
captured-effects agent records and replays identically.
What shipped, by phase:
- Phase 1 — policy + capability ledger.
RuntimePolicygainedfs,crypto,timersfields (FsPolicy/CryptoPolicy/TimerPolicyinsrc/runtime/snapshot.rs), all#[serde(default)]for back-compat, durable defaultCaptured/Captured/Virtual, andensure_durable_saferejects theHostvariants.src/runtime/capability.rsholds theCapabilityenum andCapabilityLedger;RuntimeContext::note_capabilityraises flags and mirrors them to the OTEL run span (RunSpan::record_capability). The ledger is emitted onSnapshotManifest.capabilities. - Phase 2 — VFS.
src/runtime/vfs.rsis aBTreeMap-backed, snapshot-resident tree (base64-serialized bytes, logical mtimes). It ridesSnapshotManifest.vfsand is restored on resume viawith_replay_host_promises_and_vfs(wired at the two server resume sites).node:fs+node:fs/promisesshims call__chidori_fs_*natives. VFS ops are not call-logged (state rides the snapshot); they only raiseFs*flags. Host seed viaCHIDORI_VFS_SEED. - Phase 3 — crypto.
globalThis.crypto(prelude) +node:cryptoshim. Hashing/HMAC (src/runtime/crypto.rs) run inline, flaggedCryptoHash, never logged. Randomness is captured: recorded into the call log ascrypto.randomand replayed verbatim (CryptoPolicy::Seededderives from the run seed;Captureddraws the host CSPRNG; both record). FlaggedCryptoRandom. - Phase 4 — timers. A deterministic virtual timer queue (prelude,
TIMER_VIRTUAL_POLYFILL) firessetTimeout/setInterval/setImmediatein(deadline, id)order via a self-rescheduling microtask pump, so timers run inside the engine's existing job drain with no real sleeping. A logical clock (globalThis.__chidori_now) drivesDate.now()underDatePolicy::Fixed. FlaggedTimer/Microtask.timers=disabledthrows.
Deviations and enabling work not in the original design:
- Bundler default import/export. The snapshot bundler only supported named
/ namespace / type-only imports and named exports.
import fs from "node:fs"andexport default …are now supported (binding_statement/export_statement, keyed on a module-namespace.default). This was a prerequisite — the pre-existingprocess/buffer/utilshims usedexport defaultand so were never actually runtime-loadable. - Encoding + Buffer correctness. Added
TextEncoder/TextDecoder/atob/btoa(TEXT_ENCODING_POLYFILL) — the QuickJS runtime ships none, andnode:buffer/node:fsneed them. Thenode:buffershim'stoString/fromnow handleutf8/hex/base64/latin1/ascii(previously only base64 + UTF-8 decode, which corrupted binary and could emit lone surrogates). - Builtin paths are project-root-independent.
node:builtins resolve to a fixed/__node_builtins__/<name>.jsso a shim importing another builtin (e.g.fs→buffer) resolves consistently. - VFS and
chidori.workspacestay separate stores. The design floated routingnode:fsthrough the workspace backing store. They are kept distinct:chidori.workspaceis host-mediated real disk, while the VFS is an in-memory sandbox. Unifying them would force either the VFS onto real disk (breaking determinism) or workspace into memory (breaking its real-disk contract), so the captured-effects VFS is its own snapshot-resident store. - Capability gating is visibility-only (v1). Flags are surfaced on the
manifest and OTEL spans but never block; approval gating on a flag (e.g.
CryptoKeygen) is left as opt-in future policy, per the Open Questions.
Cross-suspend timers — resolved, no extra machinery needed. Tracing the
pump showed there is effectively never a timer pending at a suspend point:
timers fire on the logical clock with no real delay, so the self-rescheduling
microtask pump drains the entire timer queue to empty during drain_jobs
before any host-operation block returns control. Both resume paths are
therefore correct without serializing timer state:
- Call-log replay re-runs the agent from the top; deterministic execution
reschedules and re-fires the same timers while host calls replay from the
log. Validated by
timers_coexist_with_recorded_host_calls_and_replay_identically. - Live-VM resume restores the heap (the pump closure and its task array) and
the pending microtask queue (the fork's
snapshot_microtask_queue/restore_microtask_queue), so any in-flight pump continuation survives.
JS timer callbacks are closures and so are intrinsically not serializable into
the JSON manifest — which is also why the manifest path relies on re-execution
rather than carrying the queue. PendingHostOperationKind::Timer remains
reserved for a hypothetical future where a timer's effect (not its callback)
must be modeled as a host operation; it is unused today.
Purpose
Today the Chidori snapshot runtime makes nondeterministic and host-reaching
JavaScript surfaces unavailable. snapshot_policy_prelude (src/runtime/snapshot.rs)
hard-disables WeakRef, FinalizationRegistry, SharedArrayBuffer, and
Atomics; freezes Date to epoch 0; seeds or disables Math.random; and the
node: resolver (src/runtime/typescript/{resolver,builtins}.rs) allowlists a
fixed set of builtins — at the time of writing process, buffer, util,
fs, fs/promises, crypto, http, https, plus the pure-logic /
virtualized modules path (and path/posix), events, url, assert (and
assert/strict), and os. The pure modules (path/events/url/assert)
are deterministic by construction; os returns fixed virtualized constants in
the same spirit as process.platform. Anything outside the allowlist throws at
first use. The authoritative list is NODE_BUILTIN_ALLOWLIST (transpile.rs),
kept in sync with BUILTIN_NAMES (builtins.rs).
This document specifies the inverse policy for a specific class of surfaces: filesystem, crypto, and timers. Rather than rejecting them, the runtime will provide them, but every nondeterministic or host-reaching operation is routed through a captured effect — recorded into the call log on first run, replayed deterministically on resume, and surfaced as a capability flag on the run manifest so operators can see exactly what an agent touched.
The guiding rule the user asked for:
Anything we would otherwise reject must be supported, but its use must be flagged and the effect captured.
Background: machinery we already have
The capture/replay pipeline this design needs already exists for host calls
(prompt, tool, http, memory, …). We extend it rather than inventing a
parallel path.
CallRecord(src/runtime/call_log.rs) —{ seq, parent_seq, function, args, result, duration_ms, timestamp, error }. The orderedCallLogis the replay source of truth.RuntimeContext(src/runtime/context.rs) — ownsnext_seq(),try_replay_checked(seq, function), andrecord_call(...). A call either replays fromreplay_logbyseqor executes live and is recorded.HostPromiseTable/PendingHostOperation/PendingHostOperationKind(src/runtime/snapshot.rs) — models async host operations that can suspend across a snapshot/restore boundary. Effects that must survive a process restart while pending (notably timers) use this table.RuntimePolicy(src/runtime/snapshot.rs) —{ typescript_imports, date, random, maps_sets, deterministic_seed }, serialized into the snapshot and checked on resume viaensure_compatible. Policy is the knob that switches a surface betweenDisabled, deterministic, and captured.snapshot_policy_prelude— the JS prelude string that installs/curtails globals. New globals (crypto, timer functions,node:fsshims) are installed here or via thenode:builtin shim layer.- The host-call bridge — JS pushes onto
globalThis.__chidori_host_callsand awaits a host-resolved promise via__chidori_host_method_queues(src/runtime/snapshot.rs~line 2937). Captured effects reuse this exact bridge so they nest correctly under parent calls (parent_seq) and stream through the existing engine pump.
Goals
- Provide working
node:fs/node:fs/promises,node:crypto/globalThis.crypto, andsetTimeout/setInterval/setImmediate(+ their clears) andqueueMicrotaskto agent code. - Every nondeterministic operation is captured: recorded on first
execution, replayed by
seqon resume, byte-for-byte identical. - Every use of a captured surface raises a capability flag recorded on the run and surfaced in the snapshot manifest, regardless of whether the individual call was deterministic.
- Filesystem state is snapshot-resident: it survives suspend → restore and is identical on replay.
- No surface silently reaches the host OS. Real disk, real RNG, real wall-clock are reachable only through explicit, policy-gated, recorded effects.
- Policy-driven: each surface has
Disabled|Captured|Hostmodes mirror the existingDatePolicy/RandomPolicyshape, withCapturedthe durable default.
Non-Goals
- No
node:net,node:child_process,node:worker_threads,node:dns, ornode:http(s)server sockets in this work. These stay rejected for now. - Outbound HTTP is served by the first-class captured host op (
http), reached through an internalglobalThis.__chidori_httpnative. There is no publicchidori.http: the stdlib net surface is the public API.globalThis.fetch(+Headers/Request/Response) and thenode:http/node:httpsclient APIs (request/get) all route through__chidori_http, so every network call — including ones made inside a dependency — inherits its security-policy enforcement (allow / ask / deny), the approval-pause path, and record/replay. SeeFETCH_POLYFILLinruntime::typescript::helpers(installed by the rust engine afterinstall_chidori_effects) and theHTTP_SHIM/HTTPS_SHIMinruntime::typescript::builtins. - No real concurrent wall-clock scheduling. Timers are virtualized against a logical clock (see Timers); we do not run a real OS timer wheel.
- No POSIX completeness for the VFS (no permissions bits enforcement, symlink
loops resolution edge cases,
fs.watch, file locks). We cover the surface real packages touch and throw clearly on the rest, matching thebuiltins.rsphilosophy. - No streaming
fshandles serialized across snapshot (createReadStreamopen across a suspend). Streams are buffered/eager in v1.
Core concept: the Captured Effect
A captured effect is any operation whose result is not a pure function of in-VM state. It is mediated by a single helper shape on top of the existing context:
seq = ctx.next_seq()
if let Some(record) = ctx.try_replay_checked(seq, function)? {
return record.result // deterministic replay, no live side effect
}
let (result, flags) = execute_live(args)?; // the only place real host access happens
ctx.record_call(CallRecord { seq, function, args, result, .. });
ctx.note_capabilities(flags); // raise capability flags
return resultThree categories, because not everything that looks nondeterministic is:
- Pure-in-VM — e.g.
crypto.createHash('sha256').update(buf).digest(), reading a file already present in the VFS. Deterministic given VM state. These run inline, not captured for performance, but still raise a capability flag ("crypto used", "fs read") so the surface is visible. - Nondeterministic, capturable now —
crypto.randomBytes,randomUUID, reading the initial contents of a host-seeded file,Date.now()when the policy allows host time. Recorded into the call log on first run, replayed thereafter. - Async + suspendable —
setTimeoutcallbacks that may straddle a snapshot boundary. Modeled as aPendingHostOperationin theHostPromiseTableso a pending timer survives restore.
Capability flags
Add a capability ledger to RuntimeContext:
pub enum Capability {
FsRead, FsWrite, FsDelete,
CryptoHash, CryptoRandom, CryptoKeygen,
Timer, Microtask,
// future: Net, Subprocess, …
}note_capabilities accumulates a BTreeSet<Capability> with first-touch
seq/timestamp. It is:
- Emitted on the snapshot manifest (alongside
RuntimePolicy) so a stored run advertises its capability surface. - Emitted as OTEL span attributes via the span emitter
(
crates/chidori/src/runtime/otel.rs) — one attribute per capability, e.g.chidori.capability.crypto_random=true. - Returnable to the host (CLI/server) so a run can be gated: "this agent used
CryptoRandom; require approval" reuses thePolicyApprovalpause path.
A capability flag is monotonic and advisory for visibility; it never blocks
inline. Blocking, when desired, is a separate policy decision layered on top
(reuse PendingApproval).
Subsystem 1 — Virtual Filesystem (VFS)
Model
An in-memory tree owned by the runtime and part of snapshot state, not the
heap. A Vfs struct lives next to the call log in RuntimeContextInner:
struct Vfs {
nodes: BTreeMap<VfsPath, VfsNode>, // BTreeMap → deterministic iteration
}
enum VfsNode {
File { bytes: Vec<u8>, mtime_seq: u64 },
Dir,
}- Paths are normalized (the
normalize_pathlogic already inresolver.rsgeneralizes here) and rooted at a virtual/. No path escapes the VFS root;..past root clamps, it does not reach host disk. mtime/atimeare logical — derived from the effectseq, not wall-clock — so stat output is deterministic. Times render through the same fixed/seeded clock asDate(see Timers).- Iteration order is
BTreeMaporder soreaddiris stable across runs.
Seeding from host
The host may pre-populate the VFS before a run (analogous to how
CHIDORI_AGENT_ENV seeds process.env):
- A
CHIDORI_VFS_SEEDchannel (manifest path → bytes, or a tarball pointer) loads files at construction. This is the only host-disk touch, it happens before agent code runs, and the seed manifest hash is recorded into the snapshot so replay validates it didn't change. This mirrorschidori_agent_env_json(). - The existing
chidori.workspacebridge (globalThis.__chidori_workspace_*,snapshot.rs~line 127) is the precedent: workspace is already a host-mediated file surface. The VFS is its in-snapshot generalization;node:fscalls should route to the same backing store aschidori.workspaceso the two views stay consistent.
Snapshot integration
The VFS serializes with the snapshot. Because it is plain data
(BTreeMap<String, VfsNode>), it serializes deterministically and restores
identically. On resume:
- A read of a file present in the VFS is pure-in-VM → inline, flagged
FsRead, not call-logged (the bytes are already in restored state). - A write mutates VFS state and is flagged
FsWrite. Writes are not host effects (they never hit disk), so they need not be in the call log — the restored VFS already reflects them. They are recorded only if we want a per-write audit trail (optional; see Open Questions).
node:fs surface (v1)
Sync + promise variants of: readFile, writeFile, appendFile, readdir,
mkdir (incl. recursive), rm/unlink/rmdir, stat/lstat, existsSync,
rename, realpath. createReadStream/createWriteStream are buffered shims
over the above. Everything else throws ENOSYS-style clear errors. Register via
the node: builtin allowlist — add "fs" to NODE_BUILTIN_ALLOWLIST
(transpile.rs) and BUILTIN_NAMES (builtins.rs) with a shim that calls into
host-bound ops (__chidori_fs_*) the same way process delegates to globals.
Subsystem 2 — Crypto
Surface
Install globalThis.crypto (Web Crypto subset) and node:crypto:
crypto.getRandomValues,crypto.randomUUID,node:crypto.randomBytes,randomFillSync,randomInt, key generation → category 2, captured + flaggedCryptoRandom/CryptoKeygen.crypto.subtle.digest,createHash,createHmac,createCipherivover caller-supplied key+IV+data → category 1, pure-in-VM, inline, flaggedCryptoHash(deterministic given inputs). No capture needed; the same inputs always yield the same bytes.
Capture mechanism for randomness
Two valid strategies; we choose (b) as default and allow (a) via policy:
(a) Seeded deterministic RNG — extend the existing
RandomPolicy::Seeded PRNG (snapshot.rs ~line 926) to back crypto randomness
from the same deterministic_seed stream. Fully reproducible with no call-log
entries, but the output is not cryptographically strong and a replay on a fresh
seed diverges. Good for tests.
(b) Captured real randomness — first run draws from the host CSPRNG, the
exact bytes are written to the CallRecord.result, and replay returns those
bytes. Cryptographically strong and perfectly reproducible on resume. This is
the durable default and the reason "crypto should exist but trigger a flag the
runtime captures" — the flag is CryptoRandom and the captured value is the
random bytes themselves.
function = "crypto.randomBytes"
args = { "n": 32 }
result = { "bytes_b64": "…" } // captured; replayed verbatimCryptoPolicy = Disabled | Seeded | Captured | Host. Captured is durable
default; Host (uncaptured live randomness) is rejected by
ensure_durable_safe exactly like DatePolicy::Host / RandomPolicy::Host
are today.
Subsystem 3 — Timers
Timers are the hard case because a callback can be scheduled before a snapshot and must fire after restore. We virtualize time against a logical clock and model pending timers as snapshot state.
Logical clock
- A monotonic
logical_now_ms: u64lives in the context, initialized from theDatePolicybase (epoch 0 underFixed).Date.now()reads it. This unifiesDateand timers under one clock — todayDateis frozen independently; this work makes the frozen value advance as timers fire. - Advancing the clock is deterministic: it is driven by the timer queue, not by
wall-clock. When the microtask/macrotask queue is otherwise empty, the engine
fast-forwards
logical_now_msto the earliest pending timer's deadline and fires it. No real sleeping occurs — asetTimeout(fn, 5000)resolves immediately in wall-clock but the in-VM clock jumps 5000ms.
Timer queue as snapshot state
struct Timer {
id: u64,
due_ms: u64, // logical deadline
interval_ms: Option<u64>, // Some → setInterval
// the JS callback continuation is captured by the VM snapshot itself,
// keyed by a stable callback handle the engine restores.
}- The set of pending timers serializes into the snapshot (ordered by
(due_ms, id)for deterministic fire order). On restore, the queue is rebuilt and execution resumes firing from the restoredlogical_now_ms. - Each scheduled timer raises capability flag
Timer.queueMicrotaskraisesMicrotask. - A timer that is pending across a suspend is represented as a
PendingHostOperationof a new kindPendingHostOperationKind::Timer, so it flows through the sameHostPromiseTablesuspend/resume path thatpromptandhttpalready use. Its "completion" is the clock reachingdue_ms.
Policy
TimerPolicy = Disabled | Virtual | Host. Virtual (logical clock,
deterministic, default) and Host (real wall-clock; rejected for durable runs).
Disabled keeps today's behavior for agents that must not schedule.
Interaction with DatePolicy
DatePolicy::Fixed becomes the initial value of the logical clock rather than
a permanent freeze. DatePolicy::Disabled still throws. A migration note:
existing snapshots created under "Date frozen at 0, no timers" remain valid
because with an empty timer queue the clock never advances — behavior is
unchanged, so ensure_compatible does not break old snapshots that lack a timer
section (treat absent as empty).
Policy summary
Extend RuntimePolicy:
pub struct RuntimePolicy {
pub typescript_imports: TypeScriptImportPolicy,
pub date: DatePolicy,
pub random: RandomPolicy,
pub maps_sets: MapSetSnapshotPolicy,
pub fs: FsPolicy, // Disabled | Captured(default) | Host
pub crypto: CryptoPolicy, // Disabled | Seeded | Captured(default) | Host
pub timers: TimerPolicy, // Disabled | Virtual(default) | Host
pub deterministic_seed: String,
}durable_defaultsetsfs: Captured,crypto: Captured,timers: Virtual.ensure_durable_saferejectsHostfor all three (consistent with the existingdate=host/random=hostrejection).ensure_compatiblegains the three fields; older snapshots without them deserialize via#[serde(default)]to the durable defaults so existing stored runs stay loadable.
Replay & determinism rules
- A captured effect is keyed by
seqfrom the single global sequence. Replay matchesseq+function; a mismatch aborts with the existingtry_replay_checkederror ("re-run without replay to regenerate"). - Inline (pure-in-VM) effects must be byte-deterministic functions of VM state. If any doubt (e.g. a hash that incorporates a timestamp), it must be promoted to a captured effect.
- The VFS, logical clock, and timer queue are state, not effects — they ride the snapshot and are never re-derived from the call log. The call log carries only the boundary values (host-seeded reads, captured randomness, host time samples).
- Capability flags are derived, not authoritative for replay: recomputed on replay from the same operations, and asserted equal to the stored manifest set as a consistency check.
node: wiring checklist
For each new builtin (fs, crypto; timers are globals, not a node: module
but node:timers/node:timers/promises should alias the globals):
- Add the name to
NODE_BUILTIN_ALLOWLIST(transpile.rs:57) andBUILTIN_NAMES(builtins.rs:19) — the comment there already notes the two must stay in sync. - Add a shim in
builtins.rsthat re-exports the installed globals / host ops, throwing a clear error on unimplemented surface (match existingprocess/buffer/utilstyle). - Host ops (
__chidori_fs_readFile,__chidori_crypto_randomBytes, …) are registered the same way current host bindings are (host_core.rs), each going through the capture helper andhost_operation_kindmapping.
Testing
- Golden replay: run an agent that writes/reads files, draws random bytes,
and schedules timers; snapshot mid-run; restore in a fresh process; assert
identical final state and identical call log. (Mirror the existing
durable-run tests in
host_core.rs.) - VFS determinism:
readdirorder, stat times,..-clamping, large file round-trip through base64 in the call log. - Crypto:
Capturedrandomness replays verbatim;Seededreproduces from seed;createHashis inline and not call-logged;Hostis rejected byensure_durable_safe. - Timers: logical clock fast-forward order;
setIntervalfires N times deterministically; a timer pending across snapshot fires correctly after restore; clock advancesDate.now()consistently. - Capability ledger: each surface raises exactly its expected flags; flags appear on the manifest and OTEL span; replay recomputes the identical set.
- Back-compat: a snapshot serialized before these fields existed loads with defaulted policy and an empty timer queue, and replays unchanged.
Phasing
- Policy + capability ledger scaffold — add the three policy fields
(defaulted, back-compat), the
Capabilityenum,note_capabilities, and manifest/OTEL emission. No new surface yet. Lands safely; everything still rejects. - VFS — backing store, snapshot serialization,
node:fsshim, wire tochidori.workspace. Capability flagsFs*. - Crypto —
globalThis.crypto+node:crypto, inline hashing, captured randomness,CryptoPolicy. - Timers — logical clock unification with
DatePolicy, virtual timer queue, snapshot-resident pending timers,PendingHostOperationKind::Timer. - Hardening — approval gating on capability flags, fuzz the VFS path normalizer, parity tests against Node for the covered surface subset.
Open questions
- Per-write audit: do we call-log VFS writes for a forensic trail, or rely solely on snapshot state? (Leaning: snapshot only, optional debug-mode audit.)
- VFS seed size: bytes inline in the snapshot vs. content-addressed external blobs with hashes in the snapshot. Large seeds argue for the latter.
crypto.subtleasync: it returns Promises even for pure digests; confirm the inline path can resolve synchronously within the VM's job queue without a host round-trip.- Interval cancellation across snapshot: a
setIntervalcleared in code that ran pre-snapshot vs. an id restored post-snapshot — confirmclearIntervalby id is stable across the serialize boundary. - Capability-based blocking: should any flag (e.g.
CryptoKeygen) default to aPolicyApprovalpause, or is visibility-only the v1 contract? (Leaning: visibility-only; blocking is opt-in policy.)