Chidori

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. RuntimePolicy gained fs, crypto, timers fields (FsPolicy/CryptoPolicy/TimerPolicy in src/runtime/snapshot.rs), all #[serde(default)] for back-compat, durable default Captured/Captured/Virtual, and ensure_durable_safe rejects the Host variants. src/runtime/capability.rs holds the Capability enum and CapabilityLedger; RuntimeContext::note_capability raises flags and mirrors them to the OTEL run span (RunSpan::record_capability). The ledger is emitted on SnapshotManifest.capabilities.
  • Phase 2 — VFS. src/runtime/vfs.rs is a BTreeMap-backed, snapshot-resident tree (base64-serialized bytes, logical mtimes). It rides SnapshotManifest.vfs and is restored on resume via with_replay_host_promises_and_vfs (wired at the two server resume sites). node:fs + node:fs/promises shims call __chidori_fs_* natives. VFS ops are not call-logged (state rides the snapshot); they only raise Fs* flags. Host seed via CHIDORI_VFS_SEED.
  • Phase 3 — crypto. globalThis.crypto (prelude) + node:crypto shim. Hashing/HMAC (src/runtime/crypto.rs) run inline, flagged CryptoHash, never logged. Randomness is captured: recorded into the call log as crypto.random and replayed verbatim (CryptoPolicy::Seeded derives from the run seed; Captured draws the host CSPRNG; both record). Flagged CryptoRandom.
  • Phase 4 — timers. A deterministic virtual timer queue (prelude, TIMER_VIRTUAL_POLYFILL) fires setTimeout/setInterval/setImmediate in (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) drives Date.now() under DatePolicy::Fixed. Flagged Timer/Microtask. timers=disabled throws.

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" and export default … are now supported (binding_statement / export_statement, keyed on a module-namespace .default). This was a prerequisite — the pre-existing process/buffer/util shims used export default and so were never actually runtime-loadable.
  • Encoding + Buffer correctness. Added TextEncoder/TextDecoder/ atob/btoa (TEXT_ENCODING_POLYFILL) — the QuickJS runtime ships none, and node:buffer/node:fs need them. The node:buffer shim's toString/from now handle utf8/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>.js so a shim importing another builtin (e.g. fsbuffer) resolves consistently.
  • VFS and chidori.workspace stay separate stores. The design floated routing node:fs through the workspace backing store. They are kept distinct: chidori.workspace is 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 ordered CallLog is the replay source of truth.
  • RuntimeContext (src/runtime/context.rs) — owns next_seq(), try_replay_checked(seq, function), and record_call(...). A call either replays from replay_log by seq or 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 via ensure_compatible. Policy is the knob that switches a surface between Disabled, deterministic, and captured.
  • snapshot_policy_prelude — the JS prelude string that installs/curtails globals. New globals (crypto, timer functions, node:fs shims) are installed here or via the node: builtin shim layer.
  • The host-call bridge — JS pushes onto globalThis.__chidori_host_calls and 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, and setTimeout/setInterval/setImmediate (+ their clears) and queueMicrotask to agent code.
  • Every nondeterministic operation is captured: recorded on first execution, replayed by seq on 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 | Host modes mirror the existing DatePolicy / RandomPolicy shape, with Captured the durable default.

Non-Goals

  • No node:net, node:child_process, node:worker_threads, node:dns, or node: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 internal globalThis.__chidori_http native. There is no public chidori.http: the stdlib net surface is the public API. globalThis.fetch (+ Headers/Request/Response) and the node:http/node:https client 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. See FETCH_POLYFILL in runtime::typescript::helpers (installed by the rust engine after install_chidori_effects) and the HTTP_SHIM/HTTPS_SHIM in runtime::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 the builtins.rs philosophy.
  • No streaming fs handles serialized across snapshot (createReadStream open 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 result

Three categories, because not everything that looks nondeterministic is:

  1. 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.
  2. Nondeterministic, capturable nowcrypto.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.
  3. Async + suspendablesetTimeout callbacks that may straddle a snapshot boundary. Modeled as a PendingHostOperation in the HostPromiseTable so 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 the PolicyApproval pause 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_path logic already in resolver.rs generalizes here) and rooted at a virtual /. No path escapes the VFS root; .. past root clamps, it does not reach host disk.
  • mtime/atime are logical — derived from the effect seq, not wall-clock — so stat output is deterministic. Times render through the same fixed/seeded clock as Date (see Timers).
  • Iteration order is BTreeMap order so readdir is 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_SEED channel (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 mirrors chidori_agent_env_json().
  • The existing chidori.workspace bridge (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:fs calls should route to the same backing store as chidori.workspace so 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 + flagged CryptoRandom/CryptoKeygen.
  • crypto.subtle.digest, createHash, createHmac, createCipheriv over caller-supplied key+IV+data → category 1, pure-in-VM, inline, flagged CryptoHash (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 verbatim

CryptoPolicy = 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: u64 lives in the context, initialized from the DatePolicy base (epoch 0 under Fixed). Date.now() reads it. This unifies Date and timers under one clock — today Date is 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_ms to the earliest pending timer's deadline and fires it. No real sleeping occurs — a setTimeout(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 restored logical_now_ms.
  • Each scheduled timer raises capability flag Timer. queueMicrotask raises Microtask.
  • A timer that is pending across a suspend is represented as a PendingHostOperation of a new kind PendingHostOperationKind::Timer, so it flows through the same HostPromiseTable suspend/resume path that prompt and http already use. Its "completion" is the clock reaching due_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_default sets fs: Captured, crypto: Captured, timers: Virtual.
  • ensure_durable_safe rejects Host for all three (consistent with the existing date=host / random=host rejection).
  • ensure_compatible gains the three fields; older snapshots without them deserialize via #[serde(default)] to the durable defaults so existing stored runs stay loadable.

Replay & determinism rules

  1. A captured effect is keyed by seq from the single global sequence. Replay matches seq + function; a mismatch aborts with the existing try_replay_checked error ("re-run without replay to regenerate").
  2. 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.
  3. 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).
  4. 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) and BUILTIN_NAMES (builtins.rs:19) — the comment there already notes the two must stay in sync.
  • Add a shim in builtins.rs that re-exports the installed globals / host ops, throwing a clear error on unimplemented surface (match existing process/buffer/util style).
  • 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 and host_operation_kind mapping.

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: readdir order, stat times, ..-clamping, large file round-trip through base64 in the call log.
  • Crypto: Captured randomness replays verbatim; Seeded reproduces from seed; createHash is inline and not call-logged; Host is rejected by ensure_durable_safe.
  • Timers: logical clock fast-forward order; setInterval fires N times deterministically; a timer pending across snapshot fires correctly after restore; clock advances Date.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

  1. Policy + capability ledger scaffold — add the three policy fields (defaulted, back-compat), the Capability enum, note_capabilities, and manifest/OTEL emission. No new surface yet. Lands safely; everything still rejects.
  2. VFS — backing store, snapshot serialization, node:fs shim, wire to chidori.workspace. Capability flags Fs*.
  3. CryptoglobalThis.crypto + node:crypto, inline hashing, captured randomness, CryptoPolicy.
  4. Timers — logical clock unification with DatePolicy, virtual timer queue, snapshot-resident pending timers, PendingHostOperationKind::Timer.
  5. 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.subtle async: 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 setInterval cleared in code that ran pre-snapshot vs. an id restored post-snapshot — confirm clearInterval by id is stable across the serialize boundary.
  • Capability-based blocking: should any flag (e.g. CryptoKeygen) default to a PolicyApproval pause, or is visibility-only the v1 contract? (Leaning: visibility-only; blocking is opt-in policy.)