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>
locate compared quote against every boundary with starts_with before
checking anything cheap, and allocated a fresh HashSet copy of the boundary
list on every call. Now compares the first byte before starts_with, and
binary-searches the already-sorted boundaries Vec for the end offset instead
of a second HashSet. The doc comment's stale "repeated-find shape below this
comment" line (referring to code this file no longer has) is replaced with
the real bound and a pointer to the new regression guard.
The existing guard (locate_completes_over_a_long_source) used a 64k-char
single-word source with only two boundaries, so it never exercised the
O(boundaries) shape the bound is about. locate_is_fast_over_many_boundaries_
and_a_long_quote adds a 528,000-char many-boundary source against a
264,000-char non-matching quote. Measured on this workstation (this test
alone, cargo test's own reported time): pre-fix ~0.31s, post-fix ~0.13s —
real, but well short of the 1.73s figure this task's own brief quoted for a
"528k against a 264k quote"; that number was not reproduced with this exact
input shape and is not claimed here.
Two rules had no test: locate_returns_the_leftmost_boundary_valid_candidate
(reversing the boundary walk returns the last candidate instead of the
first) and locate_refuses_an_empty_quote (removing the empty-quote guard
locates it as the vacuous span 0..0). Verified live: reversing the walk
returned start_byte 8 instead of 0; dropping the guard located "" as
Some(0..0); both reverted before commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
execute_dispatch's own summaries ("already acted", "holds no live Persona
opportunity") printed {subject:?}, a fifth id spelling (raw SubjectId(...)
debug) alongside table_view's printer, handle_for, and dispatch's own
matcher. Both now print through the library's id_text.
New test dispatches the same subject twice in one call to reach the
"already acted" branch and asserts the summary contains id_text's own
spelling and never "SubjectId(". Verified live: reverting either print to
{subject:?} fails the test; reverted before commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
execute_dispatch was the only call kind that did not persist at its own
site: every other tool call's own door persists its recorded body before
submitting, but a dispatch call's own continue reached none of that until
the round's own later persist. A crash between two subjects in one dispatch
call, or between the dispatch call and the round's own trailing persist,
would re-infer the subject's Persona prose on resume, and an actor call
already checked against the recorded prose would be checked against
different prose instead. execute_dispatch now calls self.persist(turn)
immediately after recording each dispatched subject's own PersonaTurn.
PA.f99's before_submit_hook test seam now also fires from execute_dispatch
right after that persist, so a test can read the durable (disk) turn at
that exact instant rather than the in-memory copy, which is mutated
regardless of whether the persist happened. A first naive test (checking
disk state from the *next* call's own before-submit point) passed even with
the persist deleted, because that next call's own persist writes the whole
turn regardless — a false sense of coverage worth recording. The real test
reads disk from execute_dispatch's own hook fire. Verified live: deleting
execute_dispatch's own persist call while keeping the hook fire made the
test read the stale pre-dispatch turn and fail; reverted before commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
skip_retry_delay_in_test was a #[cfg(test)] switch, so production's
retry_delay never ran the tested path. retry_delay_base_ms is now an
ordinary AtomicU64 field on PlayTable, defaulting to RETRY_DELAY_BASE_MS
(100ms, the value backoff_delay always used); backoff_delay itself now takes
the base as a parameter instead of a hardcoded constant. Production and test
both call the identical retry_delay -> backoff_delay path; only a test
explicitly zeroes the base through the test-only set_retry_delay_base_ms
setter (matching set_before_submit_hook's own shape). before_submit_hook
stays #[cfg(test)], with a comment on why it differs from the delay: it is
Dungeon's own crash-window test seam with no production caller, not a
formula production must also exercise.
The two retry tests now set the base to zero explicitly and one asserts
elapsed wall time stays under 2 seconds. Verified live: making retry_delay
ignore the field and always scale from the production base pushed
a_retryable_fault_exhausts_its_budget_then_closes to 36.5s real time,
failing the new bound; reverted before commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f118: round_tools's own handle-collision check and execute_round's
resume-path copy now share one owner, collision_refusal, so the two cannot
drift. WorldFixture keeps its mailbox's own background owner task instead of
discarding it as `_owner`, so a test can `.abort()` it and make the very
next mailbox call fail deterministically (aborting only kills future calls;
it does not affect the persona lane's own scripted inference port, since
that never touches self.play). Two new tests exercise this: one forces
execute_round's own snapshot() to fail on a resumed round; the other
dispatches two Personas together with a custom AbortAfterFirstPort that
aborts the owner right after the first dispatched subject's own inference
call completes, so that subject's dispatch finishes normally and the
second's execute_dispatch-internal snapshot() then fails deterministically —
in-process and race-free rather than timing-based. Both assert the turn
closes with its fault recorded rather than propagating the error out of run
unhandled. The false in-code claim that no deterministic way to force this
existed is deleted.
PA.f119: the resume-path collision check (execute_round) could not
previously be reached with a genuine two-real-subject collision the way
round_tools's own test reaches it, because execute_round always re-fetches a
fresh snapshot from the live kernel and SubjectId is only ever minted as a
random v4 UUID with no public forgery path — only round_tools takes a
caller-supplied snapshot. A source-presence test
(soul_execute_round_and_round_tools_share_the_collision_refusal_call), the
same shape this file already uses for
soul_exactly_one_production_site_mints_play_port, asserts collision_refusal
is called exactly twice in production, catching deletion of either call
site; the reachability gap is recorded, not silently declared solved.
Verified live: reverting execute_round's and execute_dispatch's own snapshot
match arms to a bare `?`, and deleting either collision_refusal call site,
each broke exactly the test meant to catch it; all four mutations reverted
before commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f115: span_is_exact's own subject filter (invariant 4) had no test that
would fail if it were deleted. New test dispatches two Personas together and
has one speak the other's own recorded turn prose verbatim, expecting
refusal. Verified live: deleting the filter made the test fail with
`applied` instead of a refusal; the filter itself was not touched.
PA.f116: the_ledger_forgets_a_closed_turns_keys_past_the_window now reads
through turn_id_for (contains is gone, see PA.f117) and,
separately, a_retried_key_from_a_turn_before_the_most_recent_is_a_no_op adds
a third closed turn and retries the middle one so the matched ledger entry
is not the front entry. With only two closed turns the retried key's entry
always happened to sit at the front, so a `turn_id_for` that ignored the
search and returned the front entry's id passed unnoticed. Verified live:
that mutant now fails the assertion; reverted before commit.
PA.f117: KeyLedger::contains is deleted — its only remaining caller was the
eviction test, now moved onto turn_id_for. Rebuilding with `cargo test`
(the module has no production caller outside tests until 8b wires it, so a
plain `cargo build` flags nearly everything and is not the right lens)
turned up exactly four items in play.rs genuinely unused even by the test
suite: PlayError::NoPlayerOpportunity (no site raises it yet — narrow
#[allow(dead_code)] added, reported below) and a trio of orphaned test
helpers (persona_model, TableFixture, table_with) with no caller anywhere,
which are deleted rather than allowed, since nothing else touches them.
`main.rs`'s blanket `#[allow(dead_code)] mod play` is removed.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f113: Dungeon's `full_id_text` and the fourth hand-copy of it in the test
module (`subject_id_text`) are deleted. `annotate_actor_tools` and
`parse_dispatch` now read/match ids through the library's own `id_text` and
`id_text_matches`, so a change to what `table_view` actually prints cannot
pass silently again. The test helper `subject_id_text` now reads a subject's
id out of a real `table_view` render's own `Subjects:` section (not the
`Places:` occupant listing, which prints the same "label [id]" shape earlier
in the string) instead of re-deriving it, and every call site was updated to
pass the snapshot. Verified live: mutating table_view's Subjects-section id
print to append a suffix made `dispatch_by_the_printed_full_id_runs_the_persona`
fail with a refusal quoting the mutated text; reverted before commit.
PA.f114: `parse_dispatch` used to silently drop a non-string subjects entry,
a malformed (non-object or non-JSON) body, and a missing `subjects` key,
contradicting PLAY_INSTRUCTIONS' promise that a bad id is refused rather than
dropped. Each shape is now refused in the tool result, quoting what was
given: invalid JSON, a non-object body, a missing `subjects` key, a
non-array `subjects`, and a non-string entry. Three new tests exercise
`parse_dispatch` directly, the exact function the dispatch tool call handler
runs arguments through.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
table.rs owns one id printer (id_text, now pub) and its matching parser
(id_text_matches), both against the same debug-paren strip. table_view
prints through id_text; id_text_matches is exact equality against it, so a
consumer reading an id an agent typed cannot drift from what the printer
prints without a shared edit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PA.f98 (high): dispatch takes each subject by its full id, exactly as
table_view prints it in brackets, never the short <handle>__ actor-tool
prefix. parse_dispatch now matches on a new full_id_text (mirrors the
library's own id_text, undocumented-but-identical, without handle_for's
8-hex truncation) and returns unmatched ids separately; the dispatch call
site folds them into the result as a named refusal ("`<text>` names no
dispatchable subject") instead of dropping them. Actor tool descriptions
now carry their own subject's label and full id (round_tools annotates the
library's own actor_tools output in place, so table.rs stays untouched).
dispatch's own tool description and PLAY_INSTRUCTIONS restated to match.
- PA.f104 (medium): dispatched is no longer snapshotted once before a
round's own calls execute; an actor call now derives it fresh from the
turn's own record at the point of resolution, so a same-round
[dispatch(Mara), <mara>__speak, end_turn] resolves speak identically on a
first pass and on resume.
- PA.f99 (medium, test gap): added a before-submit test hook
(#[cfg(test)] only) fired right before each of the three submit doors
(commit_authoring_run, execute_advance_time, execute_actor_call). It hands
the hook a fresh PlayTurnStore::pull_current_from_disk_for_test() read —
a fresh `pull_all` against the store's own already-owned redb Database,
never the cached self.state and never a second store (CultCache's
single-owner lock refuses a second OwnedRedbMessagePackBackingStore::new
on the same path outright; discovered building this seam). Three new
tests observe the durable body at the moment of submission.
- PA.f100 (medium, test gap): the same request key, reused only after
eviction from the KeyLedger window, must mint two distinct fresh turn_ids.
- PA.f101 (medium, test gap): answers: None while a question is open is
refused (StaleAnswer) and records nothing.
- PA.f102 (medium, test gap): split the old "exhausted beyond retries" test,
which actually only ever drove RecoveryRequired, into an honestly-named
RecoveryRequired test (asserts exactly one call) plus a new Retryable
budget-exhaustion test (asserts exactly ROUND_BUDGET + 1 calls, pinned to
a literal, not to the ROUND_RETRY_BUDGET symbol, so a budget mutation
can't drag the expectation along with it). retry_delay's zero-under-test
behavior is now its own small #[cfg(test)] bool (skip_retry_delay_in_test)
rather than a #[cfg(test)] swap over the whole formula; the formula itself
(backoff_delay) is a free function, always compiled, with its own unit
test.
- PA.f103 (medium, test gap): added tests for a narrate failure right after
end_turn and at the round budget, both closing with the fault recorded.
The snapshot-failure site in execute_round is reported, not covered: no
deterministic way to fail WorldMailbox::snapshot exists in this crate's
test harness without tearing down the mailbox's own background actor
independently of the fixture; documented in place.
- PA.f105 (low-medium): the resume path through execute_round now runs the
same handle-collision check round_tools runs (it never did). Added a real
test forcing a genuine collision between two real SubjectIds (spliced via
serde_json, since SubjectId is #[serde(transparent)]) and calling
round_tools directly — this was wrongly reported infeasible in the prior
pass. The resume-path (execute_round) wiring itself has no live-collision
test: forcing one needs the kernel's own snapshot to already carry one,
and the kernel mints every SubjectId internally; documented alongside the
precedent this codebase already uses for the same shape of gap
(a_refused_player_act_sets_the_refusal_line).
- PA.f108 (low): empty or whitespace-only text with no turn open to
continue (none at all, or Closed) is refused (PlayError::EmptyOpening)
before a fresh turn opens, making zero inference calls.
- PA.f109 (low): run's outcome is now RunOutcome::Ran or
Replayed { turn_id }; a replay reads ClosedTurnKeys::turn_id for the
first time anywhere in production (via a new turn_id_for_key on the
store and turn_id_for on the ledger).
- PA.f111: PLAY_INSTRUCTIONS' own doc comment rewritten off the "fresh
table_view per round" framing into the recorded-opening rule (S6).
Every mutation named in the task spec (delete/move each of the three
PA.f99 persists, turn_id=hash(key), delete the round_tools collision call,
accept a None answer, accept an empty opening, make RecoveryRequired retry,
raise the retry budget, remove the backoff cap, turn each of the two
testable close-with-fault sites back into a bare `?`) was applied, run
against its matching test, confirmed to fail, and reverted.
Test counts: Dungeon bin 98 -> 113, lib 622 -> 622 (one test renamed and
reshaped, no net add), persona-projection 30 -> 32.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PA.f106: SourceSpan::locate no longer repeats a `find` over a shrinking
haystack on each rejected overlap candidate. It now tests `quote` at each
UAX #29 word boundary directly, dropping the repeated-find shape that made
a pathological input (a long single-word run) quadratic. Doc comment
rewritten off "used to return None" history into the current rule
(PA.f111). Added a 64k-char regression guard.
- PA.f107: added Soul's multibyte overlap case
("xéé éé éé", "éé éé"), which a byte-stepping mutation (iterating raw byte
offsets instead of the boundary list) fails against directly (verified,
reverted).
- PA.f110: sets_persona_of_existing_subject_scans_every_operation_not_just_the_first
put the persona write at the end of a 2-op patch, so a `.rev().take(1)`
mutation on the production scan would still pass. Renamed and rebuilt with
the persona write strictly in the middle of a 3-op patch (harmless
Relocate before and after), so both `.take(1)` and `.rev().take(1)` fail
it (verified both, reverted).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>