Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .opencode/plans/2026-10-20_phase-11-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ This document audits the completion status of all eighteen Phase 11 tasks (F11.1
| **F11.13** | Error taxonomy | **Done** | [PR #808](https://github.com/developing-today/code/pull/808) | `CapError` now carries a stable `CapErrorCode` plus an advisory reason on the wire; `schedule.at` returns the `check_schedule` reason. Codes documented in `world_caps`. |
| **F11.14** | Cheaper `Screen` | **Done** | [PR #248](https://github.com/developing-today/code/pull/248) | `Screen.render_rows_json` reduces territory full-house (8 seats) view fuel from 7.1M to 1.6M (16% of 10M budget). |
| **F11.15** | Smaller modules | **Planned** | Tracked in roadmap | `wasm-opt -Os` and strip integration in example `build.sh` scripts. |
| **F11.16** | Replay self-check | **In Progress** | [PR #239](https://github.com/developing-today/code/pull/239) | `id world record` and `replay` fixtures with record-match verdicts landed; determinism check planned. |
| **F11.16** | Replay self-check | **Done** | [PR #239](https://github.com/developing-today/code/pull/239), [Issue #544](https://github.com/developing-today/code/issues/544) | `id world record`/`replay` fixtures landed in #239; `id world verify` double-replay determinism self-check landed for #544 (two replays compared step by step, first divergence reported; #242 host-event caveat documented). |
| **F11.17** | View trap resilience | **Planned** | Tracked in roadmap | Degrade trapped guest view calls to error screens without poisoning world state. |
| **F11.18** | Pinned toolchain for artifacts | **Planned** | Tracked in roadmap | Ensure checked-in `.wasm` artifacts are built by the pinned `nix/roc.nix` compiler. |

## Progress Summary

- **Completed Tasks (5)**: F11.1 (relay kit), F11.6 (fuel budgets), F11.11 (client projection), F11.14 (cheaper screen), and F11.12 initial fail-loud check (#244).
- **In Progress Tasks (4)**: F11.2 (Firefox E2E), F11.3 (LogSync flakes / #255), F11.4 (fast lane — lane split landed in #869, but the <15 min median exit is not yet met; corrected 2026-10-27, tracked in #551), F11.16 (replay fixtures / #239).
- **Completed Tasks (6)**: F11.1 (relay kit), F11.6 (fuel budgets), F11.11 (client projection), F11.14 (cheaper screen), F11.16 (replay self-check — `id world verify`, #544), and F11.12 initial fail-loud check (#244).
- **In Progress Tasks (3)**: F11.2 (Firefox E2E), F11.3 (LogSync flakes / #255), F11.4 (fast lane — lane split landed in #869, but the <15 min median exit is not yet met; corrected 2026-10-27, tracked in #551).
- **Planned / Next Tasks (9)**: F11.5 (injected time), F11.7 (host metrics), F11.8 (benchmarks), F11.9 (frame bytes), F11.10 (split large modules), F11.12 (journal CLI), F11.13 (error codes), F11.15 (smaller modules), F11.17 (view trap resilience), F11.18 (pinned toolchain).
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Concretely:

- `id world record NODE --capability CAP [--out FILE]` joins a world with a capability and writes its fixture.
- `id world replay --module FILE.wasm --fixture FILE --seed N [--allow-partial]` runs the fixture in the sandbox, prints a one-line verdict, and exits nonzero when the records differ.
- `id world verify --module FILE.wasm --fixture FILE --seed N [--allow-partial]` (F11.16, #544) replays the fixture twice and compares the records after every event the program sees, prints a one-line verdict first, reports the first diverging record (sequence, key, and both values), and exits nonzero at the first divergence. Where `replay` checks one run against the header's records, `verify` checks two fresh runs against each other, so it does not trust the header and also catches a divergence that appears mid-stream and later reconverges.

## Format

Expand Down Expand Up @@ -38,10 +39,11 @@ JSON Lines. Line 1 is the header; each following line is one retained, sequenced
6. **Replay uses the live path.** Input and system events go through `WorldProgram::update`, the same call the actor makes, so Input keeps its UTF-8 check and a host event is delivered as participant 0. Chat is not passed to the program.
7. **Verdicts.** `Match` and `Mismatch` compare the header's records with the replayed ones as parsed maps, so key order and whitespace do not matter. A partial window, or a fixture with no records, is `NotCompared` with a reason. Only `Mismatch` exits nonzero.
8. **Partial replay needs an explicit flag.** An incomplete fixture is refused unless `--allow-partial` is set, and its result is never compared.
9. **Verify compares two replays, never the live world.** `verify` reuses the replay path twice under the same seed and compares step by step (`world_fixture::verify` / `first_divergence`). Reading `records` per step is safe: it is a pure function of state, and the sandbox resets fuel per guest call. A partial window verified with `--allow-partial` is still compared run-to-run; only the header comparison is skipped.

## Limits

- **Host events are not in the stream.** Presence changes, capability results, and ticks reach the program as host events, but the actor never retains them in the event stream, so a recording cannot include them. A program that consumes them (`territory` consumes presence) replays to different records, and the header's records check reports `Mismatch`. A fixture is a faithful replay only for programs that do not depend on host events. The check catches this, but the fixture cannot repair it.
- **Host events are not in the stream.** Presence changes, capability results, and ticks reach the program as host events, but the actor never retains them in the event stream, so a recording cannot include them. A program that consumes them (`territory` consumes presence) replays to different records, and the header's records check reports `Mismatch`. A fixture is a faithful replay only for programs that do not depend on host events. The check catches this, but the fixture cannot repair it. `verify` inherits this limit (#242): both of its replays omit host events equally, so verify can pass for a guest whose live behaviour depended on them — it proves the recorded fixture replays deterministically, nothing more.
- **Recording joins the world.** The recorder's own presence is delivered to presence-aware programs, after the recorded head. The recording is unaffected, and the live world sees a join, as `logs` already does.
- **Retention bounds complete recordings.** `WorldLimits::default()` retains 1024 events. A longer history yields a partial window; it replays only with `--allow-partial`, and its result is not compared.
- **A partial window replays from a fresh instance.** It usually does not match the live state. Including the program's snapshot at the window's start would make partial windows exact (follow-up).
Expand Down Expand Up @@ -70,6 +72,8 @@ JSON Lines. Line 1 is the header; each following line is one retained, sequenced

Unit tests in `src/world_fixture.rs`: the round trip of every kind and of the null header variants; strict decode (unknown version, malformed lines, window errors); replay of the checked-in tic-tac-toe against the live instance's records; a partial fixture refused without `--allow-partial`; a reinstall window refused; a tampered header reported as `Mismatch`; a `territory` fixture that omits host events reported as `Mismatch`; the module check. An iroh test records a live world through `WorldClient` and replays it, and a second one records a trimmed history as incomplete. CLI parse tests cover `record` and `replay`.

For `verify` (F11.16, #544): unit tests cover `first_divergence` (identical sequences pass; the earliest step and first differing key are reported with both values; a key absent from one replay and an extra step are divergences), `verify` passes on the checked-in tic-tac-toe game, and a comparison-layer test double stands in for a deliberately nondeterministic guest — the sandbox gives guests no imports (no clock, no random source), so no checked-in guest can actually vary between instantiations; the double's records depend on a per-instantiation counter, the channel such a guest would use, and verify's comparison must catch it. A CLI parse test covers `verify`.

## References

- [Phase 9 plan](../../.opencode/plans/phase-9-portable-platform.md)
Expand Down
72 changes: 72 additions & 0 deletions pkgs/id/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1893,6 +1893,32 @@ pub enum WorldCommand {
#[arg(long)]
allow_partial: bool,
},
/// Verify a recorded fixture's determinism: replay it twice offline
/// through a module and compare the records after every event. Prints a
/// verdict line first; exits nonzero at the first divergence.
///
/// Verification covers the fixture's recorded events only. Host-originated
/// events are not retained in the event stream (#242), so a live recording
/// never contains them: a guest whose live behaviour depended on host
/// events is outside what verify can check.
#[cfg(feature = "sandbox")]
Verify {
/// The Wasm module the fixture was recorded under.
#[arg(long, value_name = "FILE")]
module: PathBuf,
/// Fixture written by `id world record`.
#[arg(long, value_name = "FILE")]
fixture: PathBuf,
/// Seed the module was installed with. The admin supplied it at install
/// time; the fixture does not carry it.
#[arg(long)]
seed: u64,
/// Replay a window that does not start at sequence 1. Its records are
/// not compared against the fixture's header, but the two replays are
/// still compared against each other.
#[arg(long)]
allow_partial: bool,
},
/// List the worlds a host serves (needs the host's admin token).
List {
/// The host's node ID (64 hex characters).
Expand Down Expand Up @@ -4174,6 +4200,52 @@ mod tests {
);
}

#[cfg(feature = "sandbox")]
#[test]
fn test_cli_parse_world_verify() {
let cli = Cli::parse_from([
"id",
"world",
"verify",
"--module",
"game.wasm",
"--fixture",
"run.jsonl",
"--seed",
"7",
]);
match cli.command {
Some(Command::World(cmd)) => match *cmd {
WorldCommand::Verify {
module,
fixture,
seed,
allow_partial,
} => {
assert_eq!(module, PathBuf::from("game.wasm"));
assert_eq!(fixture, PathBuf::from("run.jsonl"));
assert_eq!(seed, 7);
assert!(!allow_partial);
}
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
assert!(
Cli::try_parse_from([
"id",
"world",
"verify",
"--module",
"game.wasm",
"--fixture",
"run.jsonl",
])
.is_err(),
"a verify needs the seed the module was installed with"
);
}

#[test]
fn test_cli_parse_world_caps_ceiling() {
let node = "a".repeat(64);
Expand Down
60 changes: 60 additions & 0 deletions pkgs/id/src/commands/world.rs
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,13 @@ pub async fn cmd_world(command: WorldCommand) -> Result<()> {
seed,
allow_partial,
} => replay_fixture(&module, &fixture, seed, allow_partial),
#[cfg(feature = "sandbox")]
WorldCommand::Verify {
module,
fixture,
seed,
allow_partial,
} => verify_fixture(&module, &fixture, seed, allow_partial),
WorldCommand::List {
node,
admin_token,
Expand Down Expand Up @@ -1070,6 +1077,59 @@ fn replay_fixture(
Ok(())
}

/// Verify a fixture's determinism offline and print the verdict.
///
/// The verdict line comes first. When the two replays diverge, the first
/// diverging record is printed and the command fails, after the first
/// replay's records and view are printed.
///
/// # Errors
///
/// An unreadable or oversized file, a module the fixture was not recorded
/// under, a refused replay, or two replays that diverge.
#[cfg(feature = "sandbox")]
fn verify_fixture(
module: &std::path::Path,
fixture_path: &std::path::Path,
seed: u64,
allow_partial: bool,
) -> Result<()> {
let wasm = read_bounded(
module,
crate::world_session::MAX_WORLD_MODULE_BYTES as u64,
"module",
)?;
let text = String::from_utf8(read_bounded(
fixture_path,
crate::world_fixture::MAX_FIXTURE_BYTES,
"fixture",
)?)
.context("the fixture is not UTF-8")?;
let fixture = crate::world_fixture::decode(&text)?;
crate::world_fixture::check_module(&fixture.header, &wasm)?;
let runner = crate::sandbox::Sandbox::compile(&wasm, crate::sandbox::SandboxLimits::default())?;
let verification = crate::world_fixture::verify(&runner, seed, &fixture, allow_partial)?;
match &verification.divergence {
None => println!(
"records verify (two replays agree at all {} steps)",
verification.steps
),
Some(divergence) => println!("records diverge ({divergence})"),
}
if let Some(records) = &verification.first.records {
let value: serde_json::Value =
serde_json::from_str(records).context("the replayed records")?;
println!("records: {}", serde_json::to_string_pretty(&value)?);
}
if let Some(view) = &verification.first.view {
println!("view:\n{view}");
}
if let Some(divergence) = &verification.divergence {
bail!("the two replays diverge: {divergence}");
}
Ok(())
}

/// Read a file no larger than `limit` bytes.
#[cfg(feature = "sandbox")]
fn read_bounded(path: &std::path::Path, limit: u64, what: &str) -> Result<Vec<u8>> {
Expand Down
Loading
Loading