Skip to content

fix(#827): take zone_cross's verdict and its grid from one call, not two slots - #840

Merged
djhenry merged 2 commits into
mainfrom
fix-827-zonecross-slot
Aug 1, 2026
Merged

fix(#827): take zone_cross's verdict and its grid from one call, not two slots#840
djhenry merged 2 commits into
mainfrom
fix-827-zonecross-slot

Conversation

@djhenry

@djhenry djhenry commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #827.

What was wrong

ActionLoop::resolve_zone_cross took permission from one place and the grid from another:

  1. it locked the zone-asset state and asked zone_assets::usability; if usable, it fell through;
  2. the zone-line lookup then took a second lock, over self.collision — a separate shared slot.

Nothing coupled the two reads, and zone_assets::begin_zone_load writes *collision_slot = None
before it publishes Pending. A zone change landing between (1) and (2) therefore paired a
usable verdict with an emptied slot; the lookup was None for want of a grid; and the (None, None)
arm published no_path / zone_line_not_in_map — a reason docs/http-api.md defines as "the
locally loaded zone geometry has no matching WLD zone-line (DRNTP) trigger region"
. That is a
confident claim about the contents of a map the client never opened, which an agent has no second
channel to check. #829's reviewer constructed that end state directly and got exactly that string
(#827 comment 1).

What changed

resolve_zone_cross now calls zone_assets::usable_collision — the single accessor #803/#821
introduced for /v1/observe/zone_exits — whose Ok arm carries the Arc<Collision> that the
Ready state owns. The Arc is cloned out so the zone-asset lock is not held across the lookup
and publish, and the whole resolution below operates on that one value:

let usable = {
    let st = zone_assets::lock_state(&self.zone_assets);
    zone_assets::usable_collision(&st, &gs.world.zone_name).map(Arc::clone)
};
let collision = match usable {
    Err(why) => { /* re-queue + publish zone_loading */ return; }
    Ok(collision) => collision,
};let c = &collision;
let region_absent = c.region_data_absent().cloned();
let located = {};            // was: guard.as_ref().and_then(|c| …)

self.collision is no longer read anywhere in this function.

Why the single-accessor shape is stronger than a guard

A guard says "check before you read"; it can be satisfied and then bypassed, and the compiler is
indifferent. Here the check is the read:

  • The grid arrives as a non-Option. Ready's collision field is not optional, so
    usable_collision's Ok arm cannot hand back "usable, but no grid". Inside the resolution there
    is no Option<Arc<Collision>> left to be None — so (None, None) can only be reached with a
    region map in hand, and the reason it publishes is once again a statement about data that was read.
  • There is no second slot to straddle. The verdict and the grid come out of one lock acquisition
    of one mutex. A zone change cannot land "between" them, because there is no between.
  • It is not a rule someone has to remember. The pre-fix code was correct-looking: a gate, then a
    read. The failure was invisible at the call site. The new shape makes the wrong pairing something
    you would have to go out of your way to write — reintroducing it means deliberately fetching a
    grid the function was already handed (which is exactly mutation M1 below, and it now REDs).

What this does not do: it does not make the two-slot pattern unrepresentable in the language, or
anywhere else in the codebase. It removes it from resolve_zone_cross. See the #833 section.

docs/http-api.md — the paragraph #829 left behind

#829 deliberately documented this bug as still open. Leaving that text after fixing it would itself
be a false claim in a tracked file, which is this repo's most common defect.

BEFORE (lines 615-626):

Still not covered, on zone_cross only: when the reason is zone_line_not_in_map, you cannot
tell a region map that loaded and genuinely lacks a matching zone-line region from a collision
slot that held no grid at all — both produce that one reason. (What #815 separates out is the
third case, a grid that is present but whose region data failed to load: that now reports
region_data_*.) /v1/observe/zone_exits does not have this gap (it takes verdict and grid from
the single usable_collision call #821 introduced);
zone_cross reads the zone-asset verdict and the collision slot under two separate locks, so a zone
change landing between them can pair a usable verdict with an emptied slot. The end state has
been constructed directly — a usable verdict over an emptied slot does return no_path /
zone_line_not_in_map — so no type or invariant rules that pairing out. Whether a real zone change
actually interleaves that way is a separate question, still reasoned from the write order, not
measured
. Tracked as #827.

AFTER:

Now closed on zone_cross too (#827). Until #827, a zone_cross reporting
zone_line_not_in_map could not be told apart from a collision slot that held no grid at all
zone_cross asked the zone-asset state for permission and then read the collision slot under a
second, separate lock, and begin_zone_load empties that slot before it publishes pending,
so a zone change landing between the two reads paired a usable verdict with an emptied slot and the
lookup returned nothing for want of a grid. #829's reviewer constructed that end state directly and
got no_path / zone_line_not_in_map out of it. zone_cross now takes the verdict and the
grid from the one usable_collision call #821 introduced for /v1/observe/zone_exits: that call
hands back the Arc<Collision> the ready state owns, so the region lookup cannot be reached
without the grid the gate just vouched for and there is no longer an optional grid to be absent.
zone_line_not_in_map on zone_cross therefore means a region map that was read. (The third case,
a grid that is present but whose region data failed to load, is what #815 split out to
region_data_*.) Two limits, stated rather than implied: whether a real zone change actually
interleaves that way was never measured — the fix removes the pairing, so the question is moot
rather than answered; and this constrains zone_cross, not the other readers of the collision slot.

Two more doc edits in the same region, both for the same reason:

  • the zone_line_not_in_map reason row carried a **One caveat on "loaded" (#827):** clause
    saying the reason "is also what you get when the client held no collision grid at all" and that
    it "usually — not always" means the map was read. That is now false; replaced with
    **"loaded" is now literal (#827):** and the reason's actual meaning.
  • the region_data_* row referred to the previous row as "(previous row, with its own residual
    caveat)"
    . The residual caveat is the one just removed, so the parenthetical is now dangling;
    trimmed to (previous row).

Source comments updated for the same reason: the (None, None) arm's block explaining that case (b)
was "NOT fixed here", the #600 gate paragraph, the #815 one-guard paragraph, and the
zone_assets field doc on ActionLoop.

Test

zone_cross_answers_from_the_grid_its_own_gate_blessed_827 (in eqoxide-net). It publishes both
slots exactly as finish_zone_load does in production, then — for the #827 rows — empties only
the collision slot, which is the state begin_zone_load leaves behind between its two writes. The
zone-asset verdict is untouched and genuinely usable (asserted as a premise, so a row that refuses is
refusing at the region lookup and not at the #600 gate). Honour the premise, break the conclusion.

# row blessed grid slot emptied? must answer
1 emptied_slot_region_present DRNTP zone_line_box for the requested index yes walks to the line; not no_path
2 emptied_slot_region_data_failed region data = Err(Missing) yes no_path / region_data_missing
3 loaded_lacks_region_both_slots all-dry BSP that loaded, no zone-line region no no_path / zone_line_not_in_map
4 emptied_slot_loaded_lacks_region same as row 3 yes no_path / zone_line_not_in_map

Rows 3 and 4 are the acceptance bar's second half — the one that is usually dropped. A "fix" that
simply stopped emitting zone_line_not_in_map would pass rows 1-2 and fail these. Row 4 is the
sharpest of the four: the slot is emptied and the answer is still zone_line_not_in_map, because
it now comes from a map that really was read. The fix removes the falsehood, not the reason.

Mutation table

All production mutations are at the call site in resolve_zone_cross, never inside a callee's
body (a body-wrap cannot distinguish "this branch is dead" from "the predicate is false"). Each was
applied to a cp -p copy of the file and reverted from that copy with an md5sum check. Counts are
the eqoxide-net lib target from rbuild … test -p eqoxide-net --locked --no-fail-fast.

ID Mutation Expected Measured
M1 Grid provenance only: verdict still from usable_collision, but the lookup re-reads self.collision (`let guard = self.collision.read().unwrap(); … guard.as_ref().and_then( c …)`) — the pre-fix two-slot shape, isolated
M5 M1, plus row 1 deleted from the fixture table so the loop reaches row 2 RED RED — 382 / 1. Row 2: left: Some("zone_line_not_in_map"), right: Some("region_data_missing"). That is the acceptance sentence, measured.
M3 Fixture, not production: row 1's blessed grid swapped from zone_line_box(…) to flat_below(-1000.0) (no zone-line region) RED RED — 382 / 1 at row 1. This is what proves row 1 passes because its own grid carries the line, not because anything at all walks.
M4 Emit arm: the (None, Some(absent)) arm publishes Some("zone_line_not_in_map") instead of Some(absent.as_str()) (row 1 deleted so the loop reaches row 2) RED RED — 381 passed / 2 failed: the new test and the existing #815 test zone_cross_reports_an_unread_region_map_as_such_never_as_a_map_data_gap. Row 2 is not passing vacuously.
M2 Survivor probe. Verdict from one usable_collision call; grid from a second, independent usable_collision call under a fresh lock of the same slot SURVIVE SURVIVED — 383 passed / 0 failed. Reported, not omitted. Correct signature: both reads are of the one slot that owns the grid, so a disagreement between them is a refusal, not a wrong reason — there is no falsehood for a test to catch. It is not harmless in general (the second call can return Err mid-zone-change), but it cannot reproduce #827, which is what this test constrains.

M1 is the row that matters: the exact shape #833 measured as undetected at 382/0 is now RED.

Does this close #833's hole?

Yes, for the hole as filed — and it is the option #833 asked for. #833 is about
resolve_zone_cross sampling region_data_absent() and the zone-line lookup from one read guard
over self.collision, a deliberate choice that the reviewer's M6 mutation (split it back into two
.read()s) removed with the target staying green at 382/0.

That guard no longer exists. resolve_zone_cross does not lock self.collision at all; both facts
are method calls on one owned Arc<Collision>. There is no guard left to split, and no second
.read() to add — which is #833's option 1 ("return both facts from a single accessor that owns the
lock"), reached by taking the grid from the accessor that already owns the verdict. And where #829's
M6 survived, my M1 — the nearest writable equivalent, sourcing the grid from self.collision again
— now REDs.

Two honest limits. (a) This is not a language-level ban: nothing stops a future edit from adding
a self.collision.read() back into this function. What has changed is that doing so is now
gratuitous (the grid is already in scope) and caught. (b) It says nothing about the other readers
of the shared collision slot elsewhere in action_loop.rs. If #833 is read as the general property,
it is not closed; if it is read as filed — the guard in resolve_zone_cross — it is. I have not
closed the issue; that is the reviewer's/owner's call.

Five-figure log standard

rbuild <worktree> test --workspace --locked --no-fail-fast, stdout and stderr captured to
separate files
, run against the exact committed tree.

  1. Compile sentinel (stderr, not run completion): Finished `test` profile [unoptimized + debuginfo] target(s) in 41m 32s — exactly one occurrence. Run completion is established
    separately: the process exited, and the final line of stdout is a complete test result: line.
  2. Header/result equality (lost-binary check): 55 lines matching ^running [0-9]+ tests?$
    (singular test included) vs 55 lines matching ^test result:. Equal.
  3. Non-canonical result lines: 0 — every one of the 55 matches
    test result: (ok|FAILED)\. N passed; N failed; N ignored; N measured; N filtered out; finished in.
  4. Targets with nothing to run, anchored on the full triple 0 passed; 0 failed; 0 ignored: 14.
  5. Sum reconciliation: passed 1832 + failed 0 + ignored 47 + filtered 0 = 1879, and the
    running N headers sum to 1879. Equal.

Result: 1832 passed, 0 failed, 47 ignored, 0 filtered. 0 occurrences of FAILED, 0 of
panicked at, 0 lines starting error and 0 starting warning in stderr.

Count delta, reconciled by name. One test was added and none removed. eqoxide-net's lib target
reads 383 passed; 0 failed; 0 ignored in this run, and
test action_loop::tests::zone_cross_answers_from_the_grid_its_own_gate_blessed_827 ... ok appears in
this run's stdout — the name is confirmed ok, not inferred from the total. The pre-change figure
for that target is 382, which I did not measure on origin/main directly; I measured it as the
382 passed / 1 failed of mutation runs M1/M3 (383 tests, mine failing) on this branch, and it
matches the 382/0 #833 records for the same target. The workspace baseline of 1831 is therefore
arithmetic, not a measured figure — I did not run origin/main's suite.

scripts/check-no-local-detail.sh: exit 0 ("no forbidden patterns in tracked files").

NOT verified, and not claimed

  • No live run. Not attempted and not claimed. The behaviour under test is a one-call window
    between two writes inside the net thread; a live client cannot be steered into it on demand, and a
    live run that didn't hit it would be an existence proof over one trajectory, not evidence about
    a race. (Per the verification hierarchy, live validates premises, never a "cannot" claim.)
  • Whether a real zone change actually interleaves that way is still unmeasured — before this
    change and after it. Both fix(#815): stop reporting a map-data GAP for a region map the client never read #829's reviewer and this test reach the end state by construction rather
    than by racing begin_zone_load. The fix makes the question moot for zone_cross; it does not
    answer it. Do not upgrade this into "observed in the field".
  • No claim that the two-slot shape is unrepresentable in general. It is unrepresentable in this
    function's resolution path
    , because there is no optional grid and no second lock there.
  • No property test. The universal here ("zone_line_not_in_map is only ever said about a map
    that was read") is discharged by the shape — the grid is non-optional at the emit site — rather
    than by a property over inputs. If a reviewer thinks the shape argument is weaker than I read it,
    that is the finding to push on.
  • zone_assets.rs was deliberately not touched (PR fix(#826): make collision() and usability() fail to compile together on a new state variant #837 is in review against it). One
    observation for whoever owns that file, non-blocking: the usability doc lists the four consumers
    that "go through it (verified by grep)". drain_zone_cross still does — usable_collision
    delegates its verdict to usability — but the grep that verifies the list would now have to
    include usable_collision to find this caller. The claim is still true; the stated verification
    method for it is one call-site indirect.
  • Other self.collision readers in action_loop.rs are unchanged — the standing auto-cross,
    combat line-of-sight and swim probing all still read the slot directly. They drive physical
    movement rather than publishing an observable route claim (the reasoning usability's own doc
    gives), and I did not re-audit them.

Ruling: Closes #827, not merely "addresses"

The issue's acceptance is "a zone_cross whose collision slot is empty while the zone-asset verdict
is usable must not report zone_line_not_in_map, paired with a still-green test that a
genuinely-loaded region map lacking the region still does."
Both halves are met and both are
mutation-discriminating (M1/M5/M3 for the first, M4 and rows 3-4 for the second), and the construction
the issue names is no longer expressible in the function. Closes #827 is in the commit message.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HQVEpaaKeXsZcW9VT2roeV

…two slots

`ActionLoop::resolve_zone_cross` took PERMISSION from the zone-asset state
(`zone_assets::usability`) and the GRID from `self.collision`, a separate
shared slot, under a second lock. Nothing coupled them, and
`begin_zone_load` empties the collision slot BEFORE it publishes `Pending`,
so a zone change landing between the two reads paired a usable verdict with
an emptied slot. The region lookup was then `None` for want of a grid and
the `(None, None)` arm published `no_path` / `zone_line_not_in_map` — a
reason documented as "the locally loaded zone geometry has no matching WLD
zone-line (DRNTP) trigger region", i.e. a confident claim about the contents
of a map that was never opened. #829's reviewer constructed that end state
directly and got exactly that string.

It now calls `zone_assets::usable_collision` — the single accessor #803/#821
introduced for `/v1/observe/zone_exits` — whose `Ok` arm carries the
`Arc<Collision>` the `Ready` state owns. The `Arc` is cloned out so the
zone-asset lock is not held across the lookup, and the resolution below
operates on a non-optional `Collision`: there is no second slot left to
straddle and no `Option` left to be `None` for want of a grid, so reaching
the `(None, None)` arm now means the region map really was read.

That is a shape change, not a guard: the bad pairing is no longer spellable
in this function. It does NOT make the two-slot shape unrepresentable in
general (#833 asks for that) — the accessor is still a convention any caller
can decline to use.

Regression test `zone_cross_answers_from_the_grid_its_own_gate_blessed_827`
covers both halves of the acceptance bar: an emptied slot under a usable
verdict must not answer `zone_line_not_in_map` (it walks to the line the
blessed grid carries, or reports #815's `region_data_*`), AND a genuinely
loaded region map that lacks the region must still answer exactly that —
including with the slot emptied, where the reason is now truthful because it
came from the grid that was read.

`docs/http-api.md`: the "Still not covered, on `zone_cross` only" paragraph
and the `zone_line_not_in_map` reason row both documented this gap as open;
leaving them would be a false claim in a tracked file. Both rewritten, with
the residual-caveat cross-reference in the `region_data_*` row dropped.

NOT measured, and not claimed: whether a real zone change actually
interleaves that way. It was never measured before this change either — the
construction (here and in #829's review) sets the end state directly.

Closes #827

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 review — PR #840 (fix #827)

Reviewed in my own worktree at ae878a8. I did not write this change. Everything below that carries
a number was measured by me on this branch, or is marked as not established.

What I verified at source (not taken from the PR body)

The three load-bearing structural claims all hold, and hold for the reasons stated:

  • Ready's collision field really is non-optional. crates/eqoxide-nav/src/zone_assets.rs:52-59
    Ready is #[non_exhaustive] with collision: Arc<Collision>, and ZoneAssetState::ready
    (:69-88) is its only constructor, downgrading to Failed unless has_triangles(). So
    usable_collision's Ok genuinely cannot mean "usable, but no grid".
  • One lock acquisition. usable_collision (zone_assets.rs:348-357) takes an already locked
    &ZoneAssetState; the call site locks once at action_loop.rs:1708-1712 and clones the Arc out
    before the guard drops. There is no second acquisition to straddle.
  • (None, None) really does imply a region map was attached. Collision::region_data_absent
    is self.water.as_ref().err() (collision.rs:864-866), and a grid with nothing attached is
    Err(NotAttached). So region_absent == Nonewater == Ok(map). The doc sentence
    "zone_line_not_in_map on zone_cross therefore means a region map that was read" is true.
  • Single emitter. git grep zone_line_not_in_map over the whole tracked tree finds exactly one
    write: action_loop.rs:1947. So "there is no gridless path to this reason" is a claim about the
    reason string, not just about this function, and it holds.
  • "Other self.collision readers … the standing auto-cross, combat line-of-sight and swim
    probing"
    — I counted them myself: exactly three in action_loop.rs, at :1536, :2495,
    :2614, exactly those three. self.collision is not read anywhere in resolve_zone_cross
    (:1670:1951). Accurate.

Measurements

Streams captured separately, test -p eqoxide-net --locked --no-fail-fast.

Baseline (unmutated branch). Compile sentinel: exactly one
Finished `test` profile [unoptimized + debuginfo] target(s) in 36m 28s (stderr). Run
completion established separately: process exited and stdout's final line is a complete
test result: line. 2 headers matching ^running [0-9]+ tests?$ vs 2 lines matching
^test result:equal. Non-canonical result lines: 0. Targets with nothing to run on the
predicate ^test result: ok\. 0 passed; 0 failed; 0 ignored: 0. Sums: lib
383 + 0 + 0 + 0 = 383, doc-tests 0 + 0 + 1 + 0 = 1; headers 383 + 1 = 384 = 384. Equal.
test action_loop::tests::zone_cross_answers_from_the_grid_its_own_gate_blessed_827 ... ok is
present by name. 0 occurrences of FAILED / panicked at. This independently confirms the PR
body's 383 for the eqoxide-net lib target.

M1 spot-check, applied at the CALL SITE (verdict still from usable_collision; grid re-read
from self.collision, with an empty slot reproducing the pre-fix (None, None) outcome):
RED — 382 passed; 1 failed, the only failure being the new test, at row 1
emptied_slot_region_present, message "the blessed grid carries the requested zone line, so the
cross must WALK to it"
. That reproduces the PR's M1 row exactly. Mutation applied to and reverted
from a cp -p copy, md5sum identical afterwards; no git stash, no git restore.


MUST-FIX (blocking)

B1 — crates/eqoxide-net/src/action_loop.rs:4151-4154: "Row 4 is the sharpest of the four" is false, and I measured it false

The test's rustdoc says:

Row 4 is the sharpest of the four: the slot is emptied AND the answer is still
zone_line_not_in_map, because it now comes from a map that was read.

Row 4 is in fact the only row of the four that is completely blind to #827. Measured: with M1
applied at the call site (the pre-fix two-slot shape) and rows 1–3 deleted so the loop reaches
row 4
, the target is GREEN:

test action_loop::tests::zone_cross_answers_from_the_grid_its_own_gate_blessed_827 ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 382 filtered out; finished in 0.00s

(Filtered run; the positive control that the mutation was really in the build is the unfiltered M1
run above at 382/1, plus this build's warning: variant Walks is never constructed, which is only
emitted once rows 1–3 are gone.)

The mechanism is simple and should have been visible without running it: pre-fix, an emptied slot
gave located = None and region_absent = None, so the (None, None) arm published
zone_line_not_in_map — the same string row 4 asserts. Row 4 therefore passes on the buggy code
and on the fixed code, and the clause "because it now comes from a map that was read" attributes an
outcome to a cause the test cannot distinguish. That is a reasoned-not-measured mechanism claim in
a tracked file — this repo's dominant defect class — and it is the one sentence a future maintainer
would rely on when deciding whether row 4 is load-bearing.

Row 4 is still worth keeping: it is a real guard against a "fix" that stops emitting the reason
(it reds under such a mutation, which is presumably what "sharpest" was reaching for). The fix is
to say what it actually pins. Suggested: "Row 4 is the one row that is deliberately NOT a #827
discriminator — measured green under the M1 mutation on its own. It guards the opposite failure: a
'fix' that suppressed zone_line_not_in_map rather than re-sourcing it would red here. Rows 1–2
are what catch #827."

B2 — crates/eqoxide-http/src/observe.rs:104-109: this PR falsifies a call-site enumeration, and the grep that "verifies" it still matches

That rustdoc reads:

usability() has FOUR non-test consumersBy call site:
… * action_loop.rsActionLoop::drain_zone_cross.

After this change action_loop.rs has no production usability() call site at all — the gate
is usable_collision, which delegates. git grep -n 'usability(' on this branch shows the only
remaining occurrence in that file is action_loop.rs:4221, the new test's premise assertion.

So this is worse than the weakening the PR body flags for the other consumer list: the bullet
names a call site that no longer exists, and the scan someone would re-run to check it still
returns a hit on that file — because the test this PR adds is what keeps it matching. That is
exactly the #799 shape with the sign flipped: the scanner's predicate no longer covers what the
claim is about, but it keeps returning green.

Concrete consequence: this paragraph exists to justify "deliberately NOT folded into
NotUsable/zone_assets_not_ready" by enumerating blast radius. A maintainer re-deriving the list
lands in a test body and either drops zone_cross from the radius or writes the whole comment off
as stale.

Fix (one line, and observe.rs is touched by neither this PR nor #837, so it is free):

action_loop.rsActionLoop::resolve_zone_cross, indirectly, via
zone_assets::usable_collision
(#827).

The author flagged the zone_assets.rs copy of this list and not this one. There are two tracked
enumerations of usability's consumers; only one was disclosed.


NON-BLOCKING (follow-ups; the PR merges without them)

N1 — the M2 survivor's dismissal is right, but not for the stated reason

The PR says M2 is acceptable because "both reads are of the one slot that owns the grid, so a
disagreement between them is a refusal, not a wrong reason."
That covers only two of three cases.
Both usable_collision calls take &gs.world.zone_name, which cannot change during
resolve_zone_cross (the function holds &mut GameState, and the shared world.zone_points /
zone-name writers are on the same net thread — crates/eqoxide-net/src/login.rs:122 is the only
production writer of the shared list I found). So the second call can return:

  1. Err(_) — a refusal. Covered by the stated reason.
  2. Ok(same Arc) — identical to the fix.
  3. Ok(a different Arc) — reachable if a full begin_zone_loadfinish_zone_load cycle for
    the same zone name completes between the two reads. That is not a disagreement-becomes-
    refusal; it is a different grid, silently.

Case 3 is still honest, but for a different reason: the second call re-derives the verdict as
well as the grid, so whatever grid it hands back is blessed for the current zone on its own terms.
M2 is therefore an equivalent mutant with respect to #827's property, not a coverage gap — the
right conclusion, reached through a claim that is one case short. Worth recording accurately,
because the stated reason is what a future maintainer will lean on if they consider splitting the
call.

I could not construct an interleaving in which M2 produces a confidently wrong reason.

N2 — crates/eqoxide-nav/src/zone_assets.rs:294-310, the other consumer list

Agreed with the author's own flag: the usability rustdoc's "(verified by grep, not asserted)"
list still names ActionLoop::drain_zone_cross, which is now one call indirect. The claim ("goes
through it") stays true; the stated verification method no longer reproduces it. That file is held
by #837 (open, edits zone_assets.rs at @@ -327 and @@ -353 — the usability body and
usable_collision, not this doc block), so it is a follow-up, not a change to make here.

Merge-order note, since this repo has been bitten by that before: I found no semantic
conflict between #840 and #837. File sets are disjoint (action_loop.rs + docs/http-api.md vs
zone_assets.rs + src/app.rs), and #837's change to ZoneAssetState::collision (wildcard →
named arms) leaves usable_collision's Ready behaviour identical, which is the only behaviour
#840 depends on. Either order is safe. The only coupling is this doc bullet, which whichever lands
second should update.

N3 — the PR body's two honest limits are not in any tracked file

The body records: (a) this is not a language-level ban — nothing stops a future edit adding a
self.collision.read() back into this function; it is now gratuitous and caught, not prevented;
and (b) it says nothing about the other collision readers. (b) is in docs/http-api.md:626. (a) is
not anywhere tracked, and the source comment at action_loop.rs:1938-1946 says "(b) is gone by
construction
, not by a guard"
, which reads stronger than (a) admits. One clause in that comment
("…by construction in this function; a future edit can re-source the grid, which the #827 test
reds on") would put the limit where the next maintainer reads it. This repo's standing pattern is
that the PR body is not the honesty surface.

N4 — pre-existing, adjacent to B2

observe.rs:105 cites observe.rs:1554 for a usability call that is now at :1574. Not
caused by this PR
(it does not touch observe.rs); mentioned only because that bullet list is
being corrected under B2 anyway.

N5 — scope note on "closed", not a defect

docs/http-api.md:615 "Now closed on zone_cross too (#827)" is correctly scoped by its own
closing sentence. For the record, drain_zone_cross as a whole is not gridless-safe: the standing
auto-cross that runs immediately after resolve_zone_cross still reads self.collision directly
(action_loop.rs:1536) and still cannot tell "physically off every zone line" from "no grid
loaded" — which that site's own comment at :1525-1528 already discloses, and which only ever
clears state. Not introduced here, and the doc does not claim otherwise.


Rulings you asked for

#833 — recommend CLOSING as filed, with a pointer. #833 is about resolve_zone_cross sampling
region_data_absent() and the zone-line lookup from one read guard that nothing pinned. That guard
no longer exists; both facts are method calls on one owned Arc<Collision>; there is no second
.read() to add. That is #833's own option 1 ("return both facts from a single accessor that owns
the lock"), and the nearest writable equivalent of the reviewer's M6 — my M1 above — is now RED,
where M6 survived at 382/0. I counted the other self.collision readers myself: exactly three
(:1536 standing auto-cross, :2495 combat line-of-sight, :2614 swim probe), all physical, none
publishing a route/geometry reason string. The general property is NOT established — close #833
with that stated, not silently.

#827Closes #827 is earned. Both halves of the acceptance are met and rows 1–2 are the
discriminating ones (M1 RED at row 1, and the PR's M5 shows row 2 gives the acceptance sentence
verbatim). Row 4 does not contribute to that (see B1) but does not undermine it either.

Checked and could NOT establish either way

  • Whether a real zone change actually interleaves that way. Not attempted, same as the author.
    I reached the state by construction, not by racing begin_zone_load. Nobody has measured this,
    before or after the change.
  • No independent E2E live run, deliberately. The behaviour is a one-call cross-thread window
    inside the net thread; a live client cannot be steered into it on demand, and a live run that did
    not hit it would be an existence proof over one trajectory against a universal claim. Per the
    verification hierarchy, the mutation-checked suite is the ceiling here. Saying so explicitly
    rather than skipping it silently.
  • Whether an M2-shaped double call can ever be observed returning two different Arcs in
    production.
    I established it is representable (N1 case 3) but not that any real schedule
    reaches it.

CHANGES REQUESTED — two blocking findings, both false sentences in tracked files (B1 measured
false, B2 falsified by this PR's own change), both one-edit fixes. The code change itself I could
not refute: the structural argument holds at source, the mutation table's M1 row reproduces, and the
single-emitter grep makes the doc's reason-string claim a real universal rather than a local one.

Review round 1 on #840, both blocking findings, both doc-comment only.
No executable code changes.

B1 - action_loop.rs, the #827 test's rustdoc called row 4 "the sharpest
of the four ... because it now comes from a map that was read". Measured
false in review: with the M1 mutation applied at the call site and rows
1-3 removed, row 4 alone is GREEN (1 passed; 0 failed; 382 filtered out).
Pre-fix, an emptied slot left `located` and `region_absent` both None and
the (None, None) arm published the same string from no map at all, so row
4 cannot attribute its outcome to the grid it was answered from. Rewritten
to name rows 1-2 as the measured #827 discriminators and to say what rows
3-4 actually pin (they assert the reason is still produced, so a fix that
suppressed it rather than re-sourcing it fails there - stated as what the
rows assert; no suppression mutation was run).

B2 - observe.rs, this PR falsified that file's enumeration of usability()'s
non-test consumers: `action_loop.rs` no longer calls it directly. Worse,
a re-verifying `git grep 'usability('` still matches that file, because
the only remaining hit is #827's own test asserting its premise - a
scanner whose reach no longer matches the claim resting on it, failing
silently toward "still true". The list is now three direct consumers plus
`resolve_zone_cross` one call indirect through `usable_collision`, whose
first statement calls `usability` and returns its verdict as Err - so the
"stop zone-crossing" consequence still holds, and now says by which path.
The count history is spelled out rather than swapped: it read three, was
corrected to four when `move_api.rs` was found missing, and is three
direct again for an unrelated reason - not because that omission returned.

N4 - the same bullet cited `observe.rs:1554` for a call now at `:1574`.
Pre-existing and non-blocking, fixed in passing since the bullet is being
rewritten anyway.

Verified: test -p eqoxide-net -p eqoxide-http --locked --no-fail-fast,
streams captured separately. 4 `running N tests?` headers vs 4
`test result:` lines (equal); 0 non-canonical result lines; 0 targets on
the full `0 passed; 0 failed; 0 ignored` triple; 651 passed + 0 failed +
3 ignored + 0 filtered = 654 = header total 654. eqoxide-net lib 383 and
eqoxide-http lib 268, both unchanged from round 1, as a doc-only diff
should be. 0 FAILED, 0 panicked at, 0 stderr error/warning lines.

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 — both blocking findings addressed, doc/comment only (c68868f)

No executable code changed. git diff on this round is /// lines and blank lines only; the
eqoxide-net lib target still reads 383 passed and eqoxide-http 268, both identical to
round 1, which is the check that the diff really was inert.

B1 — "row 4 is the sharpest" — accepted, and thank you for measuring it

You are right, and the mechanism was visible without running it, which makes it worse rather than
better: I wrote a cause ("because it now comes from a map that was read") onto an outcome the row
cannot discriminate. Pre-fix, an emptied slot gave located = None and region_absent = None,
so (None, None) published the same string row 4 asserts. That is a reasoned-not-measured mechanism
claim in a tracked file, from the author of a PR whose whole subject is a reasoned-not-measured
mechanism claim.

Row 4 kept, sentence replaced. The rustdoc now says which rows discriminate #827 (1–2, with the M1
and M5 results named), says explicitly that row 4 is blind to #827 and cites your measurement
(1 passed; 0 failed; 382 filtered out under M1 with rows 1–3 removed), and states what rows 3–4
actually pin — that they assert the reason is still produced, so a "fix" that suppressed
zone_line_not_in_map rather than re-sourcing it fails there.

One deliberate weakening of your suggested wording: I did not write that rows 3–4 "red under such
a mutation." No suppression mutation was run, by either of us — you wrote "presumably," and I would
be laundering that into a measurement. The comment says the rows assert the reason, and flags that
no suppression mutation was run. If you want that measured, say so and I will run it; I left it out
rather than expand a doc-only round on my own authority.

B2 — the falsified enumeration — accepted, with the count history preserved

Correct, and the sharper half is the one you named second: a re-verifying git grep -n 'usability('
still hits action_loop.rs, and the hit is this PR's own test asserting its premise. The scanner
keeps returning green while its reach no longer covers the claim.

I did not take the suggested one-line edit as written, because leaving the count at FOUR would have
been a second false sentence. Verified at source on this branch: action_loop.rs contains exactly
one usability( occurrence, at the test premise, and zero production ones. The list is now:

  • three directobserve.rs:38/:67/:1574, move_api.rs:260, walker.rs:1400;
  • one indirectresolve_zone_cross, via usable_collision, whose first statement calls
    usability and returns its verdict as Err.

Two things guarded explicitly. First, the count history: the paragraph's own parenthetical said
an earlier revision "said three and omitted the fourth," so a bare four→three swap would read as
that omission returning. The comment now says it read three, was corrected to four when move_api.rs
was found missing, and is three direct again for an unrelated reason — #827 — naming move_api.rs
as the bullet that was once absent. Second, the grep hazard in both directions: usability(
over-matches in action_loop.rs (test premise, not a call site) and under-matches everywhere
(no grep for usability( alone finds the resolve_zone_cross consumer at all).

The downstream consequence clause you flagged indirectly: "stop zone-crossing" still holds, and
now says by which path — usable_collision delegating to usability — rather than resting on a
bullet that no longer exists.

N4 — :1554:1574

Fixed in passing, on the coordinator's call, since that bullet was being rewritten under B2 anyway.

N1 — your correction accepted; PR body left alone

You are right that my stated reason covered two of three cases and that case 3 (Ok with a
different Arc, after a full begin_zone_loadfinish_zone_load cycle for the same zone name)
is not a disagreement-becomes-refusal. Your reason is the correct one — the second call re-derives
the verdict, so whatever grid it returns is blessed on its own terms — and it makes M2 an
equivalent mutant with respect to #827's property rather than a coverage gap. The PR body's M2
row does not use the word "gap," so I have changed nothing there rather than re-edit a body over a
finding whose conclusion is unchanged. This comment is the record.

N2, N3, N5 — not touched, deliberately

zone_assets.rs is held by #837 (N2). N3 — the missing tracked mirror of the PR body's limit (a),
against action_loop.rs:1938-1946's "gone by construction" — is a real gap in the honesty
surface and I agree with your reading, but it belongs in a follow-up issue, not in a round scoped to
two blocking corrections. N5 needs no change by your own reading.

Verification for this round

test -p eqoxide-net -p eqoxide-http --locked --no-fail-fast, streams captured separately, log named
for the branch. Compile sentinel (stderr, one occurrence): Finished `test` profile [unoptimized + debuginfo] target(s) in 41.22s — incremental, and not used as run completion: the process was
confirmed exited and stdout's last line is a complete test result: line. 4 running [0-9]+ tests? headers vs 4 test result: lines, equal. 0 non-canonical result lines. 0 targets
on the full 0 passed; 0 failed; 0 ignored triple (the two doc-test targets have ignored > 0, so
they are correctly not counted as empty). 651 passed + 0 failed + 3 ignored + 0 filtered = 654 =
header running total 654. 0 FAILED, 0 panicked at, 0 stderr ^error, 0 ^warning.
scripts/check-no-local-detail.sh exit 0.

Delta by name against round 1: none — no test added or removed, and the two lib targets are
byte-identical in count (383, 268), which is what a doc-only diff must produce.

@djhenry
djhenry merged commit 53ded87 into main Aug 1, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant