PA.f194-A: `PlayTurnStore::open`'s catch-all arm retired a row onto its
declared `schema_id` alone, with no decode attempted. An Idunn rollback
across a schema-id move can hand a build a row stamped with a schema id it
has never seen whose bytes still decode cleanly under the current
`PlayTurnStoreState` shape (nothing about the struct changed, only the id).
That arm now attempts the decode first, exactly like the current- and
legacy-schema arms beside it: retires only on a genuine decode failure, and
leaves a readable row untouched with `open` failing loudly instead. The
sidecar's own `reason` field no longer claims "could not be read" when
nothing tried. `retire_unreadable_row`'s own doc comment now states this as
an explicit precondition on its caller. New test
`a_readable_row_declaring_an_unrecognized_future_schema_is_not_retired`
proves the readable case is not retired; the existing
`a_row_declaring_an_unrecognized_future_schema_is_retired_not_refused_forever`
still proves the genuinely undecodable case is. Mutation: reverting the
catch-all arm to retire on the schema id alone (no decode attempt) fails the
new test; reverted after confirming.
PA.f194-D: with the running hint correctly suppressed while a round is
genuinely in flight (PA.f193-D/PA.f194's own `run_in_progress` gate), the
play card rendered zero rows for the whole span between a player submitting
a turn and its own narration/question/refusal landing — a player who
submits sees nothing and has no sign anything is happening. A "resolving"
row now renders whenever `run_in_progress` is true, regardless of `state`,
staying inside invariant 8 (the player's own turn processing, no world
truth). The surviving hint's own text no longer claims "still being
resolved" — false in the only case it renders (`Running`, nothing actively
working on it) — and now says the turn did not finish. New tests
`the_card_shows_a_resolving_row_while_a_round_is_in_flight` and
`the_running_hints_own_text_does_not_claim_the_turn_is_still_in_flight`.
Mutation: removing the resolving-row block, and separately reverting the
hint text, each fails its own new test; reverted after confirming both.
PA.f194-E: `PlayTurnStore::retired_sidecar` (the field cached once at
`open`) has had no production consumer since PA.f193-E moved `/health`'s own
readiness reporting onto `PlayTable::retired_row_sidecar`, which scans the
store's directory fresh on every read instead of trusting a per-process
flag. Deleted the field, its accessor, and the tuple plumbing that carried it
through `open`'s match arms; `retire_unreadable_row`'s own doc comment no
longer credits it with `runtime_readiness` visibility. The two tests that
exercised it now assert on the sidecar file's own presence on disk directly,
which is what they were actually proving.
crates/ghostlight-dungeon --bin ghostlight-dungeon: 182 passed, 0 failed,
4 ignored (186 with --include-ignored and the Eve bridge built).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy`
asserted its positive case unconditionally whenever `git` is on PATH.
Idunn's own gate (`cargo test --locked -p ghostlight-dungeon --bin
ghostlight-dungeon`, the exact command the Yggdrasil runbook documents)
materializes `vendor/eve` byte-for-byte from the Git object store
(`Idunn::drivers::materialize_tree_raw`/`materialize_gitlink_raw`, confirmed
by reading `F:\Projects\Idunn\src\drivers.rs`) and that writer refuses to
ever write `.git` metadata, on the main tree or any Gitlink. So inside
Idunn's own gate `vendor/eve` is unconditionally a copy, not a checkout —
exactly the shape this test's own negative case proves unavailable — and the
positive assertion failed the gate for a reason unrelated to the code under
test.
The positive case now checks `vendor/eve/.git` directly (ground truth
independent of the function under test) and skips with a named reason when
it is absent, asserting only when a real checkout is actually present.
Verified: forcing that check to report absent reproduces Idunn's exact
condition and the test now reports SKIPPED instead of failing; reverted
after.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`RetiredPlayTurnRow` wrote `rmp_serde` bytes to a `.msgpack` sidecar and
nothing in this Body could read it back — preservation in name only. It is
now JSON (the GameCult substrate doctrine's own xenos-boundary carve-out: an
operator opening a forensic dump by hand is exactly that boundary, not a
CultCache consumer), with the row's original bytes preserved verbatim as a
base64 field, `payload_base64`, and the sidecar extension renamed from
`.msgpack` to `.json` to match. A round-trip assertion on the existing
undecodable-bytes test decodes the sidecar and proves the recovered bytes are
byte-for-byte the original payload.
`PlayTable::retired_row_sidecar` cached the retirement fact once, at
`open`, keyed off this process's own store construction — so `playStatus`
on `/health` reported plain `ok` again after any restart, even though the
sidecar a prior process wrote was still sitting on disk with the only trace
of what happened. It is now a fresh directory scan on every read
(`retired_row_sidecars_on_disk`, filtered to this store's own filename
stem), so a restarted process still names the retirement until an operator
actually clears the sidecar file. A new test opens a second `PlayTable`
over the same store path after the first one's retirement and proves
`/health` still reports it.
Mutations run and reverted (not committed): `RetiredPlayTurnRow.payload_base64`
back to raw lossy text — killed by the round-trip test. `retired_row_sidecar`
back to a field cached at construction — killed by the restart test.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`PlayTurnStore::open`'s current-schema arm routed every `Err` from
`read_current_schema_row` into `retire_unreadable_row`, but that function had
three exits and only one was a genuine decode failure: a canonical
re-encode mismatch or an inner `schema` tag disagreement both mean the row
decoded fine. Before this fix either case destroyed a perfectly readable
turn on the very next restart, the identical failure PA.f185 already fixed
for the legacy arms. `read_current_schema_row` now returns
`CurrentSchemaRowError::{Decode, Other}`, mirroring `LegacyRowError`: only
`Decode` reaches `retire_unreadable_row`, `Other` propagates as an ordinary
`open` failure and leaves the row untouched. Two new tests build a row that
decodes but fails each check and prove it survives open unretired.
Separately: `eve.rs`'s play card rendered "submit an empty message to
continue" hint for every `Running` turn, including the seconds a healthy
turn is genuinely running its own round loop. In that window `admit` fails
`run_lock`'s own `try_lock_owned` before inspecting anything and refuses —
contradicting the hint it had just shown. `PlayTurnView` now carries
`run_in_progress`, a non-blocking peek at `run_lock` taken alongside
`current_turn_view`'s other reads, and the hint gates on `state ==
Running && !run_in_progress`. `PlayError::TurnStillRunning` is split: the
lock-contention exit is now `TurnBusy` (its own accurate text), leaving
`TurnStillRunning`'s "only an empty continue is accepted" true for the one
case it still covers — text is non-empty on an otherwise-idle Running turn.
The `eve.rs:479`/`play.rs` doc comments both carried "PA.f188" for this
finding, colliding with the real PA.f188 (`open_question_of`'s construction-
site token coverage, `play.rs:9197`); both renumbered to PA.f193-D with a
note where the old number was.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`vendor_eve_git_checkout_available` ran `git -C vendor/eve rev-parse HEAD`
alone, which succeeds for a plain copied directory: git walks up past the
missing `.git` and answers with whichever enclosing repository it finds,
which inside this checkout is Ghostlight's own HEAD. Soul measured it
directly. The existing unit test never caught this because its own fixture
was a bare tempdir outside any repository, so it only proved "outside any
repo" fails, never "inside an enclosing repo, copied, not cloned" wrongly
succeeds.
Now the check also compares `git -C vendor/eve rev-parse --show-toplevel`,
canonicalized, against `vendor/eve`'s own canonical path, so a copy sitting
inside an enclosing repository is correctly rejected. A new test builds a
real `git init` scratch root, copies a fake vendor/eve into it, proves the
naive check really would have wrongly succeeded there, then proves the fixed
check refuses it.
Soul's owed item 3: the four tests that drive the real vendored Eve client
bridge (node + a built eve-browser-lowering + eve-contracts) used to early-
return with an `eprintln!` when those dependencies were absent, which cargo
captured and discarded on a passing test — the suite reported "178 passed"
identically whether or not the bridge was actually exercised, and only
`--nocapture` showed the difference. They are now `#[ignore]`d with a
documented `--ignored` opt-in, so a normal `cargo test` run's own summary
line distinguishes "not exercised" (ignored count) from "passed" with no
extra flags, and attempting an explicit `--ignored` run without the
dependencies now panics loudly instead of silently succeeding.
Cost: these four tests no longer auto-run on a workstation that happens to
have the dependencies installed; running them is now an explicit opt-in.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A turn a process died mid-round leaves the store's own single current-turn
row at Running forever: nothing resumes it on its own, and run's own admit
arm for a Running turn only continues it on an empty-text submit (non-empty
text is refused as TurnStillRunning). The card rendered nothing at all in
that state — no narration, no question, no refusal, since none of those are
set on a turn that never reached a close, a question, or a refused act since
it last opened — so a player had no way to discover the empty-submit
continue exists at all.
eve.rs's play-card construction now adds a world.play.running row whenever
the current turn's own view reports Running, naming the empty-submit
continue directly. This is a control hint about the player's own turn, not
world truth, so it stays within invariant 8 the same way the existing
narration/question/refusal rows already do.
Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 178
passed (177 + this one). Ran the mutation quoted in the new test's own doc
comment (replacing the state check with `if false`) and reverted it; it
failed the test as expected.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
main.ts subscribed to the api/eve/events revision stream exactly once, in an
`if (api.hasAuthenticatedSurface())` check that ran right after host.start()
and the redirect-flow resumeHeimdallAccess() call resolved. authenticatedSurface
reflects whatever the *last* surface() fetch saw, and it can flip from false
to true well after that point: a player who signs in through the in-page gate
(heimdall.auth_completion_status.v1, resolved entirely inside the vendored
host's own submit()/refresh() cycle, on its own 100ms-later timer) never
retriggers that one-shot check. That tab never got a revision subscription
for the rest of the session, so every answer after the first was refused as
stale and the card looked frozen.
trySubscribeToRevisions() replaces the one-shot check: idempotent, called
after host.start(), after the redirect flow's own refresh, and on a 2s
recheck interval, since main.ts has no direct hook into the in-page flow's
own completion moment.
pollMs moved from 0 to 5000: the app relied entirely on SSE for live
updates, so a dropped stream (closed by an intermediary, or simply never
subscribed) had no fallback at all. The vendored host's own poll already
no-ops when the surface version has not moved, so this is a low-frequency
safety net, not a second primary channel.
Verified: npm run typecheck (clean) and npm test (3/3, unchanged) in web/.
No headless-browser harness exists for main.ts's transport/host wiring; this
was verified by tracing the vendored host's own submit()/refresh()/consumeCommandResult
control flow (vendor/eve/packages/eve-browser-lowering/dist/index.js) against
the fix, not by an automated end-to-end test. Flagging as a follow-up in the
Cut 15 report rather than adding test infrastructure out of scope for this cut.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
runtime_readiness derived playStatus from state.play.is_some() alone, so a
PlayTable::new that succeeded by retiring an unreadable row (PA.f184) still
read as plain "ok" on /health — a trade Cut 14 made without meaning to: the
same row reported "unavailable" before PA.f184 existed. playStatus now
carries the sidecar path when this process's own open retired a row,
through PlayTable::retired_row_sidecar (the previous commit's own plumbing),
reusing the existing field rather than adding a sibling channel.
seed_once's runner.sweep(1) call rendered its ControllerError straight
through error.to_string() into RuntimeCommandError::Payload, which
dispatch_world hands the player verbatim. Soul measured the real string on a
fresh workstation with no inference endpoint running: "invalid command
payload: OperationalAgent inference failed: the local inference endpoint
refused the connection: error sending request for url
(http://127.0.0.1:11434/v1/chat/completions)" — this process's own host and
port, on the single likeliest seed failure there is. describe_seed_failure
now categorizes instead (ControllerError's own Display is untouched; every
other caller still sees the detailed message). The vault-open path and the
play route were confirmed clean by Soul and are not touched here.
eve_client_bridge_is_available proved node, the built lowering, and its
module graph, but not the `git -C vendor/eve rev-parse HEAD` precondition
one of its own gated tests asserts with .expect(...) rather than a skip.
Soul hit that .expect by accident against a copied (not cloned) vendor/eve.
vendor_eve_git_checkout_available is now its own parameterized check
(checked last, after the cheaper node/file checks) so a copied directory
reports unavailable instead of reaching that .expect at all; a scratch-dir
test proves the copy case without touching the real submodule.
Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 177
passed (node and the real vendor/eve checkout were both available, so every
gated test ran rather than skipped). Ran the mutations quoted in every new
doc comment by hand and reverted them; each failed the test it names.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
play::PlayTurnStore::open's current-schema arm and its final catch-all still
bail!ed unconditionally on a row it could not read, so a damaged or
future-schema row (any PlayTurn field-set change that lands without moving
STORE_SCHEMA) left the play card gone forever, exactly the PA.f177 gap but
for the arms PA.f177 itself did not touch. Both now retire through the same
retire_unreadable_row path the legacy arms use.
upgrade_legacy_row's own three non-decode exits (an inner schema tag that
disagrees with the envelope, and two compare-and-swap failure shapes) used to
be indistinguishable from a genuine decode failure once open caught the Err,
so a readable turn (Soul's probe: envelope v1, inner schema v2, decodes
cleanly) was destroyed under a sidecar reason that falsely claimed it "did
not decode". LegacyRowError now splits Decode from Other; only Decode
retires, Other propagates as an ordinary open failure that leaves the row
untouched.
Adds test coverage at the two construction sites PA.f179 fixed but never
pinned (open_question_of, and run's own StaleAnswer arm) proving a missing
question_token surfaces as None, not a fabricated Some(""); both fail under
Soul's exact mutation.
The retirement sidecar is now named *.msgpack instead of *.cc (PA.f192): it
is a one-shot forensic dump via std::fs::write, never a CultCache store, and
should not read as one. retire_unreadable_row's own doc comment now states
the write-before-destroy ordering and the orphan-sidecar risk explicitly
(PA.f189): a failed compare-and-swap after a successful sidecar write leaves
that sidecar in place with no corresponding successful retirement.
PlayTurnStore and PlayTable now remember the sidecar path a startup
retirement wrote (PA.f186's own plumbing; runtime.rs wiring in the next
commit).
Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 177
passed. Ran the mutations quoted in every new doc comment by hand and
reverted them; each failed the test it names (see the Cut 15 report for the
transcript).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f179 (runtime side): the `world.play` gate compared
`payload.answer_token.as_deref() != Some(question.token.as_str())`, which
forced `question.token`'s own `""` fallback (play.rs's own PA.f179 fix,
previous commit) into a value an attacker could literally send. Extracted
to `answer_token_admits(given, expected)`, which requires both sides
`Some` and equal — `expected: None` refuses unconditionally, including
against `given: None` or `given: Some("")`. Directly unit-tested
(`answer_token_admits_only_two_present_and_equal_tokens`), independent of
the HTTP round trip.
PA.f178: with no question currently open, `payload.answer_token` was
never read at all — a stale tab resubmitting its own spent token fell
through to `answers: None`, and its stale prose silently opened a *new*
turn, spending a real inference budget on an answer to a question that no
longer exists. A token arriving with no question open is now refused
outright ("that question is closed"), before `admit` is ever called.
PA.f180: `unwrap_bindings` merged the envelope's `bindings` object and a
button action's own sibling fields into one map with no memory of which
channel a field arrived on. `answerToken` is only ever legitimately an
action-sibling field (`eve.rs` never renders it as a captured control),
so a request built by hand that sent the *correct* token through
`bindings` instead would have been indistinguishable from the real thing.
`ACTION_ONLY_PAYLOAD_FIELDS` (currently just `answerToken`) is now
checked before the merge and refused if found inside `bindings`; every
other command's own real shape (action fields as siblings, captured
values inside `bindings`, arriving together) is unaffected.
Seed refusal leak: `seed_once`'s vault-root refusal named
`GHOSTLIGHT_SEED_VAULT_ROOT` directly to the player — a fact about this
process's own environment, not about the world (invariant 8). Replaced
with a player-safe "seeding is not available on this world"; the actual
env var name is still logged at `warn` for the operator. The other
refusals on this route (`seeding a world is its owner's lane`, `the
cognition organ is closed`, `VaultEvidenceSource::open`'s own errors,
which are deliberately vault-relative and never carry a path) were
checked and do not leak. `ControllerError`'s own `Display` (from
`runner.sweep`) is out of scope for this cut — see the Hands report for
why.
PA.f176: `eve_client_bridge_is_available()` checked only `node
--version` and that the vendored lowering's *committed* `dist/index.js`
is a file — always true regardless of whether either vendored package's
own `npm install` had ever run. Measured: with `node` present and no
`npm install` run anywhere, the old check reported "available" and the
four gated tests failed outright on `jsdom`; after installing only
`eve-browser-lowering`'s own deps, still failed on `ajv` (the sibling
`eve-contracts` package's own dependency, in its own `node_modules`,
which an install scoped to `eve-browser-lowering` never populates). The
check now spawns a real `node` process that does exactly what
`tools/eve_client_bridge.mjs` does at import time (resolve `jsdom`
through the lowering's own `package.json`, then dynamically import the
built `dist/index.js`, which is what actually pulls in `eve-contracts`
and `ajv`) and reports "available" only if that succeeds — cached behind
a `OnceLock` so the four gated tests share one real probe. All four
SKIPPED messages updated to name both vendored packages' own `npm
install`.
Tests added: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn,
an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted,
an_ordinary_answer_with_text_via_bindings_and_token_as_a_sibling_is_still_accepted
(negative proof for PA.f180), answer_token_admits_only_two_present_and_equal_tokens,
the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable.
Mutations verified by hand this pass (applied, confirmed the named test
failed, then reverted):
- removing the `payload.answer_token.is_some() && !is_awaiting_player`
guard: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn
fails (the stale resubmission is wrongly "accepted").
- reverting `answer_token_admits` to
`given.unwrap_or_default() == expected.unwrap_or_default()`:
answer_token_admits_only_two_present_and_equal_tokens fails (`None,
None` and `Some(""), None` wrongly admit).
- removing the `ACTION_ONLY_PAYLOAD_FIELDS` check from `unwrap_bindings`:
an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted fails
(the smuggled token is wrongly "accepted").
- reverting the seed refusal to
`format!("{SEED_VAULT_ROOT_ENVIRONMENT} is not configured")`:
the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable
fails.
- making `eve_client_bridge_is_available()` return `true` unconditionally
once `dist/index.js` exists (the pre-PA.f176 shape): with both vendored
packages' `node_modules` removed, the suite goes from 170 passed to 166
passed / 4 failed with a real `Cannot find module 'jsdom'` node crash —
reproducing the exact bug this cut fixes.
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 170 passed,
with both vendored packages' own dependencies installed. With `node`
present and both packages' `node_modules` removed: 170 passed, 0 failed,
4 skipped (verified this pass by temporarily moving both directories
aside and restoring them afterward). A true "no `node` on PATH" run could
not be produced in this sandboxed workstation (multiple node shims exist
on PATH outside the vendored packages); the early `node_on_path` check
this cut did not touch is unchanged from before, and
`the_committed_eve_client_bridge_fixture_round_trips_through_api_router_with_no_node`
already proves the node-independent fixture-replay path.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f177: `applied_keys`/`fault` predate `question_token` under the same
`LEGACY_STORE_SCHEMA_V1` schema id, but only `question_token` carried
`#[serde(default)]`. A row older than either field failed
`upgrade_legacy_row`'s decode with "missing field `applied_keys`",
`PlayTurnStore::open` returned `Err`, and the play card silently
disappeared forever (every restart hit the same row). Both fields now
carry `#[serde(default)]` for the same reason `question_token` already
does; the current-schema (v3) canonical re-encode check still catches a
current row missing either, since `to_vec_named` always re-emits them.
A legacy row that still cannot be decoded even leniently is now retired
to a timestamped sidecar file beside the store and play starts fresh,
logged at `warn` next to the log `open_play`'s own failure path already
uses, instead of leaving `open` failing on the same row forever.
PA.f179: `OpenQuestion::token` was `String`, fabricated from
`Option<String>` via `unwrap_or_default()`. A turn whose `question_token`
was `None` (the mint defeated) collapsed to `token: ""`, and the runtime
gate compared `Some("")` — an attacker-typable value — instead of
refusing outright. `token` is now `Option<String>`; the two construction
sites (`open_question_of`, the `StaleAnswer` arm) carry the real value.
PA.f181 (no code change): documents, at the `QuestionId` equality match
in `admit`, that a genuine mismatch is reachable in production only
through the race window between `runtime.rs`'s own read and this
function's fresh read — the one test that exercises it forges
`PlayRequest.answers` directly, since the token gate refuses everything
else upstream. The check stays; the narrowness is now recorded where it
lives.
eve.rs: `question.token` (now `Option<String>`) serializes to a JSON
`null` when no token was minted, which `answer_token_admits` refuses the
same as any other mismatch — comment only, no behavior change on this
side.
Tests added: a_legacy_v1_row_predating_applied_keys_and_fault_still_opens_and_upgrades,
a_genuinely_undecodable_legacy_row_is_retired_to_a_sidecar_and_play_starts_fresh.
Mutations verified by hand (documented in each test's own comment; each
was applied, run, and reverted this pass): removing `#[serde(default)]`
from `applied_keys` turns the first test into a panic ("the legacy row's
own turn must be recovered"); removing the sidecar-retirement fallback
from the `LEGACY_STORE_SCHEMA_V1` branch turns the second into a panic
("a damaged legacy row must be retired, not fail open outright").
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 170 passed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The map's next action and the handoff still described the world before the
play-agent pass.
Falsified against source: both claimed no local-model transport exists.
crates/ghostlight/src/local_inference.rs has existed since Cut 2, and
runtime.rs opens it on GHOSTLIGHT_LOCAL_ENDPOINT. Corrected to "exists,
unexercised" so the next session does not rebuild it.
Also retired: "world.speak is the only human action" and the story-card
description, both deleted by Cut 9 (f1732c0); and "the cut map is next",
written before Cuts 1 through 14 landed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two questions asked in one turn carried the same eve::surface_version
(asking is conversational and never commits), so the version-only
staleness check runtime.rs's world.play arm ran could not tell Q1 and Q2
apart: an answer composed for Q1 was accepted and silently resolved Q2.
Each question now mints an opaque random token (play::mint_question_token,
uuid v4) that carries no turn id, round, slot, subject, or other world
handle. PlayTurn.question_token replaces question_surface_version;
OpenQuestion.token replaces OpenQuestion.surface_version. The "Play"
button's own props.action carries the token (eve::authenticated_surface),
riding DOM-invisibly the same way props.action.command already does — a
real client spreads a button's own action fields straight into the
payload beside bindings (commandPayload,
eve-browser-lowering/src/index.ts:2101). runtime.rs's world.play arm
refuses an answer whose answerToken does not name the currently open
question's own token; PlayPayload gains answer_token.
unwrap_bindings no longer refuses a sibling next to the envelope
bindings object outright (PA.f168's original rule) — it merges bindings'
own fields with the sibling action fields instead, refusing only a
genuine name collision between the two. An unrecognized sibling is still
refused, one door downstream, by the operation's own
#[serde(deny_unknown_fields)] payload struct.
The play-turn store moves to ghostlight.play_turn_store.v3 and reads v1
and v2 leniently, upgrading in place (PlayTurnStore::open,
upgrade_legacy_row) the same way Cut 12 upgraded v1 to v2. A recovered
row whose question is still open mints a fresh token as part of the
upgrade, since no earlier schema recorded one and the card is re-rendered
from whatever the row holds.
Deleted with the version comparison: question_surface_version and the
per-round fresh-snapshot fetch execute_round took solely to compute it
(PA.f161/PA.f175's own reason to exist). QuestionId and OpenQuestion
survive: QuestionId (turn_id/round/slot) is still play.rs's own internal
admission-matching key, and OpenQuestion still carries it plus the
question's text — neither existed only to serve the deleted comparison.
Soul's own probe is now a passing regression test
(soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw): two
scripted questions in one turn, an answer carrying Q1's own token
(read off the served surface) is accepted for Q1 and refused as stale
against Q2. Mutation coverage: a missing token, a mismatched token, and
Q1's already-spent token replayed against Q2 are each refused. The real
client bridge round-trips a token through props.action and a bound text
field arriving together in one payload
(the_real_client_carries_the_answer_token_and_the_bound_text_together).
The committed eve_client_bridge_intents.json fixture needed no
regeneration: its own world.play click never has an open question, so
its action never carried a token to begin with.
crates/ghostlight-dungeon/src/play.rs, crates/ghostlight-dungeon/src/runtime.rs,
crates/ghostlight-dungeon/src/eve.rs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`tools/eve_client_bridge.mjs`'s `set` step found the rendered control and
typed into it without checking whether it was disabled, so a control a
real browser would render disabled -- and refuse keystrokes into -- still
silently captured a value here. That made PA.f167's own gap (a disabled
control cannot be proven untestable through the bridge) cheap to close:
the bridge now throws "bridge: node id <id>'s own control is disabled; a
real client cannot type into it" before assigning `element.value`.
BEHAVIOR CHANGE NOTE: this changes tools/eve_client_bridge.mjs's own
runtime behavior -- a `set` step against a disabled control now throws
instead of silently succeeding. Anyone driving the bridge directly against
a surface that renders a disabled field for the node ids it types into
will see a new failure where there was none before.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f169 (blocker): two more `client_intents` callers
(`world_create_seed_activate_and_play_round_trip_through_the_real_client`,
`a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted`)
ended in `.expect("node must be on PATH ...")` with no skip guard, so
`cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon` itself
failed in a `node`-less container -- Idunn's own gate. Both now carry the
same `eve_client_bridge_is_available()` skip guard the third caller already
had, printing a plain SKIPPED line naming themselves. Verified with `node`
scrubbed from PATH: all three skip and the full bin suite is still
156 passed, 0 failed.
PA.f172: `question_surface_version` (added in Cut 11 with no
`#[serde(default)]`) made a pre-Cut-11 `PlayTurnStore` row fail to decode,
which `PlayTurnStore::open` reported as "corrupt" and `runtime.rs` turned
into a silently missing play card. `STORE_SCHEMA` moves to
`ghostlight.play_turn_store.v2`; `question_surface_version` now carries
`#[serde(default)]`, so the same struct is a compatible read of a genuine
v1 row (`LEGACY_STORE_SCHEMA_V1`). `PlayTurnStore::open` recognizes that
schema id as legacy, not corrupt, recovers the turn, and upgrades the row
to v2 in place and durably. The strict canonical re-encode check still
applies to current-schema rows; a v1 row's bytes were never canonical
under v2 and the legacy branch does not attempt the comparison. A row that
matches the current schema id but is genuinely undecodable is still
refused as corrupt. `runtime.rs::runtime_readiness` now reports
`playStatus` ("ok"/"unavailable") on `/health`, the same
`Option -> ok/unavailable` shape `projectionStatus`/`controllerStatus`
already use, so a play table that failed to open is visible there instead
of only in a startup `tracing::warn!`.
PA.f173: `close_with_fault` left `question`/`question_surface_version` set
on the turn it closed. Unobservable through `open_question_id` only
because it gates on `state == AwaitingPlayer`, which closing always
leaves behind in the same write. `close_with_fault` now clears both,
matching `answer_turn`.
PA.f175: `execute_round` took one snapshot at round entry and reused it
for every arm in that round, so a round that authors (a `PLAY_TOOLS` run)
and then asks in the same round recorded `question_surface_version` from
before its own commit. The `ASK_PLAYER_TOOL` arm now fetches a fresh
snapshot at the moment the question actually opens.
Tests added: a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt,
a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt,
close_with_fault_clears_the_open_question_off_the_persisted_row,
a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round,
health_reports_play_status_from_state_play.
Dungeon bin: 156 passed, 0 failed (bridge available); 156 passed, 0 failed
with `node` scrubbed from PATH (3 SKIPPED).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f161: `world.play`'s staleness check compared a client's echoed
`sourceVersion` against `authenticated_wake_version` (surface_version +
play.revision), a value no real client ever echoes and that only ever grows
once a row has held any question. Every answer was refused, permanently. The
play row now records the world's own `surface_version` on `OpenQuestion`
itself at the instant a question opens (`execute_round`'s `ASK_PLAYER_TOOL`
arm); an answer is stale only when it names a version older than that.
`ScriptedPort` and its fixture helpers in `play.rs`'s own test module are
widened to `pub(crate)` (test-only) so `runtime.rs` can script a deterministic
question through the real HTTP route and prove both the accept and the
refusal.
PA.f162: the release gate shelled out to `node tools/eve_client_bridge.mjs`,
which Idunn's pinned Linux containers install no dependencies for. The real
vendored lowering's own intents for the full create/approve/seed/activate/play
round trip are now captured once, stamped with the `vendor/eve` submodule
revision that produced them, and committed
(`src/fixtures/eve_client_bridge_intents.json`). The gate replays them through
`api_router` with no `node` anywhere — verified by running with every
`node_modules` directory in the tree renamed out of the way and `node` off
`PATH`. A second, dev-only test regenerates the fixture through the real
bridge and diffs it, skipping (and saying so plainly) when `node` or the built
lowering is unavailable.
PA.f163: deleting the spawned turn's own `publish_projection` call left the
wake test green, because `dispatch_world`'s Ok arm already publishes a
revision for the immediate `accepted` response, before the turn's own round
ever executes. The test now drains that first message, waits for the turn to
actually close, and only then asserts a second, distinct wake.
PA.f164: `targets` and `jurisdictions` carried only a `placeholder`, which the
real lowering never captures; a create form filled exactly as its labels
suggest was refused as missing those fields. They (and `brief`, documented
"required, may be empty" the same way) now carry an authored `value` the
lowering's own `findAuthoredBindingValue` falls back to.
PA.f165: the surface document's own `playRevision` field had no reader beside
one direct-JSON-access test; deleted. `PlayTurnView::revision` still drives
`authenticated_wake_version`'s SSE wake internally.
PA.f166/PA.f167: corrected `PlayTurnView::revision`'s doc (still described the
fold Cut 10 undid) and `local_draft`'s doc, which called every field but
`bindingName` inert — `accessMode` is load-bearing too: an absent one defaults
the lowering's binding to `"read"` and disables the control.
PA.f168: `unwrap_bindings` now refuses a payload carrying both the envelope
`bindings` object and a flat sibling field instead of silently discarding the
sibling.
Cut the scar-tour narration at eve.rs's `authenticated_wake_version` doc, the
`authenticated_surface` play-arrival comment, and `publish_projection`'s own
comment; each now states the current rule directly instead of narrating the
cut that produced it.
Every rule above ships with a test that fails under a mutation of its
production call site; each was verified failing under that mutation and
reverted by hand before this commit.
Dungeon bin: 145 -> 151 tests (6 added). Library untouched, still 622.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cut 9 finished the play loop and gave the player an Eve card. Soul's
own probe over the vendored browser lowering found the player could
not actually use any of it: every bound command submitted
{"bindings":{}}, the card never woke after the first refresh, and one
play commit poisoned the version the kernel's compare-and-swap is
derived from.
One owner per version (PA.f148/PA.f160). The surface document's own
`version` names world.revision alone -- the one meaning
routeHint.sourceVersion and the kernel's compare-and-swap require of
it. The play row's own revision travels beside it as its own
`playRevision` field instead of folded in. The SSE wake signal
(`state.revisions`, `/api/eve/events`) is the one place the combined
value still lives, renamed `authenticated_wake_version` -- it is
never echoed back by a client, so folding is exactly its job.
The client round trip (PA.f149). `eve::local_draft` now emits the
vendored lowering's own EveStateBindingDescriptor shape -- copied
field-for-field from the vendor's own test fixture
(test/host-isolation.test.mjs), which builds exactly this under a
`ghostlight.play` surface id for its own local-draft binding -- so
`bindingName` actually matches what captureBindings names, instead of
falling back to the control's own node id. runtime.rs now unwraps the
lowering's own payload.bindings envelope before deserializing (the
documented gamecult.eve.command_invocation.v1 shape), and JSON-typed
textarea fields (targets/jurisdictions/lens_weights) accept the JSON
string a real textarea actually captures, not only an already-parsed
value. world.create's own form was also missing a control for
`brief`, a required field CREATE_BINDINGS had always advertised --
found by driving the real lowering over the real surface
(tools/eve_client_bridge.mjs), which no hand-built payload could ever
have caught.
The card wakes (PA.f150). publish_projection now runs after a play
turn's own execution finishes, on the same door eve_command's Ok arm
uses, not only at startup and after a world commit.
The question id never reaches the player (PA.f151). The lowering has
no authored, non-editable binding value ("hidden" is not a prop any
renderer reads), so the answers control is deleted outright --
server-side, execute_world's world.play arm resolves the open
question itself, and refuses a stale routeHint.sourceVersion (one
that predates the question) instead of trusting a client-named id.
PA.f84's guarantee survives: an answer cannot land on a question the
player never saw.
Play failures reach the player (PA.f153/PA.f154). PlayTable::run is
split into admit (replay/stale-answer/empty-answer/busy-turn/empty-
opening, synchronous) and execute (the round loop, spawned). The
route now awaits admit and answers denied with the reason instead of
discarding every refusal behind an already-sent accepted response.
Ownership (PA.f152/PA.f159). The play card, its controls, and the
world.play descriptor are owner-gated like world.advance_time and
world.seed, on both the surface and the route. player_subject now
resolves by human_controller matching the calling principal, not
merely by ControllerMode::Human alone. world.summary drops the
subject census; phase and clock stay.
Cleanup (PA.f155-PA.f158): stale doc references to the deleted
world.speak/story surface; PlayTurnView::turn_id and .state given
production readers (an error log line, and the admission gate's own
condition); the play row's revision surviving a restart pinned by a
new assertion; mutation M9.1 retired with the reason it can no longer
be expressed (the parameter it needed was deleted in Cut 9).
Tests: Dungeon bin 139 -> 145 (6 new), lib 622 -> 622 (untouched).
Discrepancies and known gaps, reported rather than resolved silently:
- The envelope-unwrap fix extends past the literal "envelope" wording
into payload *shape*: the JSON-textarea deserializer
(deserialize_json_capture) is an extrapolation beyond what the map
named, needed because the lowering has no JSON-typed control and so
can only ever capture such a field as text.
- The stale-answer refusal (execute_world's source_version check) has
no dedicated runtime.rs test: reaching AwaitingPlayer needs a
working inference backend, and the crate's only scripted one
(ScriptedPort) is private to play.rs's own test module, not
reachable from runtime.rs. The round-trip test's own "answer" leg is
scoped down to create/seed/activate/play for the same reason; PA.f151's
server-resolved-answer mechanism is proven instead at the play-table
layer, in play.rs's own (updated)
a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration.
- world.play's own route now checks world ownership explicitly before
admission; this was not named as an explicit sub-item but follows
directly from "owner-gated like world.advance_time and world.seed."
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Drives the real vendored eve-browser-lowering (submodule pin unmoved)
over a jsdom-rendered surface document: types into controls, clicks
buttons, and prints the resulting gamecult.eve.command_invocation.v1
intents. tools/eve_client_bridge.mjs is the harness the runtime.rs
round-trip test (next commit) uses to prove the client round trip
against Dungeon's own served surfaces, not a hand-built payload.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The play card and its own revision counter landed in the prior commit and
were verified end to end there (a question answered through the card's own
rendered binding, a turn closing with its narration visible through the
card, and the card's own subtree never rendering the operator feed). This
commit removes the mechanism the play card replaces.
Deleted: SpeakPayload, the "world.speak" arm in execute_world, eve.rs's story
card and speak control (the opportunity/affordance lookup that fed it),
"world.speak" from operation_schema, and current_operator_view (its only
purpose was fetching the operator log for the surface; authenticated_surface
no longer takes that parameter at all). world.summary's own body no longer
counts committed events, since that count depended on the same log read.
WorldMailbox::operator_log stays in the library; nothing in this crate's own
surface path calls it any more except one Cut 9 regression test that proves
the play card structurally cannot reach it.
Rewrote the tests that drove speech through Eve: http_eve_journey_uses_one_
world_owner's last leg now plays instead of speaking, and observes the play
table (current_turn_view) rather than trusting the accepted response alone
(PA.f138). eve.rs's payload_cannot_claim_authority and invocation_metadata_
rejects_legacy_authority_extensions now exercise world.play; the former
absorbed the now-redundant world_play_operation_schema_validates_though_not_
yet_advertised, since world.play is advertised as of the prior commit.
removed_operation_is_unknown gained "world.speak" as a case. every_operation_
the_panel_emits_has_a_schema now asserts world.play is among the buttons it
walks.
`rg -n 'world\.speak|operator_log' crates/ghostlight-dungeon/src` is not
literally zero: two deliberate hits remain (see the Hands report for exact
lines) — one regression assertion that the operation id is gone, and one
test-local call into the surviving library method to prove the play card
cannot reach what it returns. No production path matches.
Dungeon bin: 140 -> 139 tests (two redundant world.speak/world.play schema
tests merged into one), all passing. ghostlight lib: 622, untouched.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PlayTurnStoreState carries a persisted `revision: u64`, incremented once by
PlayTurnStore::swap_in (the row's one write door) on every committed write.
It never resets across a new turn or a restart, per the operator's ruling on
the fork Hands raised: a counter derived from existing row fields (calls.len,
rounds.len, ...) cannot see a refusal, which changes turn.refusal without
growing calls or rounds. PlayTurnView now carries the same revision, read
through PlayTable::current_turn_view.
eve.rs's authenticated_surface reads PlayTurnView and renders a play card
(world.play.card: narration, open question text, refusal line) plus a
world.play control binding ["text", "answers"], where "answers" is the open
question's own QuestionId, JSON-encoded into a bound control exactly as
lens_weights/jurisdictions/targets already encode a local-draft "json" field's
default value. authenticated_surface_version folds surface_version(snapshot)
with the play row's own revision, so a play-row-only change (no kernel patch,
e.g. a refusal or a closed turn's narration) still moves the surface version.
world.speak, the story card, and current_operator_view's log read for the
surface are all still present and unchanged; this is the "add" half of
add-first-delete-second. Both mechanisms coexist on the Active surface for
this commit.
New tests (play.rs): the_play_surface_shows_only_the_projection (the play
card's own subtree never renders operator_log content, even when it is
durably committed and world.story still legitimately shows it elsewhere
pending the deletion commit — mutation-verified by hand: rendering
operator_log into the card fails this test), a_question_is_answered_through_
the_cards_own_binding_and_the_turn_closes_with_narration (PA.f134 end to end,
answering with the exact id the rendered surface emitted, and proving
surface_version moves even though world.revision does not), and
a_commit_changing_only_the_refusal_still_moves_the_rows_own_revision (the
isolated regression for the fork itself — mutation-verified by hand: keying
the increment off calls.len() growing fails this test).
Dungeon bin: 137 -> 140 tests, all passing. ghostlight lib: 622, unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
RunOutcome now names the turn and its open question on every Ok path
(Ran/Replayed), and PlayError::EmptyAnswer/StaleAnswer carry the still-open
question a caller can retry against, so a consumer never has to read the
store to learn a QuestionId. PlayTable::current_turn_view is the small new
door a later, separate reader uses instead, since world.play's own route
spawns run in a task and answers before that call resolves (PA.f134).
Dispatch now resubmits its recorded subject list on resume instead of
re-parsing the tool call's arguments against whatever the round's own
snapshot says now, matching every other door's "recorded body decides" rule
(PA.f136). run_lock is acquired with try_lock, not a blocking wait, so a
request racing an in-flight turn is refused at once with TurnStillRunning
rather than queued behind up to ROUND_BUDGET rounds of inference (PA.f142).
turn.refusal is cleared when a player act commits, so it names only the most
recent one (PA.f144). service/play-turn-v1.cc joins the admitted state
layout's own pre-flight symlink/hardlink check (PA.f140).
Soul's own findings get their production-path tests: round_snapshot's two
callers pinned behaviorally through infer_round and execute_round separately
(PA.f137), dispatch_owner's earliest-call-wins rule pinned against a
duplicate dispatch (PA.f139), and three-subject concurrent dispatch pinned
against JSON order in both the summary and persona_turns (PA.f143). The
world.play routing test now polls current_turn_view to prove the spawned
task actually calls table.run, not just returns the same accepted JSON
(PA.f138).
PA.f147 (missing idempotency key) and PA.f141/f145/f146 (doc claims) were
investigated and are covered in Hands' own report rather than landed as
code: the described idempotency-key hole is already closed by
execute_world's own unconditional command_id parse before world.play's
branch is ever reached (mutation-verified), and the doc fixes are the
smaller diff already included in eve.rs/play.rs/runtime.rs here.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- AppState gains play: Option<Arc<PlayTable>>, populated exactly when
controllers is (both come from the one open_controller call). Cut 8b's
own map bullet asked for a play field plus a mutex; the "one running turn
per world" mutex (PA.f68/PA.f112) instead lives inside PlayTable itself
as run_lock (landed in the prior play.rs commit), since this process
holds exactly one Arc<PlayTable> for the one world it owns and a second
AppState-level lock over the same table would be a second owner of the
same invariant. Reported per the brief; see the Hands report for the
full account.
- open_controller now reads GHOSTLIGHT_PLAY_MODEL beside its four
siblings and passes it into the same open_inference call, returning the
opened inference organ and models alongside the ControllerRunner
(OpenedControllers) so the play table shares one connection to the
transports rather than opening a second. A new open_play helper builds
the PersonaLane and PlayTable from that shared organ, using
controller_permits as the play table's own concurrency pool.
- execute_world routes "world.play" ahead of the CommandBody match (it is
not one kernel command): spawns PlayTable::run in a background task and
returns {"kind":"accepted"} immediately, exactly as the map calls for.
A missing play table is denied, not a panic.
- eve.rs's operation_schema carries "world.play" =>
"ghostlight.world_play.v0" beside "world.speak", unchanged otherwise;
world.speak and the story card are untouched (Cut 9's own deletion).
Tests: world_play_reaches_the_play_table_and_is_accepted and
world_play_without_a_play_table_is_denied (runtime.rs), plus
world_play_is_advertised_and_payload_cannot_claim_authority (eve.rs,
beside payload_cannot_claim_authority for world.speak).
Dungeon bin: 125 -> 128 tests. ghostlight lib: 622 (unchanged).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Collapse infer_round's and execute_round's own duplicate snapshot-fetch
and handle-collision check into one round_snapshot() owner both call
(PA.f127/PA.f133). Delete the structural
soul_execute_round_and_round_tools_share_the_collision_refusal_call test
now that there is only one collision_refusal call site to count, and add
a_forced_handle_collision_refuses_the_round_through_round_snapshot, a
behavioural test driven through a new #[cfg(test)] force_round_snapshot
seam, matching before_submit_hook's own shape.
- execute_actor_call's ActionRejected arm now resolves the invocation's own
AffordanceSnapshot from the live snapshot and sets turn.refusal from
describe_refusal_to_actor (invariant 8: bare role names, no id, no
resolved label), instead of the agent-facing describe_refusal text.
Covered by a_refused_player_act_sets_the_refusal_line, seeding a
Draft-phase affordance with a real (Holds) precondition through
decode_authoring_call/CommandBody::AdmitPatch, since PLAY_TOOLS has no
door to a custom precondition.
- execute_dispatch now dispatches a round's fresh Personas concurrently:
each pending subject's turn is spawned under its own permit drawn from
the shared controller_permits pool (PersonaLane is now held as an Arc),
preserving PA.f124's own within-call duplicate-subject rule with an
explicit claimed_this_call set (concurrent dispatch means a repeat can no
longer be detected by checking turn.persona_turns alone, since neither
occurrence has landed there yet). Persists remain sequential once every
concurrent turn has resolved, so two subjects never race the same turn
row's compare_and_swap_batch. close_turn's own narration now also draws
a permit from the same pool, so it queues like a dispatch would.
- PlayTable gains run_lock, held for the whole body of run (PA.f68/f112):
the key-replay check and a fresh turn's own open now happen under one
acquisition, so two concurrent run calls for one table can never
interleave. Two new tests:
two_concurrent_world_play_calls_for_one_world_give_one_turn (same key,
concurrent calls: one Ran, one Replayed, one turn) and
two_worlds_own_play_tables_run_independently (two tables never share a
lock).
Dungeon bin: 122 -> 125 tests. ghostlight lib: 622 (unchanged).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f125 (blocker): execute_dispatch persisted a dispatched Persona's own
turn before execute_round ever recorded the Dispatch call's own body. A
crash in that window left persona_turns durable with no call record
naming it; on resume the already-durable turn was misread as a
different call's completed dispatch, so the resumed call's own tool
result became "<id> already acted" and the Persona's prose never
reached the agent (PA.f64). execute_round now records and persists the
call's own body before execute_dispatch runs any persona turn;
execute_dispatch tells its own not-yet-finished work apart from a
different call's completed dispatch through the new
PlayTurn::dispatch_owner (first call, in call order, to name a
subject). Replaced PA.f123's own test with
a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted,
which guards both the live durability ordering (before_submit_hook off
disk) and the resumed-call outcome (hand-built crashed turn).
Cleanup:
- PA.f128: closed a fourth turn in the ledger retry test so the
retried key's own entry sits at neither end of the ledger deque,
catching a `self.closed.back()` mutation the prior two-turn shape let
through.
- PA.f129: handle_for derives its handle from the library's own
id_text instead of a second hand-written debug-paren strip.
- PA.f130: deleted PlayError::NoPlayerOpportunity — no production site
raises it.
- PA.f131: removed the unneeded #[allow(dead_code)] on
WorldFixture::owner (the abort tests already read it).
- PA.f132 (ghostlight-persona-projection): corrected SourceSpan::locate's
doc comment, which claimed the first-byte check changed the worst
case rather than being a constant factor over non-matching
candidates; replaced the guard test's quote (which never called
starts_with at all) with a shared-prefix quote that forces the real
worst case.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>