swactor/crates/simulation/BLOCKED.md

17 KiB
Raw Blame History

Simulator BLOCKED — Stage 6 / §10.2 / §4.4 contracts unfulfilled

This file is filed per TESTING_SPEC §14. The local v1 parity bar is green for the surface this implementation chose to bind to, but three load-bearing contracts named by the plan and TESTING_SPEC have not been satisfied at full scope. The remaining work is not a v1 "out-of-scope" item under TESTING_SPEC §15, so the only honest artifact to ship is this BLOCKED.md plus the qualified BUILD_REPORT.md next to it.

Iteration 14 fixed one of the four findings (Finding D — facade surface descriptor now derived from trait declarations by build.rs); the other three remain open.

Note on layout (added in path-refresh pass): since this file was first filed, the workspace consolidated several crates (runtime-facade, sim-detector, sim-driver, lint-deterministic) into crates/simulation (PRs #39, #50, #52). Paths below reflect the post-consolidation layout. The underlying contracts and the substantive gaps they call out are unchanged.

Symbol Was Now
Runtime facade traits crates/runtime-facade/src/lib.rs crates/simulation/src/runtime/mod.rs
Surface-lock build crates/runtime-facade/build.rs crates/simulation/build.rs
Surface fingerprint crates/runtime-facade/surface.lock crates/simulation/surface.lock
Lint config crates/lint-deterministic/banned.toml crates/simulation/banned.toml
Lint scanner crates/lint-deterministic/src/lib.rs crates/simulation/src/lint.rs
Detector probes crates/sim-detector/src/lib.rs crates/simulation/src/detector.rs
Sim binary entry crates/sim-driver/src/main.rs crates/simulation/src/bin/sim-driver.rs

A. plan §"Stage 6 — TIER CHECKPOINT" — distribution migration

Contract

Migrate crates/distribution to the facade: every direct Instant, SystemTime, tokio::spawn, tokio::time::sleep, rand::thread_rng, HashMap iteration, env::var, std::fs, std::net use is rewritten against the runtime facade. Lint scope widens to include distribution; scan reports zero violations.

What's actually happening

The lint scanner's audit scope (crates/simulation/banned.toml:: [scope]::include) is exactly one directory:

include = [
    "crates/simulation/src",
]

crates/distribution is not in scope. The crate still calls the real runtime directly across at least these files:

  • crates/distribution/src/swim/member_list.rs
  • crates/distribution/src/diagnostics/aggregator.rs
  • crates/distribution/src/diagnostics/sink.rs
  • crates/distribution/src/diagnostics/probes.rs
  • crates/distribution/src/diagnostics/host_introspect.rs
  • crates/distribution/src/diagnostics/collector/udp_echo.rs
  • crates/distribution/src/diagnostics/collector/state.rs
  • crates/distribution/src/diagnostics/collector/handlers.rs
  • crates/distribution/src/cache.rs
  • crates/distribution/src/iroh_driver.rs
  • crates/distribution/src/peer_auth.rs
  • crates/distribution/src/node_metadata.rs
  • crates/distribution/src/kademlia/repair.rs
  • crates/distribution/src/kademlia/directory.rs

These call sites use tokio::spawn, tokio::time::sleep, std::time::Instant, std::time::SystemTime, rand::thread_rng, std::net::UdpSocket, and std::collections::HashMap directly.

Live-fire reproduces the gap: drop a use std::time::SystemTime; into crates/distribution/src/_violator_test.rs; the lint scan still exits 0, because that directory is not in scope.

The problem, plainly

The whole point of the runtime facade is so that peer code becomes a pure function of (spec, seed) — same inputs produce byte-identical bundle outputs, in prod and in sim. Every direct Instant::now(), tokio::spawn, or HashMap iteration in distribution breaks that guarantee on the production diagnostics path: the diagnostics aggregator's per-peer state lives in a HashMap, so its iteration order varies run-to-run with hash randomisation. A replay can't reproduce a recorded run if a HashMap::iter shows up between input and output.

Until distribution is migrated, the "sim and prod are the same code with a different runtime" claim only holds for the simulation crate itself. The peer code that actually does the work is exempt.

Potential action items

The migration is big and has a forced order. A reasonable plan:

  1. Grow the facade Spawn surface to be async-capable. The current trait (crates/simulation/src/runtime/mod.rs:91) only takes Box<dyn FnOnce() + Send + 'static>. Distribution's diagnostics pipeline is built on tokio::spawn(async move {...}). Add fn spawn_future(&self, fut: BoxFuture<'static, ()>) (or equivalent), implement on runtime::prod with tokio::spawn and on runtime::sim against the engine's fiber queue. This is a surface.lock bump.

  2. Decide on the HashMap policy and apply it crate-wide. Replace prod-path HashMap / HashSet with IndexMap / IndexSet (preserves insertion order, fast) or BTreeMap / BTreeSet (preserves sort order, slower but no extra dep). Aggregator, member-list, and kademlia routing-table state are the highest-leverage call sites — diagnostics aggregator first because it's the largest source of replay divergence.

  3. Migrate the call sites in waves. Suggested order, smallest blast radius first:

    • cache.rs and node_metadata.rs (mostly time reads).
    • peer_auth.rs (time + RNG).
    • swim/member_list.rs (spawn + time + map iteration).
    • kademlia/ (spawn + time + map iteration).
    • diagnostics/* (spawn + time + UDP + map iteration — biggest).
    • iroh_driver.rs (depends on Finding C's facade growth — defer).
  4. Widen lint scope. Add "crates/distribution/src" to banned.toml::[scope]::include and fix the violations the scanner reports until it exits 0. Do this last, after the migration waves above land — otherwise the scanner blocks every intermediate commit.

  5. Add a live-fire regression test. After (4) is green, drop a throwaway use std::time::SystemTime; somewhere in distribution/src/ and confirm the scanner catches it; revert. Record the result in the iteration notes.

This is a multi-iteration project. Budget at least three.


B. TESTING_SPEC §10.2 — detector must run on the sim facade

Contract

The detector binary must run inside the reference scenario as an additional peer.

What's actually happening

crates/simulation/src/facade/sim/bundle.rs:81 calls crate::detector::run_all() synchronously from inside the bundle writer. The detector code lives at crates/simulation/src/detector.rs and every D01–D12 probe body reaches for the real std::* / tokio surface (SystemTime::now(), Instant::now(), std::thread::spawn, std::net::UdpSocket, std::net::TcpStream, File::open("/dev/urandom"), gethostname, pid, allocator addresses, ThreadId). Each violation is silenced with an inline // lint-deterministic: allow … marker.

The detector is therefore observing the host runtime, not the sim runtime. techniques.md admits this verbatim for D01 ("The v1 sim does not intercept std::time::*; the detector running in-process therefore reads the host wall clock") and D02 ("v1 reads the host directly, so the two deltas are microseconds apart in practice").

t_detector::sim_indistinguishable consequently passes whether or not the sim leaks — there is no sim facade between the probe and the host to leak through.

The problem, plainly

NORTH_STAR's load-bearing claim is "no means, statistical or otherwise, of detecting that it is in a sim." The test that's supposed to enforce that claim is currently a no-op: the probes are literally not pointed at the simulated runtime. It's the same as running an integrity test against cat and concluding the data is intact — there's no system under test.

The lint-deterministic: allow markers are load-bearing audit evidence that this work was not done. Removing them without doing the work would just move the failure from a semantic check to a mechanical one.

Potential action items

Two changes have to land together; doing only one of them is worse than doing neither (it advertises a guarantee that isn't there).

  1. Make the sim backend actually intercept the std/tokio surface. crates/simulation/src/runtime/sim.rs (133 lines) is mostly stubs. Concretely:

    • Clock::now() must return a virtual-time-derived SystemTime, not pass through to SystemTime::now().
    • Udp::bind must return a socket whose send_to / recv_from ride the engine's link graph instead of OS sockets.
    • Spawn must enqueue onto the engine's fiber scheduler instead of tokio::spawn.
    • ProcessMeta::hostname / pid must return per-node sim values, not the host's.
  2. Route the detector probes through the facade. Open crates/simulation/src/detector.rs and, for each probe:

    • Replace SystemTime::now() with facade.clock().now().
    • Replace Instant::now() with facade.clock().monotonic().
    • Replace std::thread::spawn with facade.spawn().spawn(...).
    • Replace std::net::UdpSocket::bind with facade.udp().bind(...).
    • Drop the // lint-deterministic: allow … markers as each site converts. By the end the file should be allow-marker-free.
  3. Run the detector as a real peer. Add a host of kind detector to the reference scenario spec. In the bundle writer (crates/simulation/src/facade/sim/bundle.rs:79–88), delete the in-process detector::run_all() call. Instead, have the engine schedule a fiber on that host that runs the same probes via the sim facade and writes its verdict stream into the bundle just like any other host's records.

  4. Tighten the test. Once the above is in, modify the sim facade in a controlled way to leak (e.g., have Clock::now() return wall-clock instead of virtual). The relevant probe verdict should flip from Indistinguishable to DetectedSim and t_detector::sim_indistinguishable should fail.

This depends on Finding A only for the Spawn async surface growth, so it can begin in parallel with A's migration waves.


C. plan §"Stage 6 — TIER CHECKPOINT" — real transports on the sim facade

Contract

Real quinn linked against sim UDP — no QUIC fork. Real iroh stack (MagicSock, discovery, NodeMap, connection cache) linked against sim facade — no iroh fork.

And TESTING_SPEC §5.2:

The same quinn / iroh / iroh-relay / distribution / swactor source files run in both binaries.

What's actually happening

crates/simulation/src/engine.rs (885 lines) is a discrete-event record synthesiser. Its imports are std::cmp::Ordering, std::collections::{BTreeMap, BTreeSet, BinaryHeap}, serde::{Deserialize, Serialize}, and types from crate::spec. grep -n 'quinn\|iroh\|distribution\|swactor' crates/simulation/src/engine.rs returns zero matches.

The engine emits HostStart / HostStop / HostCrash / Snapshot / Mutation events on a synthetic loopback host and writes them to the bundle. The corpus is shaped to look like real peer-code output, but no production peer code is in the call graph.

(Update from the original BLOCKED.md: the sim-driver binary used to pin #[used] static references to defeat dead-code stripping — the t_same_binary::symbol_overlap test exists to detect that gimmick. The pin gimmick was removed during the consolidation; symbol_overlap is now listed as removed in tests/parity-bar/expected_failures.txt:33. The deeper claim is unchanged: peer code is not exercised through the sim engine.)

The problem, plainly

§5.2's whole point is that there should be one implementation of QUIC, one implementation of iroh, one implementation of distribution — and the only difference between prod and sim is which backend implements the runtime facade. If the sim binary runs synthesised events that mimic peer-code output, then the sim is not testing peer code — it's testing the simulator's model of peer code, which is exactly the thing the parity bar is supposed to make unnecessary.

This is why the dependency chain matters: real quinn needs an async UDP socket, real iroh needs quinn plus a tokio runtime and DNS, real distribution needs iroh::Endpoint. Each layer sits on the one below it.

Potential action items

This is the largest body of work in the BLOCKED set. A reasonable sequencing:

  1. Async UDP on the facade. Grow traits::Udp / traits::UdpSocket (crates/simulation/src/runtime/mod.rs:64– 73) from the current sync bind / send_to / recv_from shape to something that implements quinn::AsyncUdpSocket. In prod this wraps tokio::net::UdpSocket; in sim it wraps a queue tied to the engine's link graph and clock. surface.lock bump.

  2. Virtual tokio::time::Instant projection. iroh's internals call tokio::time::Instant::now() and tokio::time::sleep directly (it does not consult our facade). Either (a) build a sim-tokio runtime so tokio::time reads from the engine's virtual clock when iroh is linked into the sim binary, or (b) accept that iroh-as-shipped can't run under the sim and patch iroh upstream. (a) is the path TESTING_SPEC §5.2 implies.

  3. Async Spawn. Same growth as Finding A (1); A and C share this prerequisite.

  4. DNS table in sim. traits::Dns (crates/simulation/src/runtime/mod.rs:85) needs to answer relay-URL queries from a per-node DNS table the spec configures, not from the host resolver. iroh's relay discovery won't work otherwise.

  5. Build a quinn::Endpoint from facade pieces. Once (1) and (2) are in, construct a quinn::Endpoint from the facade's UDP socket and feed it to iroh's MagicSock setup.

  6. Re-point distribution. Wire distribution's transport construction off direct iroh::Endpoint::builder() calls and onto the facade-built endpoint. (Finding A's migration is a precondition.)

  7. Replace synthetic engine emission with real peer-code execution. This is the big one. engine.rs currently is the event source; under (6), real peer code becomes the event source and the engine becomes scheduling + link graph + clock. Most of engine.rs's synthetic emit logic deletes; the schema-floor parity bars then bind to events real code produced.

  8. Re-enable the parity-bar checks currently in expected_failures.txt. t_replay::*, the §6/§7 schema coverage suite, and t_same_binary::shared_load_bearing are parked behind Stage 6/7. They light up as the real-peer-code path comes online.

Expect this to span four to six iterations. Don't try to land it as one PR.


D. TESTING_SPEC §4.4 — facade surface lock — fixed in iteration 14

Contract

Each trait's method signatures hashed in declaration order.

Resolution

Was: SURFACE_DESCRIPTOR = include_str!("surface_descriptor.txt"), a 38-line hand-edited file. A trait edit that did not also touch the text file passed the lock unchanged.

Now: crates/simulation/build.rs parses crates/simulation/src/runtime/mod.rs with syn at build time, walks pub mod traits, emits one line per trait method signature into $OUT_DIR/surface_descriptor.txt, and the runtime module embeds the build output via include_str!. Live-fire verified: adding fn stealth_method(&self) -> u32 to pub trait Clock flips the descriptor hash and fails live_fingerprint_matches_locked. Reverted.

This finding is closed. Listed here for the record because the other three are open against the same TIER CHECKPOINT.


What the next agent should do

Either:

  1. Pick up the chain at A → B → C in order. A is the prerequisite for the lint widening; A's facade growth (async Spawn) and B's sim-facade interception together unlock C's real-transport linkage. Plan on multiple tier-checkpoint-sized iterations; this is not a single-iteration cleanup.

  2. Raise a TESTING_SPEC defect under §14.2 if a specific check is itself wrong — e.g., if §10.2's "additional peer" wording proves impractical given the workspace constraint no-modify src/ and the facade should instead intercept at the bundle-writer boundary. A defect would need to quote NORTH_STAR / SPEC / OBSERVABILITY and ship as a separate commit titled TESTING_SPEC: correct §N.M per §14.

What the next agent should not do is widen the lint-deterministic: allow markers, leave the engine wholly synthetic, or paper over symbol-presence checks. The bar in this repo is what the spec says.