Skip to content

fix(#803): make "no exits" and "could not read the exits" different types - #821

Merged
djhenry merged 5 commits into
mainfrom
fix-803-zone-exits-wtr
Aug 1, 2026
Merged

fix(#803): make "no exits" and "could not read the exits" different types#821
djhenry merged 5 commits into
mainfrom
fix-803-zone-exits-wtr

Conversation

@djhenry

@djhenry djhenry commented Aug 1, 2026

Copy link
Copy Markdown
Owner

What this fixes

RegionMap::load collapsed four distinct .wtr failures — missing, not region data, unsupported
version, truncated — into None. That None was installed onto the collision grid; zone_line_indices()
did .unwrap_or_default(); get_zone_exits iterated the empty vec and answered [] with 200 OK.

[] is also the true, common answer for "this zone has no zone lines". So a failed file read was
published as a fact about the world, in the one endpoint that says how to leave a zone. An agent that
asks how to get out of a broken zone is told, confidently and with a 200, that there is no way out.

Closes #803.

The fix: a type, not a guard

The lever is tier 1 of the verification hierarchy — make the bad state unrepresentable — and the
oracle is the compiler, not a source-text pin:

  • RegionMap::load (the Option-returning one) is deleted outright, not merely unused. A
    tombstone comment at its old site says why, so it does not grow back.
  • Collision::water is now Result<Arc<RegionMap>, RegionDataAbsent>, not Option<_>.
  • Collision::zone_line_indices() changed return type from Vec<i32> to
    Result<Vec<i32>, RegionDataAbsent>. Every caller had to fail to compile, which is how each
    one was found; there is no way to write the old lossy read and still build.
  • RegionDataAbsent carries either NotAttached or LoadFailed(RegionLoadError), and as_str()
    yields six distinct machine-readable reasons.
  • GET /v1/observe/zone_exits returns 503 zone_region_data_unavailable with reason +
    human detail when the read failed. 200 [] now means exactly one thing: the region map loaded
    and holds no zone-line regions.

Design deviation from the issue, and why

The issue suggested routing the refusal through zone_assets' usability verdict (a NotUsable
variant). I did not do that. usability() has three consumers: every /observe/* endpoint, the
nav walker's drive_walk gate, and ActionLoop::drain_zone_cross. A new NotUsable variant would
have stopped routing and /observe/frame in every zone with a missing .wtr — a much larger
behaviour change than the honesty bug warrants, and one that trades a silent lie for a broad outage.
The refusal is local to get_zone_exits, which is the endpoint that was lying.

Files changed (10 files, +551 / -83)

 crates/eqoxide-core/src/region_map.rs | 119 ++++++++++++-----
 crates/eqoxide-http/src/observe.rs    | 172 ++++++++++++++++++++++++-
 crates/eqoxide-nav/src/collision.rs   | 233 ++++++++++++++++++++++++++++------
 crates/eqoxide-nav/src/water_grid.rs  |   9 +-
 crates/eqoxide-nav/src/zone_assets.rs |  33 ++++-
 crates/eqoxide-net/src/action_loop.rs |  14 +-
 docs/http-api.md                      |  34 +++++
 src/app.rs                            |  14 +-
 src/movement.rs                       |   2 +-
 tests/water_capability.rs             |   4 +-

Hunk inventory for the files this PR shares with #810

#810 also touches crates/eqoxide-http/src/observe.rs, src/app.rs, src/movement.rs,
docs/http-api.md and crates/eqoxide-net/src/action_loop.rs. Exact hunks here, so merge order can
be judged rather than guessed:

File Hunks What
src/app.rs @@ -789,3 +789,9 @@, @@ -794 +800 @@ One logical change in the zone-load worker: RegionMap::loadtry_load, a warn! on failure, set_waterset_region_data.
src/movement.rs @@ -2810 +2810 @@ One line, inside #[cfg(test)] mod tests: same call-site move.
crates/eqoxide-net/src/action_loop.rs one hunk, ~line 1772 zone_line_indices() now returns a Result, so this call site had to compile. It keeps today's behaviour via .unwrap_or_default() deliberately, with a comment saying so; changing zone_cross's reporting is #815, not this PR.
crates/eqoxide-http/src/observe.rs @@ -81,0 +82,36 @@ (new region_data_unavailable helper), @@ -1887,2 +1923,10 @@ (get_zone_entrances, forced by the Result), @@ -1955 +1999,9 @@ (the get_zone_exits refusal, 9 lines), @@ -5172,0 +5225,114 @@ (new test module) The new test module is inserted before GUARD_REACH_SENTINEL_OBSERVE, which must stay last in the file.
docs/http-api.md @@ -458,0 +459,3 @@ (3-line cross-reference), @@ -475,0 +479,31 @@ (new ### Zone exits: [] means exactly one thing (#803) section) Pure additions; no existing line rewritten.

Mutation results — both directions, 7 mutations, 0 survivors

Green control M0 (unmutated): eqoxide-core 150 passed, eqoxide-http 266 passed,
eqoxide-nav 232 passed / 16 ignored, all ok, exit 0.

Each mutation is applied to a cp-restored pristine tree (single-occurrence anchor assert), then
cargo test -p eqoxide-nav -p eqoxide-http -p eqoxide-core --lib --locked --no-fail-fast.

# Mutation Result Tests that went RED
M1 collision.rs: restore the lossy read — Err(absent) => Err(absent.clone()) becomes Err(_) => Ok(Vec::new()) (the old unwrap_or_default) RED (3) collision::…::every_load_failure_is_an_error_not_an_empty_list, collision::…::a_grid_with_no_region_data_attached_is_an_error_too, observe::…::a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits
M2 observe.rs: endpoint guard removed — the Err arm returns Vec::new() instead of refusing RED (1) observe::…::a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits (collision stayed green — correct, the mutation is one layer up)
M3 observe.rs: refusal renamed "zone_region_data_unavailable""zone_assets_not_ready" RED (2) observe::…::the_refusal_is_distinct_from_the_zone_assets_gate, observe::…::a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits
M4 region_map.rs: collapse all six as_str() reasons to one string RED (3) region_map::tests::region_data_absent_reasons_are_distinguishable, collision::…::the_reported_reason_names_the_actual_failure, observe::…::a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits
M5 observe.rs: the plausible wrong fix — refuse whenever the exit list is empty RED (3) observe::…::a_zone_that_genuinely_has_no_zone_lines_still_answers_the_empty_list, plus two pre-existing tests: observe::zone_asset_gate_tests::zone_exits_answers_normally_once_ready, observe::tests::every_previously_age_less_bare_body_endpoint_carries_the_header
M6 collision.rs: set_region_data drops the cause, always storing NotAttached RED (3) collision::…::the_reported_reason_names_the_actual_failure, collision::…::every_load_failure_is_an_error_not_an_empty_list, observe::…::a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits
M7 collision.rs: blanket refusal — Ok(w) => Ok(…) becomes Ok(_) => Err(NotAttached), i.e. never answer at all RED (4) collision::…::a_loaded_map_with_no_zone_lines_still_answers_the_empty_list, collision::…::a_loaded_map_with_a_zone_line_enumerates_it, observe::…::a_zone_with_a_zone_line_still_lists_it, observe::…::a_zone_that_genuinely_has_no_zone_lines_still_answers_the_empty_list, plus two pre-existing observe::zone_cross_observables_713::*

Survivors: none. M7 was added specifically because after M1–M6 three of the new tests
(a_loaded_map_with_no_zone_lines_still_answers_the_empty_list,
a_loaded_map_with_a_zone_line_enumerates_it, a_zone_with_a_zone_line_still_lists_it) had not been
killed by anything and were therefore unproven controls rather than measured assertions. M7 kills all
three. Every one of the 9 added tests is now killed by at least one mutation, and the two directions
are both covered: M1/M2/M3/M4/M6 kill "stopped refusing", M5/M7 kill "started refusing when it
shouldn't".

Three files were mutated across the run. After the last mutation they were restored from pristine
copies and verified by md5sum (collision.rs ea868f79…, observe.rs 2c5e4898…,
region_map.rs ee594ce9…), with git status --porcelain empty.

Forced .wtr failure, observed live — not reasoned

Three runs on one client, one port I own, against a private copy of the asset tree
(XDG_DATA_HOME pointed at a scratch dir). The real asset was never mutated: its md5
(6e941725de5301812187e6ca7dbbd738) was checked before and after and is unchanged.

The corruption is 4 bytes patched in place in the copy — the .wtr header's node count, 14655 →
999999, leaving the file length at 586218. That shape was chosen deliberately: artifacts_intact()
in src/asset_sync.rs verifies only file size, so a genuinely shortened file would have been
silently repaired by the gamedata re-sync before the client ever read it. Same size, still
Truncated.

Run 1 — this branch, healthy asset. GET /v1/observe/zone_exits200 OK

[{"gated":false,"index":1,"location":[-853.66,199.42,-41.97],"zone_id":null},
 {"gated":false,"index":2,"location":[-1860.26,790.68,-111.97],"zone_id":57}]

Run 2 — this branch, corrupted asset, same zone. GET /v1/observe/zone_exits
503 Service Unavailable

{"error":"zone_region_data_unavailable",
 "reason":"region_data_truncated",
 "detail":".wtr is truncated: header declares 999999 BSP nodes, file is 586218 bytes"}

A WARN was emitted at load. zone_assets still reported ready with collision_loaded: true and
terrain_meshes: 82, and /zone_entrances, /doors and /frame all still answered 200 — the
refusal is scoped to the one endpoint that could not answer truthfully, exactly as intended.

Run 3 — the control that shows this is a real bug, not a hypothetical. The same corrupted file,
same zone, run against the release binary built from main:

200 OK
[]

…and zero log lines mentioning region_map, .wtr or truncat. Total silence plus a confident
falsehood: the agent is told this zone has no exits.

All three clients were shut down with POST /v1/lifecycle/exit; afterwards pgrep -x eqoxide was
empty and the port was free.

Full workspace suite

Finished `test` profile [unoptimized + debuginfo] target(s) in 12m 05s
Figure Value
running [0-9]+ tests? headers 53
test result: lines 53
Non-canonical result lines 0
All-zero targets (field-anchored ^test result: ok\. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out) 15
passed / failed / ignored / filtered 1777 / 0 / 47 / 0
Sum vs header sum 1824 = 1824

^error / panicked / Killed: 0 hits in either stream. cargo check --workspace --all-targets:
Finished dev profile in 1m 21s, 0 errors, 0 warnings.

Test-count delta vs merge base 7159140: #[test]/#[tokio::test] attributes went 1805 → 1814,
+9, which matches 10 added test functions minus 1 replaced
(load_is_try_load_with_the_reason_thrown_away became
region_data_absent_reasons_are_distinguishable, since the function it tested no longer exists).

No source-text pins were added

Per the project's own history with include_str!+contains pins (they prove a line was written,
not that it is reached), this PR adds none. The enforcement is the return type of
zone_line_indices(). I also checked the three existing pins that read files I edited, and none of
them intersect my hunks: action_loop.rs:4449 reads only the | `idle` | row of
docs/http-api.md; src/movement.rs:3715/3767 are anchored on app.rs's zone-change reload block
and camera-init step, not the zone-load worker I touched; observe.rs:3701 scans for net-health
stamp patterns, which my new module contains none of.

Follow-ups filed, not fixed here

NOT verified, and not claimed

Closes #803.

…ypes

`RegionMap::load` collapsed four distinct `.wtr` failures — missing, not
region data, unsupported version, truncated — into one `None`. That `None`
was installed on the collision grid, `Collision::zone_line_indices()`
`.unwrap_or_default()`-ed it to an empty vec, and `/v1/observe/zone_exits`
published the result as a bare `[]` with 200 OK.

`[]` is also the true, common answer for a zone that genuinely has no
zone-line regions, so the two were byte-identical. Exits are the only way
out of a zone, so a failed FILE READ read to an agent as a fact about the
world: sealed in, from a success response it had no way to doubt.

Type-level fix rather than a caller-side guard, because the guard is what
kept failing:

* `Collision::zone_line_indices()` returns `Result<Vec<i32>,
  RegionDataAbsent>`. Every caller now fails to compile until it says out
  loud what it does with the failure.
* `Collision`'s region slot is a `Result<Arc<RegionMap>, RegionDataAbsent>`,
  set through `set_region_data(Result<_, RegionLoadError>)` — the loader's
  own verdict, carried rather than discarded.
* `RegionDataAbsent` adds the one case `RegionLoadError` cannot express:
  nothing was ever attached to this grid.
* `RegionMap::load` is DELETED, not merely unused. While it existed,
  `load(..).map(Arc::new)` remained the shortest way to get a region map
  onto a grid, and it is lossy in the direction that lies.
* `/v1/observe/zone_exits` refuses with 503 `zone_region_data_unavailable`
  plus a machine-readable `reason`, deliberately distinct from the #579
  `zone_assets_not_ready` verdict (that one also gates the nav walker and
  the net thread's zone-cross drain; a missing `.wtr` does not invalidate
  the terrain those consume).

`[]` with 200 now means exactly one thing: this zone's region map loaded
and contains no zone-line regions.

Closes #803.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
@djhenry

djhenry commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Independent acceptance review — CHANGES REQUESTED

I did not write this change; my job was to refute it. The code is right. I could not break the
type-level fix by any means I tried: the lever holds, all three spot-checked mutations reproduce the
author's results exactly, and the endpoint behaves correctly against a real corrupted asset on a live
client. Every blocking finding below is a false or overstated claim in a tracked file — the same
pattern the last several PRs hit. All are one- to three-line edits.

Reviewed on the merged tree: PR head ec42ee3 + origin/main b22594a = merge e1b9229.
main moved three times while I worked (9e41178fe731b17428809b22594a); I discarded
two complete in-flight runs made against bases that no longer exist rather than report them, and
re-merged three times. Every figure below is measured on e1b9229. Where a result was measured on
the previous merge base I say so and show why it carries over.


Blocking

B1 — collision.rs:1188 states a mechanism the code contradicts, and it is the pre-#683
misconception this endpoint already got wrong once
.

/// `Ok(vec![])` is a real reading: this zone's region map loaded and contains no zone-line
/// regions (or is a v1 map, which carries no indices). `Err` means the question could not be

The parenthetical says a v1 map yields []. It does not. region_map.rs:466-468 says the opposite,
explicitly:

/// `iterator`. Index 0 (a region baked without a resolvable index, or any v1-map zone line) IS
/// included (#683): it is a real exit whose destination only the server can resolve, not a
/// non-exit.

and region_map.rs:920 is a passing test pinning it (assert_eq!(rm.zone_line_indices(), vec![0])). A v1 map with zone lines returns [0], not [].

This matters beyond pedantry in three ways: (a) it contradicts this PR's own contract in
docs/http-api.md — "200 with [] means, and only means, this zone's region map loaded and
contains no zone-line regions" — so the two tracked files now disagree about what [] means, which
is the exact ambiguity #803 exists to remove; (b) it re-asserts the belief #683 was filed to kill;
(c) a future agent reading only collision.rs would conclude a v1 zone legitimately reports "no way
out". Delete the parenthetical.

B2 — the new tracked comment at observe.rs:91-95 under-counts usability()'s consumers, and it
is the justification for deviating from the issue.

that verdict is consumed by the nav walker's drive_walk gate and by ActionLoop::drain_zone_cross
as well as by every /observe/* endpoint

There is a fourth production consumer that is not an /observe/* endpoint:
move_api.rs:260, inside POST /v1/move/goto, which produces the zone_assets_pending note.
Full non-test call-site list I grepped: observe.rs:38, observe.rs:67, observe.rs:1554,
move_api.rs:260, walker.rs:1400, action_loop.rs:1692.

The deviation itself is correct — the omission makes the author's outage argument understated,
not overstated, and I confirmed the premise live (below). But this is a tracked file asserting a
verified-by-grep inventory that is wrong, and it inherits the same wrong count from
zone_assets.rs:303+ ("verified by grep, not asserted — #600. Three, …"). Fix both, or say four.

B3 — docs/http-api.md offers region_data_not_attached to agents as a reason they may receive.
It cannot occur in a release build.

NotAttached is produced in exactly two places: the Collision constructors (collision.rs:770,
:797), and set_water (collision.rs:850), which is
#[cfg(any(test, feature = "test-fixtures"))]. Every production Collision that reaches a
SharedCollision is built at app.rs:799 and gets set_region_data(...) on the very next line with
an Ok or an Err(LoadFailed(..)). I enumerated every Collision::build call site in src/ and
checked each against its enclosing module: zone_in.rs:339, movement.rs:1543, movement.rs:2611,
app.rs:2776 and model.rs:632 are all below a #[cfg(test)] mod tests (movement.rs:1505, etc.).
app.rs:799 is the only one that is not. So five of the six documented reasons are reachable; the
sixth is not. Either mark the row as an internal/never-served state or drop it.

B4 — the docstring at observe.rs:1951-1952 contains "never", and the counterexample is compiled
into the same function.

[] means exactly one thing: this zone's region map loaded and contains no zone-line regions
(#803).
It never means "the file that would list them did not load"

get_zone_exits guards the region map inside if let Some(col) = s.shared_collision.read()… with
no else (observe.rs:2024-2047). When the collision slot is None, exits stays empty and
the handler returns 200 [] having consulted no region map at all. That path is live in-tree today
and green: my M5′ run made it fail loudly, which is direct evidence two currently-passing tests
(observe::tests::every_previously_age_less_bare_body_endpoint_carries_the_header,
zone_asset_gate_tests::zone_exits_answers_normally_once_ready) reach 200 [] through it.

I then tried to reach it in production and could not, so I am reporting this as an overstated
claim rather than a live lie. What I established by reading every writer:

  • The only production = None write to a SharedCollision is zone_assets.rs:353 in
    begin_zone_load, which also publishes Pending.
  • finish_zone_load (zone_assets.rs:379) writes verdict.collision().cloned(), None only when
    the verdict is Failed, and it writes the slot before the state, which is the safe order.
  • app.rs:664/683 publish Failed without touching the slot; model.rs:492 writes Some.

So collision == None ⟹ state ∈ {Idle, Pending, Failed} for every production writer. begin_zone_load
writes the slot before the state, so there is a two-lock window where the slot is None while the
state is still the previous zone's Ready — but usability's StaleForPreviousZone check closes it,
because the stale Ready names the old zone. The one gap I could not close by reading:
zone_needs_reload (app.rs:2455) compares zone names case-sensitively while usability
compares them case-insensitively, so a republish of the same zone under a different spelling
would satisfy zone_needs_reload and not be judged stale — leaving the window open. I could not
produce that input, so I am not claiming it is reachable.

Nothing enforces the coupling — not a type, not a test. The word "never" is doing work the code
does not do. Two options, both cheap: soften the sentence to say what actually holds, or close it for
real — zone_assets_not_ready already computes st, and ZoneAssetState::Ready owns the
Arc<Collision>, so reading the grid from st.collision() instead of the shared slot makes the
None case unrepresentable and deletes the fall-through entirely. That is the tier-1 version of this
fix and it is a few lines.


Required follow-up (coverage, not correctness) — two mutations I invented both survived

Every mutation the author ran is at the library layer, and that layer is genuinely well covered. The
production wiring is not covered at all.

# Mutation (mine) Result
N1 water_grid.rs:308: sever the write — Self::Unmeasured(e) => { Err(e) }, dropping col.set_region_data(Err(e.clone())) SURVIVOReqoxide-nav/-http/-core fully green
N2 app.rs:800: delete c.set_region_data(water); — the single production write SURVIVOR — whole root crate green, 13 targets

N2 is the significant one. With that one line gone, every zone's grid carries Err(NotAttached) and
/v1/observe/zone_exits returns 503 in every zone in the game — total loss of the endpoint — and
the suite does not notice. The type change forced all the library call sites to speak up, which is
exactly what it was supposed to do, but the line that actually feeds it in the shipped client is
pinned by nothing. Right now the only thing standing between a "cleanup" commit and a dead endpoint
is a live run someone remembers to do.

N1 is smaller but has a tracked claim on it: water_grid.rs:302-306 says "#803: BOTH arms now
write the grid
… recording the reason keeps the Err return AND makes the grid itself able to
refuse honestly". Deleting that write changes no test outcome, so that stated behaviour change is
unproven. (All install() callers today are tests, so blast radius is low.)


Minor

  • M1. the_refusal_is_distinct_from_the_zone_assets_gate uses assert_ne!(j["error"], "zone_assets_not_ready"). Under M2′ — the endpoint restored to 200 []j["error"] is
    Value::Null on an array body and the assertion passes. Measured: it survived M2′. It is
    killed by the author's M3, so it is not an unproven control, but it is green under the precise
    regression its module is named after. Same family as the is_null()-on-a-missing-key vacuity fixed
    elsewhere recently; asserting the 503 shape positively would be strictly better.
  • M2. region_data_unavailable emits four keys (error, reason, detail, message);
    docs/http-api.md shows three. message is undocumented.
  • M3. PR-body hunk table: "@@ -1887,2 +1923,10 @@ (get_zone_entrances, forced by the
    Result)" is git's nearest-preceding-function context, not the edited function — the hunk adds a
    doc comment to get_zone_exits. get_zone_entrances (observe.rs:1904) is untouched and reads
    s.world.zone_points, never the grid. No code impact.
  • M4. The source-text-pin inventory ("the three existing pins that read files I edited") is
    incomplete: transport.rs:3440-3447 include_str!s the edited action_loop.rs, and
    guild.rs:126/149/623/693 scan every .rs in eqoxide-http/src including the edited observe.rs.
    I read all of them — none is tripped by this PR, and GUARD_REACH_SENTINEL_OBSERVE is still the
    last item in observe.rs after the merge. No action beyond the inventory.

Verified, with the method

Tier-1 lever — holds. RegionMap::load has zero surviving callers anywhere
(crates/, src/, tests/, tools/); the six hits are prose in doc comments. The tombstone at
region_map.rs:402 claims only what is true. I scanned for the bypasses that would have made the
type change cosmetic — .ok(), unwrap_or_default(), Err(_) => Ok(vec![]) — and found exactly
one unwrap_or_default(), the deliberate one at action_loop.rs:1784, which is commented and
deferred to #815 and disclosed in docs/http-api.md. No silent bypass was added.

Mutations — 3 independent spot-checks, all reproduce the author's table exactly. Mutated at the
call site, not by wrapping a body. Green control M0: core 151 / http 267 / nav 237, all ok.

# Mutation Result Tests RED
M2′ observe.rs: the plausible wrong fix — col.zone_line_indices().unwrap_or_default() RED (1) a_region_map_that_did_not_load_refuses_instead_of_reporting_no_exits
M1′ collision.rs: Err(_) => Ok(Vec::new()) RED (3) every_load_failure_is_an_error_not_an_empty_list, a_grid_with_no_region_data_attached_is_an_error_too, + the observe one
M5′ observe.rs: refuse whenever the exit list is empty RED (3) a_zone_that_genuinely_has_no_zone_lines_still_answers_the_empty_list, zone_exits_answers_normally_once_ready, every_previously_age_less_bare_body_endpoint_carries_the_header

Same counts and same test names the author reported. Two of their three restore md5s reproduce
byte-for-byte on my tree (collision.rs ea868f79…, region_map.rs ee594ce9…), which independently
corroborates the clean-restore claim from the run I could not see. I re-verified all five mutated
files against pristine copies by md5 afterwards; both worktrees end git status --porcelain empty.

Carry-over: the mutations and the live run were executed on the previous merge base (ec42ee3 +
7428809). I did not re-run them after merging b22594a, so instead of assuming the delta is nil I
diffed the two merged trees: they differ in two files onlycrates/eqoxide-ipc/src/lib.rs
(untouched by any mutation) and src/app.rs, where the entire delta is the two deleted
v.moving = … lines at 1390/1720, inside controller_view.lock() blocks. The other four
mutation-target files (collision.rs, water_grid.rs, observe.rs, region_map.rs) are
byte-identical across the two bases, and the N2 site is app.rs:800, ~590 lines and one function
away from either deleted line. The mutation results transfer.

Cross-PR semantic conflict — checked, clean. The merge with #810 is textually clean across five
shared files. #810's observe.rs edits land in get_debug (@@ -903, @@ -1168) and mod tests
(@@ -2934); #821's land at @@ -79, @@ -1884, @@ -1952 and a separate new module — disjoint.
The specific risk raised (that #821 might change shared body assembly and silently alter
get_debug's hand-built player object) does not materialize: #821's diff contains no +/-
line touching with_snapshot_age, zone_assets_json_of, get_debug, any serde attribute, or any
insert( — its refusal path is a new private function called from one handler. I confirmed every
test either PR added survives the merge by name and passes, including the five
*_afloat_stall tests. The merged doc is internally consistent: #821's new prose names only
error / reason / detail, never pos_*. #818 touches steering.rs/walker.rs/walker_sim.rs
no overlap.

#823 (ControllerView.moving) — checked, and not on trust. Its src/app.rs hunks are at
@@ -1384 and @@ -1714; #821's single app.rs hunk is 786–798. Beyond the line distance I
checked the direction that actually matters: #821's whole diff contains no +/- line mentioning
ControllerView or moving, so it cannot be reading the field #823 removed. (select_player_action
at app.rs:2484 takes a moving: bool parameter — a different thing, untouched by both.) The one
moving-shaped risk here would be a reader of the IPC field inside #821's changed code, and there is
none. GUARD_REACH_SENTINEL_OBSERVE is still the last line of observe.rs (5484, file length 5484)
after all three merges.

Figures — merged tree e1b9229, stdout and stderr captured to separate files, exit code captured.

  • Finished \test` profile [unoptimized + debuginfo] target(s) in 8m 17s`, exit 0
  • 55 running [0-9]+ tests? headers vs 55 test result: lines — equal, so no binary died
    mid-run. Independently: 42 Running + 13 Doc-tests = 55 targets, matching the 55 headers.
  • 0 non-canonical test result: lines.
  • 14 all-zero targets (field-anchored on 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out).
  • 1807 passed + 0 failed + 47 ignored + 0 filtered = 1854, and the sum of the N in the headers is
    also 1854. Zero FAILED, zero panicked, clean ^error scan on both streams.

All ten added tests ran and passed by name, and the one deleted test
(load_is_try_load_with_the_reason_thrown_away) is absent from the log, as it should be. So is
a_zero_index_zone_line_region_is_still_recognized_683 — green, which is the measurement that
falsifies B1.

Delta reconciliation — now exact. Against the stated main baseline of 55 headers /
1798 + 0 + 47 + 0 = 1845: my merged tree is 55 headers / 1854, i.e. +9 passed, +0 ignored, +0
targets
. Independently, git grep -c '#\[(test|tokio::test)\]' gives 1810 attributes at the PR
base 7159140 and 1819 at the PR head ec42ee3 (+9), and 1833 at main b22594a vs
1842 at my merge (+9). The PR adds 10 test functions and deletes 1, net +9, and every one of
the +9 is accounted for by name above. No test went missing anywhere in three merges. Earlier in the
review I reconciled the author's 53 / 1824 the same way; that reasoning is now superseded by this
direct measurement.

Live E2E — both directions, on a client I launched, pinned to this tree's binary.
/proc/<pid>/exe resolved to my own review worktree's target/release/eqoxide, mtime matching the
build. Private XDG_DATA_HOME copy; the real asset's md5 6e941725de… was identical before and
after and the private tree is deleted.

  • Control, intact .wtr: 200 with two exits, index 1 and 2. I parsed the .wtr myself
    beforehand — 14655 v2 nodes, zone-line leaves carrying indices [1, 2] — so this is checked against
    an oracle I derived independently, not against the client's own claim. This is also the only
    evidence that the wiring my mutation N2 shows is untested actually works.
  • Corrupted, same file length: 4 header bytes patched, node count 14655 → 999999, length
    unchanged at 586218 (chosen because artifacts_intact() checks size only, so a real truncation
    would be re-synced away before the client read it). Result:
    503 {"error":"zone_region_data_unavailable","reason":"region_data_truncated","detail":".wtr is truncated: header declares 999999 BSP nodes, file is 586218 bytes"}.
  • The deviation's premise, confirmed live rather than argued: with the region map broken and the
    zone otherwise ready, /v1/observe/frame, /v1/observe/zone_entrances and /v1/observe/debug
    all still returned 200. Folding this refusal into NotUsable would have taken all three down.
    That is the strongest evidence in the PR that declining the issue's suggestion was right.

NOT verified

  • region_data_missing / _unreadable / _not_region_data / _unsupported_version were not
    forced on a wire.
    I traced each to its construction site in try_load and confirmed app.rs:793
    calls try_load in production, but only _truncated was observed end-to-end. Read, not measured.
  • B3's unreachability is a "cannot" claim and a live run cannot discharge it. It rests on my
    enumeration of Collision::build sites and the cfg gate on set_water — reading, not a type.
    Nothing prevents a future non-test Collision from reaching the slot un-attached.
  • B4's production-unreachability is likewise read, not proven. No test and no type enforces the
    slot/state coupling; I did not attempt to drive the case-mismatch window on a live server.
  • I did not run a clean main baseline myself. The 1845 figure I reconcile against is the one I
    was given; what I measured is my merged tree and the attribute counts on both trees. The +9
    agrees across three independent derivations, but the main half of the comparison is not mine.
  • The mutations and the live run predate the b22594a merge. I justified the carry-over by
    diffing the two merged trees rather than re-running them; that is a read plus a git diff, not a
    re-measurement.
  • The #[ignore]d tests (47) were not run, and I did not evaluate whether any of them bears on
    this change.
  • Only three of the author's seven mutations were reproduced. M3, M4, M6, M7 are taken on trust;
    the three I did check matched exactly, which is why I read the rest as credible rather than
    verified.

Verdict: CHANGES REQUESTED. B1–B4 are doc/comment edits; nothing about the implementation needs
to change. The two survivor mutations should land as tests before or shortly after this merges —
N2 in particular, because a green suite currently permits deleting the one line that makes the whole
feature work.

djhenry and others added 4 commits August 1, 2026 00:39
…ing an unservable reason (B3)

Addresses review round 2 findings **B1**, **B3** and minor **M2**. Comment/doc only; no
behaviour change.

**B1 — `collision.rs`, `zone_line_indices()`.** The doc said `Ok(vec![])` covers a map that
"is a v1 map, which carries no indices". It does not. v1 records have no `zone_line_index`
field, so a v1 zone line reads as index **0**, and index 0 is INCLUDED — the function returns
`[0]`, not `[]`. `region_map.rs`'s own `zone_line_indices` doc says so, and
`a_zero_index_zone_line_region_is_still_recognized_683` pins it green. The parenthetical
restated the exact pre-#683 belief that locked a character in qrg forever, and it contradicted
this PR's own `docs/http-api.md` contract ("`[]` means, and only means, …"). Replaced with the
correction, named as such, so it cannot be re-derived.

**B3 — `docs/http-api.md`.** `region_data_not_attached` was listed as a `reason` an agent may
receive. It cannot occur in a release build: `NotAttached` comes only from the `Collision`
constructors and from `set_water`, which is `#[cfg(any(test, feature = "test-fixtures"))]`, and
the one production grid construction attaches region data in the same call. Removed from the
outcome table and documented separately as what it actually is — an internal state that, if it
ever reached an agent, means a CLIENT BUG rather than a missing asset. The claim is labelled as
an argument from enumerating construction sites, not a type-level guarantee.

**M2** — the refusal emits four keys (`error`, `reason`, `detail`, `message`); the doc showed
three. `message` is now documented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
… (B4), and recount usability()'s consumers (B2)

Addresses review round 2 findings **B4**, **B2** and minor **M1**.

**B4 — the "never" is now enforced by the code instead of asserted by a docstring.**
`get_zone_exits` took permission from the zone-assets verdict and then read the grid from the
SEPARATE `shared_collision` slot behind `if let Some(col)` with no `else`. A `None` there
returned `200 []` — "this zone has no way out" — having consulted no region map at all. Nothing
coupled the two slots: not a type, not a test. The reviewer's M5' run proved two currently-green
tests reach `200 []` through exactly that path, and while it traced every production
`SharedCollision` writer without finding a way in, it could not close the window where
`zone_needs_reload` compares zones case-SENSITIVELY and `usability` case-INSENSITIVELY.

Taken the reviewer's tier-1 option rather than softening the sentence. New
`zone_assets::usable_collision(state, player_zone) -> Result<&Arc<Collision>, NotUsable>` returns
the verdict AND the grid the `Ready` state OWNS, in one call, delegating to `usability` so the
rule cannot fork. `get_zone_exits` uses it and the `if let Some(col)` is gone — there is no
longer a branch that reaches the response builder without a grid, so every `[]` this endpoint
emits has been through `Collision::zone_line_indices()`. `zone_assets_not_ready`'s body is split
into `zone_assets_refusal` so both callers serve the byte-identical 503.

Pinned three ways rather than described:
* `zone_assets::…::usable_collision_agrees_with_usability_for_every_state` — over every state ×
  zone combination the two agree, the `Ok` hands back the state's OWN `Arc` (`ptr_eq`), and the
  unreachable fallback never fires.
* `observe::…::an_unset_shared_collision_slot_can_no_longer_produce_an_empty_exit_list` — the
  exact fixture shape the fall-through fired on (`Ready` for this zone, shared slot `None`),
  asserted in BOTH directions so it cannot pass by the endpoint having stopped reporting exits.
* Two fixtures were updated rather than worked around: `ready_state()` and the #646 header loop
  now carry a region map that LOADED and has no zone lines. `test_ready()`'s grid has no region
  data attached at all, which is a state production never has — a `ready` zone has always had
  `set_region_data` called with an `Ok` or with the loader's `Err`.

**B2 — `usability()` has FOUR non-test consumers, not three.** The missing one is
`move_api.rs`'s `POST /v1/move/goto`, which is not an `/observe/*` route — which is how it went
uncounted. The reviewer's judgement that the DEVIATION itself is correct is preserved and now
rests on the true count: a `NotUsable` variant would also have taken down `/move/goto`, so the
omission understated the outage. Corrected in both places that carried the wrong count:
`observe.rs`'s justification comment and `zone_assets.rs`'s "verified by grep — #600" inventory.

**M1** — `the_refusal_is_distinct_from_the_zone_assets_gate` asserted only
`assert_ne!(j["error"], "zone_assets_not_ready")`, which is vacuously true on an ARRAY body, so
it survived the endpoint being restored to `200 []` — the precise regression it is named after.
Now asserts the 503 and the error string positively.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
…tations survived (N1, N2)

Addresses review round 2's two SURVIVOR mutations. Both were tracked claims with no test behind
them — the defect class this project ranks highest.

**N2 — `src/app.rs`, the single production write.** Deleting `c.set_region_data(water)` in the
zone-load worker left the whole root crate green, while a real client would then carry
`Err(NotAttached)` on every zone's grid and answer `503` from `/v1/observe/zone_exits` in EVERY
ZONE IN THE GAME. #803's type change forced all the LIBRARY call sites to speak up; the line
that feeds them in the shipped client was pinned by nothing.

The four lines lived inside a spawned thread closure and so could not be called from a test.
Extracted verbatim into `app::build_zone_collision(za, maps_dir, zone_name)` — named, `pub(crate)`,
documented as the client's ONE production construction of a collision grid — and pinned by
`app::zone_load_wiring_803`, which calls the production function against a real `.wtr` written to
a temp dir. The `.wtr` is built as BYTES rather than handed in as a parsed `RegionMap`, so the
test covers the whole wiring: the `maps/water/<zone>.wtr` path convention, `try_load`'s `Ok` and
`Err`, and the attach. Both directions: a zone with a `.wtr` enumerates its exit (`Ok([7])`), a
zone without one refuses naming `region_data_missing` — NOT `region_data_not_attached`, which is
precisely what severing the write produces.

One deliberate behaviour change, disclosed: the `.wtr` load (and its `warn!`) now runs only when
the terrain assets loaded. When they did not, no grid is built at all and the zone publishes
`Failed`; warning about the region data of a world that does not exist was noise about a grid
that is not there.

**N1 — `water_grid.rs`, the `Unmeasured` arm.** `install()`'s comment claims "#803: BOTH arms now
write the grid … makes the grid itself able to refuse honestly". Severing
`col.set_region_data(Err(e.clone()))` changed no test outcome, so that stated behaviour change
was unproven. `installing_an_unmeasured_zone_is_a_handled_failure_762` now asserts the grid's
state BEFORE the install (`region_data_not_attached` — nobody asked) and AFTER
(`region_data_missing` — we asked and the file was not there), plus that
`zone_line_indices()` refuses with that cause rather than answering `[]`, plus that the
`Measured` arm's write CLEARS the recorded failure instead of latching it. Its stale
"the grid keeps `water: None`" comment is corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV
@djhenry

djhenry commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

Round 2 — all four blocking findings applied, both surviving mutations now killed

Head is now 7e626eb (fast-forward on ec42ee3, merged onto current main 41cca4e as af5dcb7). Three commits, one per finding group:

Commit Findings
4a0176c B1, B3 (+ M2)
5dd9608 B4, B2 (+ M1)
7e626eb N1, N2

B1 — the v1-map falsehood (collision.rs)

Deleted. I confirmed your reading independently against region_map.rs: zone_line_at returns Some(0) for a v1 record and zone_line_indices() includes index 0, so a v1 zone line yields [0], not []. The replacement sentence now says so explicitly, names the round-2 finding, and points at a_zero_index_zone_line_region_is_still_recognized_683 — which is the test that would break if anyone re-introduced the nonzero gate that caused #683.

B2 — usability() has FOUR consumers, not three

Confirmed by grepping every non-test call site: observe.rs:38/67/1554, move_api.rs:260, walker.rs:1400, action_loop.rs:1692. Both inventories (observe.rs and zone_assets.rs:303+) now say four and list POST /v1/move/goto explicitly, with a note that it is not an /observe/* route — which is exactly the shape of the mistake that let it go uncounted. As you said, the deviation from the issue was correct; only the tracked count was wrong.

B3 — region_data_not_attached

I chose make the doc honest, not make the state reachable, and I want to be explicit about the trade so you can push back:

  • Removing NotAttached from the enum means Collision::build has to take region data as a constructor argument — ~8 call sites, most of them fixtures, for a state no release binary can serve. That is churn priced against a doc line.
  • So docs/http-api.md's reason table is now five rows, and region_data_not_attached is documented below it as a state that exists in the code and cannot be served: the one production construction (build_zone_collision in src/app.rs) builds and attaches in a single call, and the only reason-free write (Collision::set_water) is #[cfg(any(test, feature = "test-fixtures"))]. An agent that somehow receives it is told plainly it is a client bug, not an asset problem.
  • I labelled the limit in the doc itself: that is an argument from enumerating construction sites, not a type-level guarantee. Nothing stops a future non-test grid from reaching a reader un-attached. If you want the tier-1 version, say so and I will do the constructor change.

B4 — the 200 [] fall-through is now unrepresentable

Took your tier-1 suggestion. New zone_assets::usable_collision(state, player_zone) -> Result<&Arc<Collision>, NotUsable>: it runs usability first and, on the None (i.e. usable) path, hands back Ready's collision field — which is not an Option. get_zone_exits now does one read of one slot and either gets a grid or returns the same 503 body (zone_assets_not_ready was split into a gate and a reusable zone_assets_refusal, so the refusal text is literally shared, not duplicated).

The if let Some(col) … with no else is gone; there is no longer a path from a None slot to 200 [].

Why this is behaviour-preserving in production, not just in tests: finish_zone_load writes *collision_slot = verdict.collision().cloned() — the same Arc the Ready state holds — and begin_zone_load is the only production = None. So changing the read source cannot change a production answer. Two tests hold that down: usable_collision_agrees_with_usability_for_every_state (5 states x 5 zones, asserts refusal equality and Arc::ptr_eq on the Ok arm) and an_unset_shared_collision_slot_can_no_longer_produce_an_empty_exit_list, which forces shared_collision = None under a Ready state and asserts 200-with-exits / 503, with assert_ne!(j, json!([])) on both.

Disclosure — I edited two fixtures. ready_state() and the #646 header loop previously leaned on test_ready(), whose grid is Err(NotAttached); under the new read they would 503. I installed RegionMap::flat_below(-10.0) (a map that loaded and has no zone lines) in those two places and deliberately did not touch the shared test_ready() helper, so no other test's premises moved.

N1 / N2 — both mutations are now RED

I re-ran both against the final tree.

N2 (delete c.set_region_data(water);, the single production write): previously the whole root crate stayed green. The write now lives in a named, testable build_zone_collision(za, maps_dir, zone_name) with a new mod zone_load_wiring_803 that drives it over real v2 .wtr bytes.

---- app::zone_load_wiring_803::a_zone_with_a_wtr_gets_a_grid_that_can_answer_its_exits stdout ----
  left: Err(NotAttached)
 right: Ok([7])
---- app::zone_load_wiring_803::a_zone_with_no_wtr_gets_a_grid_that_refuses_with_the_real_cause stdout ----
  left: "region_data_not_attached"
 right: "region_data_missing"
test result: FAILED. 211 passed; 2 failed; 1 ignored

One behaviour change comes with the extraction and is stated in the function's doc comment: the .wtr load and its warn! now run only when terrain assets loaded. Previously they ran unconditionally.

N1 (sever the Unmeasured arm's write): previously three crates stayed green, so "#803: BOTH arms now write the grid" was unproven.

---- water_grid::tests::installing_an_unmeasured_zone_is_a_handled_failure_762 stdout ----
assertion failed: the Unmeasured arm must RECORD why, not leave the grid looking un-asked
  left: Some("region_data_not_attached")
 right: Some("region_data_missing")
test result: FAILED. 237 passed; 1 failed; 16 ignored

Both mutations were applied at the call site and reverted from byte-identical copies (md5-verified) before the workspace run, not by wrapping a function body. Neither pin is an include_str! source-text pin.

Workspace verification (merged tree, 7e626eb, streams captured separately)

  • Finished \test` profile [unoptimized + debuginfo] target(s) in 4m 41s`
  • 55 running [0-9]+ tests? headers = 55 test result: lines (equal, so no binary was lost)
  • 0 non-canonical result lines
  • 14 targets at a field-anchored 0 passed; 0 failed; 0 ignored
  • 1823 passed + 0 failed + 47 ignored + 0 filtered = 1870, which equals the header sum of 1870
  • 0 FAILED result lines

Against the 41cca4e baseline of 55 / 55 / 0 / 14 / 1857: the structural figures are identical and the count is +13, which is this PR's own tests plus the four added this round.

What I did NOT verify

  • No live run. Nothing here was exercised against a running client or a real zone; the 503-vs-200 [] behaviour is proven by tests and by the finish_zone_load aliasing argument, not by observation.
  • Nothing was validated on a release binary — the entire run above is the debug test profile.
  • The B3 unreachability claim is an enumeration of construction sites, not a type-level guarantee, and I have said so in the doc rather than in this comment only.
  • I did not close the case-sensitivity window between usability's eq_ignore_ascii_case and zone_needs_reload's case-sensitive compare — that is pre-existing and out of scope for these findings, but it is still open.

@djhenry

djhenry commented Aug 1, 2026

Copy link
Copy Markdown
Owner Author

APPROVED — independent adversarial review, round 2. All four round-1 blockers are fixed, and I
reproduced both surviving mutations as RED rather than accepting the claim. Two non-blocking notes
below; I would merge over both.

Reviewed at head 7e626eb. origin/main 41cca4e is already an ancestor (git merge-base --is-ancestor confirms), and git merge origin/main in my worktree reported Already up to date
so the merged tree is 7e626eb and every figure below is measured on the thing that would land.


Round-1 findings: verified fixed

B1 (was blocking) — fixed and the replacement is accurate. The v1 parenthetical is gone and what
replaces it states the opposite, correctly: v1 zone lines read as index 0, index 0 is included, so
a v1 map yields [0] not []. It agrees with RegionMap::zone_line_indices, with the
docs/http-api.md "and only means" contract, and with the #683 regression test — which is green in
my run (a_zero_index_zone_line_region_is_still_recognized_683 ... ok).

B2 (was blocking) — fixed in both inventories. Both now say FOUR and name POST /v1/move/goto as
the non-/observe/* one. I re-grepped rather than reread: the non-test usability( call sites are
observe.rs:38, :67, :1554, move_api.rs:260, walker.rs:1400, action_loop.rs:1692. That is
exactly the corrected list, with no fifth.

B4 (was blocking) — closed at the type level, not relocated. get_zone_exits now takes verdict
and grid from one call. I checked the thing that would make this cosmetic — whether shared_collision
is still an input to any answer — and it is not: the only production readers left in the HTTP layer
are observe.rs:696 and :714, the two cumulative nav_support/nav_tight diagnostic counters,
which is exactly the exclusion list the tracked comment claims.

On the case-sensitivity window you asked me to rule on: the author is right to call it out of
scope, and I can now say why rather than leaving it open.
zone_needs_reload (src/app.rs:2476)
is !scene_zone.is_empty() && scene_zone != current_zone — a case-sensitive compare that is
therefore strictly more eager than usability's eq_ignore_ascii_case. A case difference can
only cause a spurious reload, never a missed one; a spurious reload publishes Pending, which is
an honest 503. There is no input under which it produces a stale-but-blessed grid. Combined with the
grid now coming from the Ready state that usability just vouched for, my round-1 concern is
genuinely closed. Worth one sentence somewhere so the next reviewer does not re-derive it.

B3 (was blocking) — accepting the doc disposition, and the doc's claims check out. I did not take
the enumeration on trust:

  • self.water has exactly two writers — collision.rs:831 (set_region_data, always with a
    reason) and collision.rs:850 (set_water, reason-free, #[cfg(any(test, feature = "test-fixtures"))]). The doc's "the only reason-free way to write the slot" is exact.
  • Workspace-wide, I scanned every Collision::build( call against the first cfg(test) /
    cfg(any(test marker in its own file. One site precedes any such marker: src/app.rs:85,
    inside build_zone_collision. Every other site in src/ and in all six crates sits behind one.
  • The feature cannot leak into a shipped binary: every features = ["test-fixtures"] activation in
    the workspace is in a [dev-dependencies] table — I checked all seven Cargo.tomls and there is
    no [dependencies] edge.

And the doc labels its own limit ("an argument from enumerating every construction site, not a
type-level guarantee"). That is the right shape for a claim of this strength. Documenting the state
as a client-bug indicator rather than deleting the variant is the better trade here — the constructor
change would touch ~8 mostly-fixture sites to make unrepresentable a state that no release build can
serve, and the honesty risk was never the variant, it was the table telling an agent to expect it.

Minor M1 from round 1 — fixed, and I measured that the fix bites. the_refusal_is_distinct_from_ the_zone_assets_gate now asserts positively. Under the plausible-wrong-fix mutation it previously
survived, it is now one of the three tests that kill it (see M2″ below).


Both surviving mutations: reproduced RED, not accepted

Control (test -p eqoxide -p eqoxide-nav -p eqoxide-http -p eqoxide-core --lib --locked --no-fail-fast): eqoxide 213 · core 151 · http 268 · nav 238, exit 0.

# Mutation Result
N1r sever col.set_region_data(Err(e.clone())) from the Unmeasured arm (water_grid.rs:308) RED237 passed; 1 failed, installing_an_unmeasured_zone_is_a_handled_failure_762, panicking on "the Unmeasured arm must RECORD why"
N2r delete the set_region_data line from build_zone_collision (src/app.rs) RED211 passed; 2 failed, exactly a_zone_with_a_wtr_gets_a_grid_that_can_answer_its_exits and a_zone_with_no_wtr_gets_a_grid_that_refuses_with_the_real_cause

Both match the reported figures exactly. N2's fix is the right shape and not a source-text pin:
extracting the wiring into a named function and driving it from real .wtr bytes on disk covers
the path convention, the water/ join and the loader's Ok/Err, not just the assignment.

The one disclosed behaviour change in that extraction — the .wtr load and its warn! now run only
when the terrain assets loaded — is benign, and I traced it rather than assumed: opt_assets: None
collision: None and load_error: Some(..)finish_zone_load publishes Failed
/zone_exits answers the zone_assets_not_ready 503. No observable loses information; only a log
line about a world that does not exist goes away.

The "unreachable" universal: true, and pinned — measured both directions

You were right that a passing run cannot discharge it, so I attacked the sentence with two mutations
in opposite directions.

  • P2 — force the fallback reachable (state.collision().filter(|_| false).ok_or(…)): RED,
    and killed by 10 tests including usable_collision_agrees_with_usability_for_every_state
    itself, which fired its own "Idle came from the unreachable fallback, not the verdict"
    assertion. The test is a real pin, not decorative naming.
  • P1 — change the fallback's error value (ok_or(NotUsable::Idle)ok_or(NotUsable::Failed)):
    SURVIVED the whole four-crate suite. A survivor here is the expected signature of genuine
    unreachability: no test can observe a value nothing ever produces. P1 and P2 together are the
    evidence; either alone would be ambiguous.
  • P8 (invented) — the plausible "simplification" a future agent would reach for: check
    state.collision() first and return it, consulting usability only as a fallback. That silently
    blesses a Ready grid for the previous zone. RED, killed by
    usable_collision_agrees_with_usability_for_every_state and by
    world_endpoints_refuse_in_the_wrong_zone_window.

On exhaustiveness (non-blocking, note 1). The test's state list is hand-written, so
for_every_state is aspirational rather than structural — but it does cover all four
ZoneAssetState variants × 5 zone strings (including a case-difference and an empty zone). The
asymmetry worth one line: usability's match has no wildcard, so a new variant is a compile
error that forces a decision — but collision() is match self { Self::Ready{..} => Some(..), _ => None }, and that _ would silently answer None for a new variant that carries a grid. That is the
only seam by which the fallback could become reachable, and closing it is a one-line change (drop the
wildcard). Not blocking: today the claim is true, and P2 shows the test would catch it.

Fixture blast radius: measured, and confined

This was the item most likely to hide something, so I measured it rather than reading the call sites.

  • P4 — revert ready_state() to the pre-round-2 test_ready(): exactly one test goes red,
    zone_exits_answers_normally_once_ready — the one the author disclosed. The other six
    ready_state() consumers (all /debug worker-health and zone-assets-evidence tests) are
    unaffected, so no pre-existing test was relying on the absence of region data.
  • P6 (the axis nobody would think to check) — flip both edited fixtures from flat_below(-10.0) to
    flat_below(1000.0)
    , making the entire fixture world water: SURVIVED, 268 passed. The
    water dimension of the new fixture is inert; the edit moves the tests along the region-data-presence
    axis only, which is the axis it was made for. (I deliberately left the third, pre-existing
    flat_below at observe.rs:5468 alone.)
  • M2″ — the plausible wrong fix at the new call site (col.zone_line_indices().unwrap_or_default()):
    RED, killed by 3 tests. In round 1 the same mutation was killed by only 1. The fixture edit
    and the M1 vacuity fix made this mutant harder to survive, not easier — the opposite of the
    failure mode you asked me to look for.

All five mutated files restored by cp from pristine + touch; md5s re-verified identical
(app.rs 0d2f79d3…, collision.rs 9ad12412…, observe.rs c24f428f…, water_grid.rs 40a748ce…,
zone_assets.rs 5d17ef5a…); worktree git status --porcelain empty.

Figures — 7e626eb, stdout/stderr captured separately, exit code captured

  • Finished \test` profile [unoptimized + debuginfo] target(s) in 10m 42s`, exit 0
  • 55 running [0-9]+ tests? headers = 55 test result: lines. Independently 42 Running +
    13 Doc-tests = 55 targets.
  • 0 non-canonical test result: lines.
  • 14 all-zero targets, anchored on all three fields. Confirming your warning: the loose
    0 passed predicate reads 18 on this tree, which would have looked like four targets going
    dark when nothing has.
  • 1823 passed + 0 failed + 47 ignored + 0 filtered = 1870, and the header N sum is also 1870.
    Zero FAILED, zero panicked, clean ^error scan on both streams.

The +13 reconciles two independent ways, both measured. Against the 41cca4e baseline of 1857:
+13 passed, +0 ignored, +0 targets. And by git grep -c '#\[(test|tokio::test)\]' per commit —
41cca4e 1845 → af5dcb7 (round-1 content) 1854 (+9) → 4a0176c (docs) 1854 (+0) → 5dd9608 1856
(+2) → 7e626eb 1858 (+2) = +13. Every one of the four round-2 additions is named and green:
usable_collision_agrees_with_usability_for_every_state,
an_unset_shared_collision_slot_can_no_longer_produce_an_empty_exit_list,
a_zone_with_a_wtr_gets_a_grid_that_can_answer_its_exits,
a_zone_with_no_wtr_gets_a_grid_that_refuses_with_the_real_cause. Nothing else changed count, and no
test went missing.


Non-blocking notes — I would merge over both

  1. collision()'s _ => None wildcard is the single seam by which the documented-unreachable
    fallback could become reachable, while usability's wildcard-free match would force a compile
    error. Dropping the wildcard would make both halves fail loudly together.
  2. One sentence on the case-sensitivity asymmetry. zone_needs_reload being case-sensitive is
    safe precisely because it is more eager than usability — that is worth stating where the two
    compares live, so the next reader does not have to re-derive it (or "fix" it in the unsafe
    direction).

Explicitly NOT verified

  • No live run this round. Round 1's two-direction forced-.wtr-failure run was against the old
    handler; B4 rewired the grid source, so that evidence does not transfer to the new code path. I
    verified the new path by types, tests and six mutations only. A live run would validate the premise
    again; it could not discharge the "unreachable"/"never" claims either way.
  • Baseline is not mine. The 1857 figure is the one I was given; what I measured is this tree and
    the per-commit attribute counts. The +13 agrees across both, but the baseline half is not my
    measurement.
  • Four #[cfg(test)]-marker scans are heuristic at the file level — a non-test function defined
    after a file's first cfg(test) marker would not be flagged. I hand-checked the one file where
    that shape actually occurs (zone_assets.rs: mod tests at 431, all its build sites below it,
    fixture_grid explicitly gated).
  • 47 #[ignore]d tests did not run; /v1/move/zone_cross's zone_line_not_in_map mislabel remains
    deferred to zone_cross reports zone_line_not_in_map (a map-data GAP) when the region map failed to LOAD #815 and I did not exercise it; I did not review PR fix(#811,#812,#813,#814): detect the joint palette structurally, delimit its token, key downgrades by file, and floor the cap #820.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

/zone_exits reports [] with 200 when the zone's .wtr fails to load — a failed read published as 'this zone has no exits'

1 participant