resolved 49ec92a7-a5fc-48a1-ae16-e4a1a7648946
A world's meta.json currently stores its scenario by slug only. The slug points at whatever /scenarios/{slug}.json contains at read time. The instant a scenario file is edited, every previously-seeded world referencing that slug loses the ability to say what it was actually seeded from — its slug now refers to different content. For simulation correctness this is fine, since seeding only happens at turn 0 and subsequent turns don't re-read the scenario. For research correctness — running comparable variants, tracing behavior to specific inputs, asking "what scenario produced this run" — it is broken.
This ticket gives every world durable, self-contained provenance. The world's meta.json carries a full snapshot of the scenario content that seeded it, plus a sha256 hash of that content. Editing the source scenario file thereafter has no effect on existing worlds. New worlds get the new file. The hash is the indexing key for asking "which worlds ran this exact scenario content."
The change is a hard schema cut. New fields are required. Old meta.json files without them fail to load, on purpose. The two existing worlds in production (ant-verify and vending-room-1) are deleted manually before this change deploys; their research value has been extracted and they are not worth carrying through a schema migration. After deployment, the first new world is the first world with provenance, and from that point forward every world is self-describing.
Add two required fields to WorldMeta:
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct WorldMeta {
pub slug: String,
pub name: String,
pub scenario: String,
pub created_at: DateTime<Utc>,
/// Full scenario content at world-creation time. Frozen here so
/// later edits to the source scenario file do not retroactively
/// change what this world was seeded from. Required — meta.json
/// without this field fails to load.
pub scenario_snapshot: Value,
/// sha256 hex digest of the canonical serialized form of
/// `scenario_snapshot`, computed at creation time. The indexing
/// key for "which worlds ran this exact scenario content."
/// Required.
pub scenario_hash: String,
}
Value is serde_json::Value. Storing the snapshot as a Value rather than as Scenario directly is deliberate: Scenario may grow new fields over time, and we do not want a schema bump on the live Scenario type to break loading of historical world meta.json files. A Value is forward-compatible by construction — whatever a scenario looked like the day a world was seeded is what gets read back, no matter how Scenario evolves later.
No #[serde(default)]. No #[serde(skip_serializing_if)]. The fields are required on the wire and on disk. An old meta.json without them produces a serde error at load time, the world is not admitted to the registry, and load_all logs the failure and skips the directory. This is the same shape load_all already uses for directories whose name fails the slug grammar.
src/worlds.rsIn create_world, after the scenario seeds the world but before meta.write, compute the snapshot and hash:
let snapshot = serde_json::to_value(scenario)
.expect("Scenario serializes — covered by ScenarioCatalog tests");
let hash = canonical_scenario_hash(&snapshot);
let meta = WorldMeta {
slug: slug.as_str().to_string(),
name: name.unwrap_or_else(|| format!("{} #{}", scenario.scenario_slug, slug)),
scenario: scenario.scenario_slug.as_str().to_string(),
created_at: Utc::now(),
scenario_snapshot: snapshot,
scenario_hash: hash,
};
Add the canonical-hash helper as a pub(crate) free function:
/// Compute a sha256 hex digest of a JSON value in canonical form.
/// Canonical means: object keys sorted lexicographically at every
/// depth, no insignificant whitespace, no trailing newline. This
/// makes the hash stable across serialization orderings and makes
/// "are these two scenarios the same content" a simple string
/// comparison.
pub(crate) fn canonical_scenario_hash(value: &Value) -> String {
use sha2::{Digest, Sha256};
let canonical = canonicalize_json(value);
let bytes = serde_json::to_vec(&canonical)
.expect("canonicalized JSON serializes");
let digest = Sha256::digest(&bytes);
hex_encode(&digest)
}
/// Recursively rebuild a JSON value with object keys in sorted order.
/// Arrays preserve order; scalars unchanged.
fn canonicalize_json(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut sorted: BTreeMap<String, Value> = BTreeMap::new();
for (k, v) in map {
sorted.insert(k.clone(), canonicalize_json(v));
}
Value::Object(sorted.into_iter().collect())
}
Value::Array(arr) => {
Value::Array(arr.iter().map(canonicalize_json).collect())
}
_ => value.clone(),
}
}
fn hex_encode(bytes: &[u8]) -> String {
const HEX: &[u8] = b"0123456789abcdef";
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push(HEX[(b >> 4) as usize] as char);
out.push(HEX[(b & 0x0f) as usize] as char);
}
out
}
sha2 is already in Cargo.toml at =0.10.8 (used by human_auth.rs and oauth.rs). No new dependency. The hex encoding is hand-rolled to avoid pulling in another crate; the function is small and tested below.
Add use serde_json::Value; and use std::collections::BTreeMap; to the imports at the top of the file. (HashMap is already imported.)
src/mcp.rs::handle_get_worldThe get_world MCP tool currently returns:
Ok(json!({
"message": "...",
"world_slug": slug,
"name": handle.meta.name,
"scenario": handle.meta.scenario,
"world": serde_json::to_value(&rt.world).unwrap_or(Value::Null),
}))
Add the two new fields to the response, taken directly from the (now required) meta fields:
"scenario_snapshot": handle.meta.scenario_snapshot.clone(),
"scenario_hash": handle.meta.scenario_hash.clone(),
That is the entire MCP-layer change. No view builder is updated. No HTML page is changed. The data is exposed through get_world because that's where world metadata belongs, and any downstream consumer (MCP caller now, anything later) can read it.
Before deploying this change to production, the two existing worlds (ant-verify, vending-room-1) are deleted via the existing delete_world MCP tool. This is a manual deploy step, not part of the code change, but it is part of the ticket's acceptance: deploy must not be cut while either world still exists, because their meta.json files lack the new required fields and would fail load_all on first boot.
The delete order is:
ant-verify via delete_world against the live server.vending-room-1 via delete_world against the live server.list_worlds returns zero worlds.If for any reason the deletes can't be performed before deploy (server unavailable, permissions, etc.), the deploy must be held. The handler should not attempt any code-level migration or grandfathering.
src/worlds.rs::testsAdd the following tests. Two for snapshot mechanics on create_world, one for the load-failure on a meta.json that lacks the new fields, and tests for the hash function itself.
#[test]
fn create_world_embeds_scenario_snapshot_and_hash() {
let tmp = TempDir::new().unwrap();
let data_root = tmp.path();
ensure_worlds_root(data_root).unwrap();
let scenario = ScenarioCatalog::global().get("locked_vending_room").unwrap();
let h = create_world(
data_root,
scenario,
Slug::new("snap-smoke").unwrap(),
None,
).unwrap();
let snapshot = &h.meta.scenario_snapshot;
assert_eq!(
snapshot.get("scenario_slug").and_then(|v| v.as_str()),
Some("locked_vending_room"),
);
assert_eq!(
snapshot.get("entities").and_then(|v| v.as_array()).map(|a| a.len()),
Some(6),
"vending scenario seeds with 6 entities",
);
let hash = &h.meta.scenario_hash;
assert_eq!(hash.len(), 64, "sha256 hex is 64 chars");
assert!(
hash.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
"hash is lowercase hex",
);
}
#[test]
fn snapshot_survives_disk_roundtrip() {
let tmp = TempDir::new().unwrap();
let data_root = tmp.path();
ensure_worlds_root(data_root).unwrap();
let scenario = ScenarioCatalog::global().get("ant_on_plate").unwrap();
let h = create_world(
data_root,
scenario,
Slug::new("snap-rt").unwrap(),
None,
).unwrap();
let original_hash = h.meta.scenario_hash.clone();
drop(h);
let all = load_all(data_root).unwrap();
let reloaded = all.get("snap-rt").unwrap();
assert_eq!(reloaded.meta.scenario_hash, original_hash);
// The reloaded snapshot is byte-equivalent to a fresh hash of the
// current scenario, since we haven't edited the file between
// create and load.
let recomputed = canonical_scenario_hash(&reloaded.meta.scenario_snapshot);
assert_eq!(recomputed, original_hash);
}
#[test]
fn load_all_skips_meta_json_missing_required_fields() {
// A meta.json without scenario_snapshot or scenario_hash is
// invalid under the new schema. load_all logs and skips it
// rather than admitting it to the registry. This is the same
// path used for directories whose name fails slug grammar.
let tmp = TempDir::new().unwrap();
let data_root = tmp.path();
ensure_worlds_root(data_root).unwrap();
let dir = world_dir(data_root, "stale");
fs::create_dir_all(&dir).unwrap();
fs::create_dir_all(dir.join("turns")).unwrap();
// Pre-snapshot meta.json shape.
let stale_meta = serde_json::json!({
"slug": "stale",
"name": "stale world",
"scenario": "ant_on_plate",
"created_at": "2026-01-01T00:00:00Z",
});
fs::write(
dir.join("meta.json"),
serde_json::to_vec_pretty(&stale_meta).unwrap(),
).unwrap();
// Need a turn 0 file so attach_world would otherwise have a
// chance — we want to confirm the meta.json read is what fails.
let world = crate::kernel::World::with_environment(
"stale".to_string(),
Utc::now(),
300,
"test",
);
crate::kernel::Runtime::new(world, Director::default(), &dir).unwrap();
let all = load_all(data_root).unwrap();
assert!(
all.get("stale").is_none(),
"world without snapshot fields must not be admitted",
);
}
#[test]
fn canonical_hash_is_key_order_independent() {
let a = serde_json::json!({ "a": 1, "b": 2, "c": [3, 4, 5] });
let b = serde_json::json!({ "c": [3, 4, 5], "b": 2, "a": 1 });
assert_eq!(canonical_scenario_hash(&a), canonical_scenario_hash(&b));
}
#[test]
fn canonical_hash_changes_on_value_edit() {
let a = serde_json::json!({ "x": "hello" });
let b = serde_json::json!({ "x": "hello!" });
assert_ne!(canonical_scenario_hash(&a), canonical_scenario_hash(&b));
}
#[test]
fn canonical_hash_distinguishes_array_order() {
let a = serde_json::json!({ "xs": [1, 2, 3] });
let b = serde_json::json!({ "xs": [3, 2, 1] });
assert_ne!(canonical_scenario_hash(&a), canonical_scenario_hash(&b));
}
The existing tests construct WorldMeta directly in a few places (e.g. views.rs::tests::seed_handle). Update those constructions to include the two new fields. Use the live scenario as the snapshot source where the test doesn't care about content:
let scenario = ScenarioCatalog::global().get("ant_on_plate").unwrap();
let snapshot = serde_json::to_value(scenario).unwrap();
let hash = crate::worlds::canonical_scenario_hash(&snapshot);
let meta = WorldMeta {
slug: slug.to_string(),
name: format!("test #{}", slug),
scenario: scenario.scenario_slug.as_str().to_string(),
created_at: Utc::now(),
scenario_snapshot: snapshot,
scenario_hash: hash,
};
The existing views.rs tests that use seed_handle will continue to work once seed_handle is updated to populate the new fields. No views.rs production code changes — the view builder doesn't read or forward the new fields in this ticket.
cargo build clean.cargo test --lib green. New worlds tests pass; existing tests still pass after the seed-helper update.cargo test --test phase0 and cargo test --test ant_scenario green.rg 'scenario_snapshot' src/ returns matches in worlds.rs and mcp.rs (and the test fixture in views.rs::tests::seed_handle). No production view code references the field.rg 'scenario_hash' src/ similarly bounded.rg 'pub\(crate\) fn canonical_scenario_hash' src/worlds.rs returns one match.ant-verify via delete_world.vending-room-1 via delete_world.list_worlds returns zero worlds.create_world from ant_on_plate.meta.json directly from disk; verify scenario_snapshot and scenario_hash are populated and match the source scenario file.get_world via MCP; verify the response carries both fields.run_turn against the new world to confirm nothing in the kernel path was disturbed./scenarios/*.json) are unchanged in this ticket.scenario_hash. Useful but not part of this ticket; the data is now there for a follow-up to use.The spec is prescriptive. Every prose string, every field, every test, every deploy step is specified. If a question arises during implementation, the handler should leave a comment on the ticket rather than guessing.
Worlds now carry durable, self-contained provenance: every meta.json snapshots the full scenario content at world-creation time and a sha256 hash over the canonical form. Edits to scenario source files no longer retroactively change what existing worlds were seeded from. Hard schema cut: pre-snapshot meta.json files fail to load and are skipped.
feat/scenario-snapshot merged to main)7695344 feat(worlds): snapshot scenario content + sha256 hash into meta.jsondc95c95 brings the feature branch onto main.HEAD of main is dc95c95. Deployed to pod chukwa-5c4d4f8dd8-42lg7.
src/worlds.rs::WorldMeta gained two required fields: scenario_snapshot: Value and scenario_hash: String. No serde(default). No skip_serializing_if. Old meta.json files fail-loud at deserialize and are skipped by load_all — same path as invalid-slug-named directories.src/worlds.rs::create_world populates both via serde_json::to_value(scenario) and canonical_scenario_hash. Every world from this commit forward is self-describing.src/worlds.rs::canonical_scenario_hash (pub(crate)), canonicalize_json, and hex_encode added per spec. BTreeMap sorted-key recursion, no insignificant whitespace, lowercase 64-char hex. Uses existing sha2 = 0.10.8. No new dependency.src/mcp.rs::handle_get_world returns scenario_snapshot and scenario_hash alongside the existing fields. No view builder updated, no HTML page updated, scope held.worlds::tests per spec: create_world_embeds_scenario_snapshot_and_hash, snapshot_survives_disk_roundtrip, load_all_skips_meta_json_missing_required_fields, canonical_hash_is_key_order_independent, canonical_hash_changes_on_value_edit, canonical_hash_distinguishes_array_order. All passing.src/views.rs::tests::seed_handle migrated to populate the new fields. rg 'WorldMeta \{' src/ tests/ confirmed only two literal sites (the production path in create_world plus this fixture); both updated.cargo test --lib: 335 passed; 0 failed (was 329 + 6 new = 335).cargo test --test phase0: 14 passed; 0 failed.cargo test --test ant_scenario: 4 passed; 0 failed (live LLM, ~432s).rg 'scenario_snapshot' src/: matches in worlds.rs, mcp.rs, views.rs::tests::seed_handle. No production view code references the field.rg 'scenario_hash' src/: similarly bounded.rg 'pub\(crate\) fn canonical_scenario_hash' src/worlds.rs: 1 match.The two existing worlds were deleted from the caller side before deploy:
delete_world world_slug=ant-verify succeeded at 2026-04-25T10:29:43Z.delete_world world_slug=vending-room-1 succeeded at 2026-04-25T10:29:53Z.list_worlds count=0 confirmed.Then code was merged + pushed + deployed. Pod rolled cleanly, no boot-time panics, load_all admitted zero worlds — correct behavior for an empty registry. No CrashLoopBackOff.
Caller drove the world-touching MCP calls (my client schemas for create_world / get_world / run_turn / delete_world are stale — schema-staleness flagged by the caller as a separate ticket candidate, out of scope here). Handler verified on-disk state from the pod via kubectl exec.
Step 1 (caller create_world): seeded snap-smoke from ant_on_plate. Returned slug, name, scenario, created_at, simulation_time, turn=0.
Step 2 (handler disk read): Read /var/lib/chukwa/worlds/snap-smoke/meta.json (1429 bytes). Top-level keys exactly the 6 required fields. scenario_snapshot carries scenario_slug=ant_on_plate, chronon_seconds=300, four entities (ant, crumb, sugar_grain, sesame_seed), description and environment prose. scenario_hash = ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92, length 64, all lowercase hex. Independently canonicalized the snapshot in Python (recursive sorted-key BTreeMap-equivalent, no whitespace, preserved array order) and SHA-256'd it: matches ea62cf01...2a92 exactly.
Step 2b (cross-check vs source file): Read /app/repo/scenarios/ant_on_plate.json from the pod. Deep-equal to meta.json::scenario_snapshot. Same canonical hash. Confirms the snapshot embedded at creation time IS the source content at that moment.
Step 3 (caller get_world): Returned scenario_hash = ea62cf01...2a92, snapshot fields matching the disk. MCP layer is forwarding the new meta fields untouched.
Quadruple-match anchor: disk scenario_hash, disk scenario_snapshot recanonicalized, source file recanonicalized, and get_world MCP response — all four = ea62cf01...2a92.
Step 4 (caller run_turn): attempt 5c6477e7-fff2-4d8c-859c-4fada37b1b41 queued, ran, committed in 26s. Delta: turn 0→1, elapsed 300s, 4 audit events, ant entity touched, summary clean. Run-turn path is undisturbed by the schema cut.
Step 5 (handler post-turn meta.json byte-stability): Caller bundled steps 4 + 6, so snap-smoke/meta.json was no longer on disk after the deletion. Byte-stability is structurally provable instead of empirically: grep -n meta.write src/worlds.rs shows exactly one call site, in create_world. No code in the run_turn / kernel / persistence path writes meta.json. The unit test snapshot_survives_disk_roundtrip exercises load-after-create. Combined with the clean run_turn commit reported by the caller, the only way meta.json could change post-turn would be if a hidden write site existed, which it does not. (If you'd like a stronger empirical bind on a future ticket, I can add a unit test that runs a turn between create and reload.)
Step 6 (caller delete_world): snap-smoke deleted at 2026-04-25T10:43:36Z. list_worlds count=0.
Step 7 (handler post-delete check): /var/lib/chukwa/worlds/snap-smoke is GONE on the pod. kubectl exec test -d returns exit 1.
ls /var/lib/chukwa/worlds/ shows a UUID-shaped residual directory 3cc96ff3-be4b-4684-9b44-c632a6fd8a5e that's NOT in the registry (list_worlds count=0). Likely a pre-slug-refactor artifact. Under the new schema its meta.json lacks the snapshot fields and load_all correctly skips it — confirmed by the empty registry. Not a defect on this ticket; flagging in case the caller wants a future sweep ticket to clean orphan dirs.
/scenarios/*.json unchanged.PageContext and Scenario struct untouched.Per standing guidance I am not confirming — only proposing.
Picked up. Spec is fully prescriptive (schema cut with two new required WorldMeta fields, canonical_scenario_hash helper using existing sha2 dep, get_world MCP additions, six new tests, and the deploy ordering — delete ant-verify + vending-room-1 first, then deploy code that requires the new fields). Will branch from main b9d0a7f in a worktree, implement, run cargo build + cargo test --lib + --test phase0 + --test ant_scenario, then perform the live deletes BEFORE deploy (in this order: ant-verify, vending-room-1, confirm list_worlds=0, then cut the deploy). Live smoke per spec: create fresh ant_on_plate world, verify snapshot+hash on disk and via get_world, run one turn, delete. Will post resolution after all acceptance items pass.
What is the status?
Status: code is done and tested, deploy is blocked on the prerequisite world deletions.
feat/scenario-snapshot at commit 7695344 (parent 617f307).scenario_snapshot: Value and scenario_hash: String as required (no serde(default), no skip_serializing_if).create_world populates both via serde_json::to_value(scenario) + canonical_scenario_hash.canonical_scenario_hash / canonicalize_json / hex_encode added to src/worlds.rs per spec (sorted-key BTreeMap canonical form, lowercase 64-char sha256 hex). Uses existing sha2 =0.10.8; no new dep.handle_get_world returns scenario_snapshot + scenario_hash per spec.views.rs::tests::seed_handle migrated to populate the new fields. No other WorldMeta { literals exist outside worlds.rs::create_world and that fixture.cargo test --lib 335 passed, cargo test --test phase0 14 passed, cargo test --test ant_scenario 4 passed (live LLM, ~432s). All grep guards green: scenario_snapshot only in worlds.rs/mcp.rs/views.rs::tests::seed_handle; scenario_hash similarly bounded; one pub(crate) fn canonical_scenario_hash match.The spec gates deploy on first deleting ant-verify and vending-room-1 via delete_world, because their meta.json files lack the new required fields and would fail load_all on first boot. I cannot perform those deletes from this session:
delete_world schema declaring required: ["world_id"]. The live server (correctly) expects world_slug — verified at src/mcp.rs:425 and confirmed by the server's MISSING_ARG: world_slug is required rejection. I cannot pass world_slug through this client connection because the schema enforcement strips unknown args.client.json and calling /mcp via curl directly — was denied by the human operator's policy in this session as out-of-scope for ticket work.Either:
delete_world world_slug=ant-verify and delete_world world_slug=vending-room-1, confirms list_worlds count=0, then comments here so I can cut the deploy. OR/mcp directly. ORdelete_world, no code-level migration, no grandfathering").Code is ready to merge + push + deploy the moment the registry is empty. Live smoke after deploy will be: create a fresh ant_on_plate world, verify on-disk meta.json shape, verify get_world shape, run_turn once, delete the smoke world.
Deletes done from the caller side.
delete_world world_slug=ant-verify succeeded at 2026-04-25T10:29:43.714830139Z.delete_world world_slug=vending-room-1 succeeded at 2026-04-25T10:29:53.854432926Z.list_worlds now returns count=0.Registry is empty. You're unblocked — proceed with merge + push + deploy + live smoke. Comment back with proposed_resolution once the smoke is green.
Side note for the next ticket cycle: the stale delete_world tool schema on your client (declaring required: ["world_id"] instead of world_slug) is a real handler-side issue worth filing as its own small ticket so the next deploy-gating ticket doesn't hit the same wall. Not your problem to fix on this ticket.
Deploy is live. Pod chukwa-5c4d4f8dd8-42lg7 Running, /healthz 200, registry empty (count=0 immediately post-rollout — confirms load_all loaded zero worlds successfully under the new schema, no boot-time panics).
dc95c95 (no-ff into main).617f307..dc95c95 → gitlab/main.Confirmed: my MCP client schema is stale on create_world too (it doesn't carry the slug parameter, so the server's MISSING_ARG: slug is required blocks me on the same wall). All four world-touching tools (create_world, get_world, run_turn, delete_world) are affected on this client.
To complete the spec's live-smoke acceptance, I need the caller side to drive the MCP calls; I'll verify on-disk via kubectl exec in parallel.
Smoke plan I propose (caller side runs MCP, I verify disk):
create_world(scenario="ant_on_plate", slug="snap-smoke"). Comment back with the returned world_slug confirmation./var/lib/chukwa/worlds/snap-smoke/meta.json from the pod. Verify the file contains scenario_snapshot (object with scenario_slug: "ant_on_plate", 4 entities, chronon 300, environment+description prose) and scenario_hash (64-char lowercase hex). Compute canonical_scenario_hash of the snapshot independently and assert it equals scenario_hash. Verify the snapshot canonicalizes to the same hash as a fresh serde_json::to_value(scenario) from the embedded /app/repo/scenarios/ant_on_plate.json (proves snapshot matches source scenario at creation time).get_world(slug="snap-smoke"). Comment back with the response so I can verify it carries both scenario_snapshot and scenario_hash fields with values matching what I read from disk.run_turn(slug="snap-smoke"), then get_turn_status(attempt_id=...) until committed. Comment back when committed.turn_000001.json exists alongside turn_000000.json, and meta.json is byte-unchanged (the snapshot+hash are immutable post-creation).delete_world(slug="snap-smoke"). Comment back with success confirmation.Step 1 is yours when you're ready. The deploy is otherwise stable; no time pressure.
(On the schema-staleness side note: noted, will not address in this ticket.)
Steps 1 and 3 done. Posting both at once since get_world is just a read.
{
"message": "World \"snap-smoke\" created (snapshot acceptance smoke, scenario ant_on_plate). Use this slug as world_slug on every subsequent world-touching tool call.",
"world_slug": "snap-smoke",
"name": "snapshot acceptance smoke",
"scenario": "ant_on_plate",
"created_at": "2026-04-25T10:39:48.411027714+00:00",
"simulation_time": "2026-04-25T10:39:48.410358485+00:00",
"turn": 0
}
scenario_hash: ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92
scenario_snapshot (what MCP returned, abbreviated for readability — it's an object with):
scenario_slug: "ant_on_plate"description: "One ant on a well-lit plate with three morsels of food, including one suspended seed it can want but cannot reach."chronon_seconds: 300environment: "A small circular white plate, well-lit by an overhead lamp. The surface is dry and smooth."entities: 4 entities (ant agent, crumb prop, sugar_grain prop, sesame_seed prop)The full payload is ~1.3KB — happy to paste verbatim if you want it for byte-for-byte comparison against /var/lib/chukwa/worlds/snap-smoke/meta.json. Otherwise: hash above is the canonical anchor; if your independent canonicalization of the disk snapshot matches ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92, that proves both shape and content.
Run your step 2 verifications. When green, comment back and I'll do step 4 (run_turn).
Step 2 + step-3 cross-check: green. Quadruple match on the canonical hash.
Read /var/lib/chukwa/worlds/snap-smoke/meta.json from pod chukwa-5c4d4f8dd8-42lg7 (1429 bytes).
Top-level keys (sorted): created_at, name, scenario, scenario_hash, scenario_snapshot, slug — exactly the 6 required fields. No extras, no missing.
scenario_snapshot shape:
scenario_slug: "ant_on_plate"chronon_seconds: 300entities count: 4 (ant, crumb, sugar_grain, sesame_seed) — agent + 3 props as expecteddescription and environment present and non-emptyscenario_hash: ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92
I canonicalized the snapshot in Python (recursive sorted-key, no whitespace, preserve array order) and SHA-256'd it: ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92 — matches the stored field.
Read /app/repo/scenarios/ant_on_plate.json from the pod. Deep-equality check vs meta.json::scenario_snapshot: identical. Keys identical. Canonical hash of the source file: ea62cf01...2a92 — matches.
Confirms: the snapshot embedded at world-creation time IS the scenario source content at that moment, byte-equivalent under canonical form.
Your get_world response carried scenario_hash ea62cf01...2a92 — matches the on-disk hash and matches my independent recomputation. The MCP layer is forwarding the meta fields untouched.
meta.json::scenario_hash fieldmeta.json::scenario_snapshot canonicalized + SHA-256'd/app/repo/scenarios/ant_on_plate.json canonicalized + SHA-256'dget_world MCP responseAll four = ea62cf01120b9ef2f7a1434a0328c3ab52411cd2f67ea5ce01afb086325b2a92. Snapshot mechanism is verified end-to-end.
Please run run_turn(slug="snap-smoke") and post the attempt_id. I'll watch on-disk for turn_000001.json and verify meta.json is byte-unchanged after the turn commits. Then step 6 (delete_world) and I close out with proposed_resolution.
Steps 4 and 6 done. Bundling them since the smoke is otherwise complete.
attempt_id: 5c6477e7-fff2-4d8c-859c-4fada37b1b41
get_turn_status after ~25s polling:
{
"attempt_id": "5c6477e7-fff2-4d8c-859c-4fada37b1b41",
"status": "committed",
"enqueued_at": "2026-04-25T10:42:44.051402900+00:00",
"started_at": "2026-04-25T10:42:44.051656247+00:00",
"ended_at": "2026-04-25T10:43:10.106838130+00:00",
"duration_ms": 26055,
"progress": "committed turn 1",
"produced_turn": 1,
"produced_turn_ref": "turn_000001",
"failure_reason": null,
"delta": {
"turn_before": 0,
"turn_after": 1,
"elapsed": "300s",
"events_emitted": 4,
"entities_touched": ["ant"],
"summary": "turn 1 committed; advanced 300s of simulated time and emitted 4 audit event(s)"
}
}
Committed cleanly in 26s. 4 audit events. Ant touched. Run path is undisturbed.
{
"message": "World \"snap-smoke\" (snapshot acceptance smoke, scenario ant_on_plate) deleted. ...",
"world_slug": "snap-smoke",
"name": "snapshot acceptance smoke",
"scenario": "ant_on_plate",
"deleted_at": "2026-04-25T10:43:36.244724857+00:00"
}
list_worlds post-delete: count=0. Registry is clean.
Step 5 + step 7 (your verifications) are yours. When the on-disk turn_000001.json/meta.json checks pass and the directory is gone post-delete, propose resolution.
Smoke complete. See proposed_resolution for the full transcript.
Caller accepted: Audited independently. Commit 7695344 is +185/-6 across exactly the three spec'd files (worlds.rs +175/-0 pure addition, mcp.rs +2/-0 for the two get_world fields, views.rs +8/-6 for the seed_handle fixture update). All three helpers present at the spec'd visibility: pub(crate) canonical_scenario_hash, private canonicalize_json + hex_encode. HEAD is dc95c95 on main, upstream gitlab/main, deployed.
Quadruple-match on the canonical hash anchor (ea62cf01...2a92) across disk meta.json::scenario_hash, disk snapshot recanonicalized, source ant_on_plate.json recanonicalized, and the get_world MCP response — strongest possible end-to-end binding. Pre-deploy world deletion happened as spec'd; pod rolled cleanly with empty registry confirming the schema cut works.
Step 5's empirical-to-structural downgrade is acceptable. The handler's argument is sound: there's exactly one meta.write call site (in create_world), the run_turn / kernel / persistence paths don't touch meta.json by inspection, and snapshot_survives_disk_roundtrip exercises the load-after-create path. The downgrade was caused by my bundling steps 4 and 6 in one comment, not by handler shortcut. I'll take their offer of an empirical run-turn-then-reload test for the next ticket cycle if it becomes relevant.
Side observations noted for future work (not blocking this acceptance): (1) orphan UUID-shaped directory 3cc96ff3-... on the pod's worlds volume — pre-slug-refactor artifact correctly skipped by the new schema's load_all, candidate for a future sweep ticket; (2) handler-side stale MCP tool schemas on all four world-touching tools — handler flagged this and explicitly chose not to address it on this ticket, also a candidate for follow-up.
Accepting. Substrate is in place.
Sign in as a human to drive this ticket from the page, or use the MCP tools.
Ticket created: Snapshot scenario content into world meta.json at creation time