Heimdall's Discord callback issues a random completion code distinct from
the attempt id, but the vendored adapter required the two to be equal and
rejected every real return as a malformed witness, so no player could sign
in. Re-vendored from Heimdall e8e2832, which completes by attempt id and
never reads the completion code; the test fixture issues a distinct code as
the server does.
A player who finishes Discord elsewhere and switches back to this tab never
reloads it, so the load-time resume never ran. The page now reruns the same
resume when it becomes visible with an attempt in flight.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Odin (96a55ec) now computes Ready with an external-operator-binding
dependency not ready, but CultLib's shared contract
(runtime_authority_contracts.rs:797-805) refuses any correlation claiming
Ready while a non-optional dependency is not ready, so Odin rejected every
Ghostlight Active presence (30/30 in the last deploy). The external kind
existed only because Heimdall isn't v2-managed; that reason doesn't survive
contact with Odin's own Ready computation.
Ghostlight's deploy stops declaring Heimdall as an Idunn dependency. The
daemon discovers it through Odin at use instead: HeimdallClient already took
Option<HeimdallDependencyBinding> with None as the non-managed path, so
runtime.rs now passes None directly rather than computing it from a
dependency_bindings field that no longer exists, and the loopback-only,
non-secret-bearing, app-bound HMAC+AES envelope enforcement is unchanged.
When Heimdall becomes a v2 target the dependency returns as `required`
managed.
- deployment/idunn/recipe.toml: deleted the heimdall.command-boundary
[[dependencies]] block. Ghostlight's own required_environment for
GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE/GHOSTLIGHT_HEIMDALL_BASE_URL is
unrelated (Heimdall's HTTP app integration, not the Idunn dependency) and
stays.
- idunn_health.rs: runtime_dependency_bindings now requires exactly the Odin
dependency (one, not two). Deleted HEIMDALL_CAPABILITY,
required_external_dependency and its doc comment, the `heimdall` field on
RuntimeDependencyBindings, the fixture's external_dependency helper and
its Heimdall entry, and the recipe-drift test's Heimdall block/assertion
(now asserts exactly one [[dependencies]] block and fails if a Heimdall
dependency reappears without the daemon changing to expect it again).
HeimdallDependencyBinding itself moved to heimdall.rs (see below) rather
than being deleted outright: it's still the shape
require_expected_heimdall_boundary checks a discovered boundary against
when Idunn does supply one, which stays true the moment Heimdall returns
as a managed dependency.
- heimdall.rs: now owns HeimdallDependencyBinding (moved from
idunn_health.rs, which no longer has any use for it). No behavior change:
HeimdallBoundaryLocator::Odin's `expected: Option<HeimdallDependencyBinding>`
and its None-tolerant checking logic were already exactly the
"discover through Odin, verify against a managed binding when one exists"
shape this ruling asks for.
- runtime.rs: expected_heimdall is gone; `HeimdallClient::from_env` now
takes `None` directly instead of a value computed from a
dependency_bindings field that no longer exists, with a comment on why.
Mutation: re-added the heimdall.command-boundary [[dependencies]] block to
the recipe -> recipe_matches_the_daemon_it_deploys fails (asserts exactly
one dependency block). Restored; deployment/idunn/recipe.toml is
git-diff-clean after, one clean 7-line deletion.
Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
192 passed, 0 failed, 4 ignored (unchanged count: removed a test helper, not
a test).
Structural delta: 4 files changed, 42 insertions(+), 138 deletions(-) in
Ghostlight (net -96 lines) — a genuine subtraction, not a relocation with
padding; the only addition of substance is the doc comment on the relocated
HeimdallDependencyBinding explaining why it still exists.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Odin refuses Active until it has correlated the lease Idunn just granted
("Ready topology state is not supported by correlated evidence"); the
first v2 candidate to get past Warming and the lease died on that single
refusal. Retry it with the helper and window the Warming publish already
uses; the periodic republish already only logs. The cadence constant now
names both presences.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
aca5686's fix was right but its two new tests always called acquire_recent
(custody enforced), and a test lease necessarily lives under a tempdir —
never root-controlled. On the pinned Linux image that failed with "process
write-lease ancestor contains a non-root-controlled directory" instead of
exercising the retry logic; on Windows it passed only because
require_process_write_lease_custody is a deliberate no-op there, which is
exactly the platform gap this whole fix is about. The deploy platform is
Linux, so the tests have to run there for real, not slip through on a
Windows-only no-op.
RuntimePresencePublisher now carries the same enforce_custody choice
ProcessWriteLeaseGuard::acquire_inner already had: true from
from_environment (every production construction path), false from the test
fixture constructor. wait_for_write_lease calls acquire_recent or
acquire_recent_fixture to match. acquire_recent_fixture is no longer
#[cfg(test)]-gated, since production's own wait_for_write_lease source must
reference it (unreachable there, since production publishers always carry
enforce_custody: true, but the symbol has to exist in a release build). No
trait, no env flag — a plain bool threaded through the existing constructor.
Added production_custody_choice_rejects_a_lease_under_a_non_root_tempdir:
builds a publisher the way from_environment does (enforce_custody: true)
via a new fixture_with_state_contract_and_custody helper (the existing
fixture() stays enforce_custody: false, unchanged), points it at the same
empty-revoked-store shape the other two tests use, and asserts
wait_for_write_lease still refuses it with a custody-rejection error rather
than acquiring it. This one is #[cfg(target_os = "linux")]-gated —
correctly, not as a workaround: custody enforcement is architecturally a
root-ownership concept that idunn_health.rs's own code makes a no-op on
every other OS, so there is nothing for the assertion to observe on Windows
regardless of this fix. The two tests from aca5686
(an_empty_revoked_lease_store_times_out_not_not_admitted,
a_lease_written_after_an_empty_revoked_store_still_wins) needed no changes
beyond inheriting enforce_custody: false through fixture() as before — they
were already testing the retry logic, not custody, and now do so honestly on
both platforms since custody enforcement is out of their path entirely.
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon (Windows): 192
passed, 0 failed, 4 ignored, unchanged from aca5686 — the new Linux-gated
test does not compile into this binary here, so it does not appear even as
ignored. Awaiting the Linux run for its count and the custody-rejection
assertion.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Live failure under Idunn v2 (deploy up-180cf07f): the candidate died in ~2s
with "process write lease was not admitted: ... zero records". Idunn's own
revocation of a prior candidate's lease leaves the store and its .lock
present but empty (a 22-byte zero-record CultCache file), which
`path.exists() && lock.exists()` cannot distinguish from a real lease.
wait_for_write_lease called acquire_recent exactly once on that signal and
let its failure propagate.
wait_for_write_lease's job is "wait until the exact lease for this
incarnation is admitted", not "wait until something exists at the path". On
each poll where the store exists, it now attempts acquire_recent; on failure
it remembers the error and keeps polling (heartbeats unaffected); only the
outer deadline bails, and that bail carries the last acquisition error as
context for diagnosis. acquire_recent's own exact-match checks are untouched
and stay the authority — nothing here special-cases empty stores by size or
by matching error text, so the same retry covers empty, stale-incarnation,
and not-yet-written stores alike, exactly as asked.
Tests (idunn_health.rs, all run on Windows — decode_write_lease and
acquire_inner's enforce_custody path have a Linux-only branch inside
require_process_write_lease_custody, but that function no-ops on non-Linux,
so this whole path is exercised here too, not gated):
- an_empty_revoked_lease_store_times_out_not_not_admitted: builds a
present-but-zero-record store via push-then-delete of a throwaway envelope
(the crate's own public store API, not hand-encoded bytes) — the same
shape Idunn's revocation leaves. Asserts the wait runs its own full 600ms
timeout (not an instant "not admitted") and that the timeout error still
carries "not admitted" in its chain.
- a_lease_written_after_an_empty_revoked_store_still_wins: same empty start,
but a valid lease for the fixture's own incarnation is written 300ms in
from a spawned task. Proves the retry re-attempts acquisition on every
poll, not just the first: the wait returns the written lease.
Mutation: reverted to a single-shot acquire (return on first attempt
regardless of outcome) -> the empty-store test fails in ~1ms with the
pre-fix symptom instead of running its 600ms timeout. Restored; diff-clean
after.
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 192 passed, 0
failed, 4 ignored (was 190 before this pass; +2 new tests).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Soul's diagnosis: Idunn publishes Expected and starts the unit before it
observes the running process on a later tick and publishes the activation
(Idunn/src/control_plane.rs). Until that activation exists, Odin refuses
every Warming presence this daemon publishes ("no exact current activation
and provider anchor"), and the daemon's single initial publish_warming()
call, and every heartbeat inside wait_for_write_lease, propagated that
refusal through `?` and killed the process. Muninn tolerates the same gap by
re-publishing every 2s and only logging failures
(Muninn/crates/muninn-daemon/src/main.rs); this cut matches that posture in
Ghostlight, touching only the daemon.
1. initialize_production_admission now retries the initial publish_warming()
at a 2s cadence (matching Muninn), bounded by the same 120s window
wait_for_write_lease already used — pulled into one named constant,
WRITE_LEASE_WAIT_TIMEOUT, reused by both rather than inventing a second
value. Each retried publish is a genuinely new message, not a duplicate
Odin would drop: signed_record increments publisher_sequence and refreshes
observed_at_unix_millis on every call (verified by reading it, not
assumed). Every failed attempt is logged at warn with its error; the loop
exits only when the window expires, and returns the accepted presence so
it carries forward into wait_for_write_lease's recent_warming_proofs
exactly as the single call used to.
The retry policy is retry_until_deadline, generic over the attempt
closure and a sleep/clock seam (no trait hierarchy on the publisher) so
it's unit-testable without waiting real time out: production passes
tokio::time::sleep and Instant::now, a test passes a no-op sleep paired
with a fake clock that only advances when the sleep seam is called.
2. wait_for_write_lease's own heartbeat publish no longer propagates a
failure through `?`; it logs a warning and keeps waiting, exactly like
the initial-publish retry, until the wait's own outer deadline.
3. Tests (crates/ghostlight-dungeon/src/runtime.rs, idunn_health.rs):
- retry_until_deadline_returns_the_first_accepted_value: refused twice
then accepted succeeds and returns the accepted value, via a fake
clock — runs in 0.00s, no real waiting.
- retry_until_deadline_fails_with_the_last_error_after_the_window:
always-refused fails once the fake clock crosses the deadline,
carrying the last attempt's own error.
- a_failed_heartbeat_does_not_abort_the_wait_for_lease: a real fixture
publisher pointed at a closed port (127.0.0.1:9, the existing fixture
convention) so every publish fails fast; proves the wait survives past
one heartbeat interval and only ends at its own outer timeout, not the
heartbeat's own error. This one uses real timing (~6s) since
wait_for_write_lease's internal loop has no clock seam and adding one
was out of scope for this cut; verified by mutation that it would have
caught the old bug (see below).
Mutations, all verified and reverted (diff-clean after):
- retry_until_deadline: made the first failure unconditionally fatal
(`if true || now() >= deadline`) -> both new retry tests fail, the
first on attempt 1 instead of retrying to attempt 3. Restored.
- wait_for_write_lease's heartbeat: restored the old `?`-propagating
form -> a_failed_heartbeat_does_not_abort_the_wait_for_lease fails in
~5.6s with "publishing Warming heartbeat ... timed out connecting" (the
pre-fix symptom), instead of running to the 6s outer deadline. Restored.
Decisions made without a further ruling:
- Named the shared timeout constant WRITE_LEASE_WAIT_TIMEOUT (both the
initial-publish retry and wait_for_write_lease's own timeout parameter
use it) rather than threading one Duration value through both call
sites by hand.
- Put retry_until_deadline in runtime.rs, beside
initialize_production_admission, its only caller — not in
idunn_health.rs, which owns the publisher/protocol, not retry policy.
- The heartbeat-tolerance test uses real timing rather than adding a
clock seam to wait_for_write_lease's own internal loop; that loop's
shape (poll write-lease file + sleep 100ms + heartbeat check) is
unrelated to this fix and reworking it for a seam felt like scope creep
against the operator's instruction to avoid a trait hierarchy.
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 190 passed, 0
failed, 4 ignored (was 186 before this pass; +3 new tests here, +1 from
Self's own commits since the last pass).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Idunn launches managed targets with PrivatePIDs=yes; systemd writes the
outer-namespace pid into LISTEN_PID, which the daemon cannot observe, so
the first v2 probation process exited at once with "systemd authority FDs
belong to another process". Odin made the same change in 8e16f56.
The exact descriptor count and name order, the root-owned read-only
descriptor checks and the signature verification of their contents bind
the descriptors to Idunn; LISTEN_PID must still be a canonical non-zero
pid. The layout test is now pure and runs on every platform; restoring
the own-pid comparison fails it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Idunn passes a runner container only the variables its step declares, and
the image's own CARGO_HOME is read-only, so the first v2 build failed
writing cargo's git database for the codex-connector dependency. Rust
steps declare CARGO_HOME and HOME, web steps HOME and NPM_CONFIG_CACHE, as
Odin and Heimdall do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dungeon runs on Yggdrasil under Idunn v2; Raven hosts only Bonsai and the
link. The scheduled task is unregistered and the secret copies removed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
S1 (critical): require_runtime_bundle and require_root_read_only_file required
gid 0 on the Idunn runtime bundle directory and its documents. Idunn creates
the bundle inside runtime_root, which is setgid to the workload's own group
(so the process write-lease record beside it can share that gid,
Idunn/src/drivers.rs), and hardens only uid 0 + mode (0555 dir, 0444 files) —
never gid. The bundle inherits a real workload group, never 0: the live Odin
bundle is root:odin and Odin accepts it. Every managed daemon on Yggdrasil
would fail this exact check at Warming.
Extracted the rule into two pure functions over (uid, gid, mode) —
is_root_service_read_only_directory and is_root_read_only_file — so it is
testable without a Linux filesystem instead of sitting unexercised behind
#[cfg(target_os = "linux")] the way the real check always has (nothing in
this crate built a chowned/chmodded fixture for it before). gid is accepted
as a parameter but never checked, with a comment saying why. Also routed
require_root_controlled_directory_chain's existing check (which never
required gid 0) through the same directory predicate, for one owner of the
rule instead of two copies of it.
Added root_read_only_accepts_any_gid_and_refuses_group_or_other_write,
covering: any gid accepted, owner-write allowed on directories (0o022 never
checked it), group/other-write refused on both files and directories, wrong
uid refused. Mutation: restored `&& gid == 0` in
is_root_service_read_only_directory, confirmed the any-gid assertions fail,
reverted (diff-clean).
S3: recipe_matches_the_daemon_it_deploys previously matched anywhere in the
recipe text, so it passed with Odin's and Heimdall's dependency kinds
swapped, passed when only [[provides]]'s schema was wrong (the world slot's
own copy was still right), was blind to the world/mesh slot schemas
specifically, and never checked STATE_CONTRACT_SHA256 at all. Added
recipe_blocks/find_block to anchor every assertion to its own table (by id
for state slots, by capability for provides/dependencies), and pinned
EXPECTED_STATE_AND_SLOTS_TEXT beside STATE_CONTRACT_SHA256 — the exact
[state]+[[state.slots]] source text the hash was computed from (LF, matching
the committed git blob, not a Windows checkout's autocrlf) — with a comment
that both constants change together. No TOML parser or msgpack encoder is in
this workspace, so the test cannot recompute the hash itself; pinning the
source text it was computed from is the cheapest honest proxy.
Verified all four of Soul's named mutations now fail correctly and were
reverted (recipe.toml is git-diff-clean after): provides schema changed alone
(world slot untouched) -> fails on the provides block; Odin/Heimdall
dependency kinds swapped -> fails on the Odin block; world slot schema alone
-> fails on the world block (previously the blind spot); an app-sessions
writer/recovery edit -> fails on the STATE_CONTRACT_SHA256 text pin.
Found and fixed one collateral break: my new doc comment for
EXPECTED_STATE_AND_SLOTS_TEXT named `StateDeclaration`, which matched the
existing play::tests::soul_no_forbidden_writer_name_appears_in_dungeons_own_source
pin (it scans non-test production source, including doc comments outside a
`#[cfg(test)]` line, and it forbids the substring "Declaration"). Reworded to
"the recipe's own parsed `[state]` struct" — no functional change.
Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
186 passed, 0 failed, 4 ignored (185 -> 186, +1 for the new pure-predicate
test). cargo test -p ghostlight --lib controllers:: -> 131 passed, 0 failed,
1 ignored, unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
F1 (critical, Soul): managed admission still bailed without
GHOSTLIGHT_CONTROLLER_CONNECTOR and required Idunn's Expected to carry a
third, connector, dependency that no longer exists anywhere upstream.
idunn_health.rs now reads no connector env, requires exactly Ghostlight's two
real dependencies (Odin, Heimdall), and RuntimeDependencyBindings/
RuntimePresencePublisher carry no connector field. runtime.rs's
open_controller no longer takes an endpoint parameter for a lane it cannot
offer: Dungeon passes None for ConnectorBinding and says why in a comment.
configured_connector_endpoint is deleted. The library's CodexConnector
transport (crates/ghostlight) is untouched, and play.rs's own use of the
codex_connector crate for transcript types is unrelated and untouched.
F3: Heimdall is not an Idunn-v2-managed incarnation (every v2 deploy has
failed; it runs as legacy heimdall.service). Idunn projects an
[[external_capabilities]]-declared provider as provider_authority =
"external-operator-binding" with no provider_expected_projection_sha256
(Idunn's DependencySelection::expected_projection), never the managed shape a
"required" dependency expects. Recipe's Heimdall dependency kind is now
"external-operator-binding"; idunn_health.rs's required_external_dependency
checks exactly that shape. Odin stays a managed shared-infrastructure
dependency.
F2: the recipe disagreed with the binary on every declared contract, so
Warming would fail even with F1/F3 fixed. schema_generation was "world-v2"
against the daemon's "world-v3"; ghostlight.world-service provided
"foundation-v0" against the binary's actual ghostlight.world_state.consumer.v6
/ consumer-v6 (STATE_SCHEMA); controller-work's slot schema was pinned at
"v3" against the code's "v17". Fixed all three against the source constants,
and recomputed idunn_health.rs's STATE_CONTRACT_SHA256 against the corrected
[state] block using Idunn's own canonical_state_contract_sha256 (msgpack +
sha256 of the recipe's StateDeclaration) run against the finalized recipe —
not guessed. Promoted CONTROLLER_WORK_SCHEMA (ghostlight) and STORE_SCHEMA
(ghostlight-dungeon's app_session) from private to pub/pub(crate) so a test
can check itself against them instead of hand-copying version numbers.
Added one Dungeon test, recipe_matches_the_daemon_it_deploys, pinning recipe
strings against the daemon's own constants: state generation, provided
schema/compatibility, all four slot schemas, the exact dependency count and
kinds, and the absence of the retired CodexConnector dependency. No TOML
parser exists in this workspace's Cargo.lock, so it string-matches the exact
values rather than parsing into a struct — it catches value drift but not
field-placement drift a real parse would also catch (documented in the test's
own comment). Mutation-verified: corrupting schema_generation fails it;
reverted (diff-clean).
Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
185 passed, 0 failed, 4 ignored (was 184; +1 for the new test). cargo test -p
ghostlight --lib controllers:: -> 131 passed, 0 failed, 1 ignored, unchanged.
Negative grep for CONNECTOR in crates/ghostlight-dungeon/src: zero survivors.
sudo -n idunn validate on Yggdrasil against the updated recipe + binding:
both ok.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The NoAudience rejection Soul flagged (fresh-workspace-handoff.md) was a
missing position, not a transport problem: all three subjects in
real_local_model_cognition_modes_commit_speech declared position: None, so
the kernel had no audience for the seed speech. Add a Place ("roll-hall")
and set each subject's position to it, copying the pattern already used by
the passing persona-lane fixture (~line 7550). No other test body or
assertion changes.
Verified against real Bonsai over the Raven tunnel: passes in 284.68s,
persona prose "Mara hears Iris. She says, \"I hear you.\"" Mutation
re-verified with open_inference actually reached this time: pointing
LocalBinding at the closed loopback port 127.0.0.1:9 fails at inference
("NarrativePersona acceptance did not complete"), not at NoAudience;
reverted by reverse edit (diff-clean).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CodexConnector is not a real option for Dungeon inference (operator ruling
2026-09-23): Bonsai 2 reaches Yggdrasil over a restricted reverse SSH tunnel
at loopback 127.0.0.1:18080. Rename
real_codex_connector_cognition_modes_commit_speech to
real_local_model_cognition_modes_commit_speech, open inference with
LocalBinding instead of ConnectorBinding, and read
GHOSTLIGHT_LOCAL_ENDPOINT/GHOSTLIGHT_LOCAL_MODEL_PREFIX in place of the
connector endpoint and credential. Update the Idunn recipe's acceptance step
and service required_environment to match (drop the connector env, add the
elaborator model var the test already required but the recipe omitted), and
drop the gamecult.codex.subscription-inference dependency: Dungeon no longer
depends on CodexConnector for inference. Update the remaining live references
to the old test name in notes/map.
CodexConnector's transport code and the v1 actuator are untouched; both are
separate follow-up cuts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Operator ruling 2026-09-23: Dungeon on Yggdrasil via Idunn, Bonsai on
Raven over a restricted reverse tunnel on the WireGuard mesh;
CodexConnector is not a Dungeon inference option.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Dungeon decoded Heimdall's published command boundary and every private
receipt with deny_unknown_fields over the subset of fields it reads.
Heimdall has published owner, lifecycleAuthority, healthPublication,
forbiddenWriters and compatibility on the boundary since June, and a
schema field on every receipt, so sign-in failed at discovery with
"unknown field `owner`" and would have failed again on each receipt.
Heimdall owns those documents; Dungeon now reads the fields it needs and
ignores the rest. The signed PrivateEnvelope, whose shape both sides fix,
stays strict. The new test decodes the shapes Heimdall emits and fails
with the reported error when the boundary is made strict again.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The browser validates every result against gamecult.eve.command_result.v1
before it reads a field of it, and that schema is additionalProperties:false
over schema, receipt, transientProjection, pluginPayload and draftDirective.
Dungeon returned a flat object -- state, message, sourceVersion, providerId,
surfaceId, operationId, idempotencyKey, updatedAtUtc at the top level -- so
the client rejected every result whole with "invalid Eve command result: data
must not have additional properties". Sign-in was simply the first command a
player could press.
The per-command detail now rides in the receipt, which is
gamecult.eve.command_receipt.v1 and does admit its own extra fields, so the
sixth argument keeps working as receipt detail rather than a sibling.
The tests read the shape they now produce, and one new test reads the two
contracts off the pinned vendor/eve checkout and checks a real result against
them, rather than restating the allowed keys in an assertion that would only
ever agree with its own author.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>