Commit graph Ghostlight/crates
Author SHA1 Message Date
Meta Vens
c5bf090d62 Stop declaring Heimdall as an Idunn dependency
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>
2026-09-23 20:29:00 +02:00
Meta Vens
203dc3658e Retry the initial Active presence through Odin's correlation gap
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>
2026-09-23 20:11:26 +02:00
Meta Vens
140445dda8 Let the publisher carry its own custody choice, so the lease-retry tests run on Linux
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>
2026-09-23 20:01:54 +02:00
Meta Vens
aca5686f01 Retry write-lease acquisition, not just its existence check
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>
2026-09-23 19:57:01 +02:00
Meta Vens
a397132223 Tolerate Idunn's activation-publication gap in the daemon's own Warming path
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>
2026-09-23 17:38:31 +02:00
Meta Vens
1fd52b7c60 Stop comparing LISTEN_PID to the daemon's own pid
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>
2026-09-23 17:17:05 +02:00
Meta Vens
e1c54c8f28 Fix runtime-bundle gid check and strengthen the recipe-drift test (Soul pass 2)
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>
2026-09-23 16:25:12 +02:00
Meta Vens
c716b57730 Cut CodexConnector out of managed admission; fix Heimdall's dependency kind and recipe/binary drift
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>
2026-09-23 15:59:09 +02:00
Meta Vens
06a5edc6b7 Place the acceptance fixture's subjects so the gate can pass
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>
2026-09-23 15:22:25 +02:00
Meta Vens
22ec118d72 Swap the real cognition acceptance test to the local Bonsai transport
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>
2026-09-23 15:13:45 +02:00
Meta Vens
94ab279ac4 Read Heimdall's documents as its consumer, not its schema (PA.f197)
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>
2026-09-23 14:39:29 +02:00
Meta Vens
0bf36c7a8a Return command results in the shape the contract admits (PA.f196)
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>
2026-09-23 14:22:01 +02:00
Meta Vens
07aae19dae Cut 17: the fourth exit decodes before retiring, the card stops going blank mid-round, and a dead accessor is gone (PA.f194-A/D/E)
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>
2026-09-23 07:11:24 +02:00
Meta Vens
abb84b49f2 Cut 17: the checkout-detecting test no longer asserts against Idunn's own gate (PA.f194-C)
`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>
2026-09-23 07:10:58 +02:00
Meta Vens
db4f574c57 Cut 16: the retired-row sidecar becomes readable and the retirement signal survives a restart (Soul's owed items 5 and 6, PA.f193-E)
`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>
2026-09-23 06:21:37 +02:00
Meta Vens
d082cd2277 Cut 16: a readable current-schema row was destroyed, and the running hint contradicted admit's own refusal (PA.f193-A/B, PA.f193-D)
`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>
2026-09-23 06:06:21 +02:00
Meta Vens
63964b067d Cut 16: the vendor/eve checkout check certifies a copy as a checkout, and the eve-bridge tests skip silently (PA.f193-C, Soul's owed item 3)
`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>
2026-09-23 06:06:02 +02:00
Meta Vens
1dea8f5d67 Cut 15: the play card names the empty-submit continue while a turn is stuck Running
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>
2026-09-23 05:36:22 +02:00
Meta Vens
d366316a21 Cut 15: readiness reports a retirement, seed refusals stop naming the inference host, and the bridge probe checks git (PA.f186, PA.f187, PA.f190)
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>
2026-09-23 05:32:00 +02:00
Meta Vens
f295a3a4fb Cut 15: retire every unreadable play-turn row, not just legacy ones (PA.f184, PA.f185, PA.f188, PA.f189, PA.f192)
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>
2026-09-23 05:31:39 +02:00
Meta Vens
0808c6a7fd Cut 14: refuse a stale tab's token, a token smuggled via bindings, and a leaked seed config name (PA.f178, PA.f179, PA.f180, seed refusal)
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>
2026-09-23 04:30:43 +02:00
Meta Vens
58324f8233 Cut 14: retire unreadable legacy play rows, stop degrading a missing answer token to empty (PA.f177, PA.f179, PA.f181)
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>
2026-09-23 04:30:06 +02:00
Meta Vens
2f2a8b671f Land Cut 13: bind an answer to its question with an opaque token (PA.f170)
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>
2026-09-23 03:10:58 +02:00
Meta Vens
7052644467 Land Cut 12: gate the answer path, recover pre-Cut-11 play stores (PA.f169, PA.f172, PA.f173, PA.f175)
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>
2026-09-23 02:27:44 +02:00
Meta Vens
4155823250 Land the answer path and the release gate (Cut 11, PA.f161-PA.f168)
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>
2026-09-23 01:40:26 +02:00
Meta Vens
43b6af61a5 Land the client round trip (Cut 10, PA.f148-PA.f160)
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>
2026-09-23 00:36:43 +02:00
Meta Vens
f1732c0ae5 Delete world.speak and the story card (Cut 9, part b)
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>
2026-09-22 23:33:33 +02:00
Meta Vens
322bdf69c6 Add the Eve play card and the play row's own revision (Cut 9, part a)
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>
2026-09-22 23:24:48 +02:00
Meta Vens
22eb918d1b Fix batch 8b-fix: answerable questions, dispatch resume, busy refusal (PA.f134/f136/f137/f139/f142/f143/f144/f140)
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>
2026-09-22 22:58:57 +02:00
Meta Vens
a99fdfe412 Fix batch 8b-runtime: wire the play table into AppState, GHOSTLIGHT_PLAY_MODEL, world.play routing, ghostlight.world_play.v0
- 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>
2026-09-22 22:06:33 +02:00
Meta Vens
1b20a0fc5d Fix batch 8b-play: round_snapshot owner, actor refusal line, concurrent dispatch PA.f13/f68/f105/f112/f124/f127/f133/PA-Q8
- 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>
2026-09-22 21:59:21 +02:00
Meta Vens
43987dcfb0 Fix batch 8a-fix5: dispatch call body durable before its effects PA.f125-PA.f132
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>
2026-09-22 21:31:30 +02:00
Meta Vens
9a2cb2eafe Fix batch 8a-fix4: SourceSpan::locate performance and guard tests PA.f120, PA.f121
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>
2026-09-22 20:42:44 +02:00
Meta Vens
5b9671b267 Fix batch 8a-fix4: dispatch summaries print through id_text PA.f124
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>
2026-09-22 20:39:12 +02:00
Meta Vens
91e80d37c2 Fix batch 8a-fix4: dispatch persists its own recorded Persona turn PA.f123
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>
2026-09-22 20:37:01 +02:00
Meta Vens
a395210036 Fix batch 8a-fix4: retry delay is a real field, not a cfg(test) swap PA.f122
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>
2026-09-22 20:31:44 +02:00
Meta Vens
d042dc7c31 Fix batch 8a-fix4: shared collision check and covered snapshot faults PA.f118, PA.f119
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>
2026-09-22 20:27:06 +02:00
Meta Vens
5af2a180c3 Fix batch 8a-fix4: ledger and cross-persona quote coverage PA.f115-PA.f117
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>
2026-09-22 20:15:23 +02:00
Meta Vens
7bede2f6d1 Fix batch 8a-fix4: dispatch id parsing and refusals PA.f113, PA.f114
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>
2026-09-22 20:08:02 +02:00
Meta Vens
340d5836e5 Fix batch 8a-fix4: library id printer/parser PA.f113
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>
2026-09-22 20:07:48 +02:00
Meta Vens
754c7fe288 Fix batch 8a-fix3: Dungeon play loop (PA.f98-PA.f109, PA.f111)
- 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>
2026-09-22 19:25:58 +02:00
Meta Vens
d28912cf64 Fix batch 8a-fix3: library seams PA.f106, PA.f107, PA.f110
- 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>
2026-09-22 19:25:26 +02:00
Meta Vens
74b628dfd7 Fix batch 8a-fix2: Dungeon play loop (PA.f83-PA.f97)
One Dungeon commit: turn identity, the answer/continue contract, fault
closing, and retry disposition all read and mutate the same PlayTurn/
PlayTurnStore state machine inside run()/execute_round(), so splitting them
across commits would leave intermediate commits that don't build toward a
coherent state (e.g. PA.f84's answers-matching depends on PA.f83's
turn_id/QuestionId identity; PA.f85's close_with_fault helper is what
PA.f94's retry-disposition and PA.f97's collision fault both close through).

PA.f83 (turn identity): turn_id is minted fresh (Uuid::new_v4) when a turn
opens; never taken from a request key. The store's PlayTurnStoreState now
also carries a KeyLedger: a closed turn's own applied_keys are archived into
it (PlayTurnStore::open_new_turn) when the next turn opens, covering the
most recent KEY_LEDGER_WINDOW (64) closed turns plus the still-open one's
own applied_keys (PlayTurnStore::key_known). A key found either way is a
no-op.

PA.f84 (answer identity): PlayTable::run now takes a PlayRequest{text,
answers: Option<QuestionId>} (PlayRequest: From<&str>/From<String> keep
almost every existing call site unchanged via `.into()`). QuestionId is
(turn_id, round, slot), derived by PlayTurn::open_question_id from the
currently open ask_player call. An AwaitingPlayer turn only accepts an
answer whose QuestionId matches exactly (PlayError::StaleAnswer otherwise).
A Running turn accepts empty text as a plain continue and refuses non-empty
text (PlayError::TurnStillRunning). `answers` given with no question open
is PlayError::NoQuestionOpen.

PA.f85/PA.f93 (close-with-fault helper): one close_with_fault(turn, detail)
closes the turn, records the fault, and persists it. Every fault path after
a turn opens now goes through it: the three duplicated inline blocks
(authoring-run Invariant, advance_time Invariant, actor-call Invariant) are
collapsed into it; a request-build failure, a snapshot failure (execute_
round's own, execute_dispatch's per-subject one), and a narrate failure
(close_turn, called from both the round-budget path and end_turn) all route
through it too, rather than leaving the turn Running behind a bare `?`.

PA.f94 (retry disposition): infer_round's retry loop now reads
InferenceFault::disposition() through the new absorb_or_close helper: only
Retryable retries, bounded by ROUND_RETRY_BUDGET with a small capped
exponential backoff (retry_delay, #[cfg(test)] zeroed — the simplest
injection seam). RecoveryRequired and IntegrityViolation close on their
first occurrence. The stale "library gap" doc and its dangling "module docs
at the bottom" reference are deleted.

PA.f86: no production change was needed — rebuild_conversation already used
turn.opening_prompt, not a fresh table_view. Added the missing regression
test and ran the described mutation against it.

PA.f87: deleted the byte-offset pin test. Replaced with two behavioural
ones: a_resumed_authoring_run_resubmits_its_recorded_shared_patch (a 2-call
run's crash-window resume, mirroring the existing single-call precedent) and
commit_authoring_run_records_the_run_even_when_its_submit_is_refused (a
first-attempt run whose kernel submission is genuinely refused still records
its own decoded patch before/regardless of that refusal). Both mutations
named in the spec (delete the record, move it into the success arm) were
run against the second test and fail as required.

PA.f88 (Dungeon side): added play_tools_is_pinned_to_its_exact_contents_
with_no_communicate, pinning PLAY_TOOLS's exact 24 entries.

PA.f90: PlayTurn::dispatched_subjects now requires a persona_turns entry for
the subject, not just a Dispatch call record — a dispatch whose Persona
faulted or found no opportunity no longer counts as dispatched.

PA.f95: the round-budget doc, applied_keys/fault's dead #[serde(default)]
(and the same reasoning applied to the new KeyLedger field), and
close_turn's/begin_turn's unused parameters are all gone.

PA.f96: replaced the old dispatch_command_id_for_handle unit test with
dispatch_order_never_changes_the_request_id_a_subject_gets: two Persona
subjects dispatched under one shared turn_id, once [Mara, Borin] and once
[Borin, Mara], comparing each subject's own inference request_id (which
carries dispatch_command_id's actual output) across both orders via the
production execute_dispatch path itself, not a recomputed hash. The
position-hashing mutation was run against this test and fails.

PA.f97: handle_for is now the subject id's first 8 hex digits. A collision
among the snapshot's own subjects is a turn fault via find_handle_collision,
wired into round_tools (its Result<_, String> already flows through
infer_round into close_with_fault). Two subjects sharing a handle cannot be
forced from real, randomly issued SubjectIds (no public constructor exists
outside ghostlight), so find_handle_collision's own detection algorithm is
tested directly against two real ids under a shared, hand-assigned handle
string; the genuine end-to-end fixture collision is reported as infeasible
in this pass's own report rather than faked.

Test counts: Dungeon bin 78 -> 98, lib 621 -> 622 (unchanged by this
commit, from the prior library batch), persona-projection 26 -> 30
(likewise). Every new/changed rule was run against its own reverting or
loosening mutation and confirmed to fail before being restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:15:07 +02:00
Meta Vens
3d6a58e958 Fix batch 8a-fix2: library seams PA.f89, PA.f92, PA.f88 note
PA.f89: SourceSpan::locate (ghostlight-persona-projection/src/lib.rs) used
str::match_indices, which never reports overlapping matches, so a
boundary-invalid candidate at an earlier byte offset hid a later,
boundary-valid candidate that overlapped it: locate("Juno no no", "no no")
and locate("xab ab ab", "ab ab") both returned None. Fixed by scanning with
find() from an advancing offset and, on a rejected candidate, resuming one
char past that candidate's own start rather than skipping past its end. The
UAX #29 boundary rule itself is unchanged; whitespace-only and
punctuation-only spans still locate.

PA.f92: WorldPatch::sets_persona_of_existing_subject (ghostlight/src/patch.rs)
had only single-op fixtures, so a .take(1) mutation on its operations scan
would have passed every test. Added a multi-op test where a harmless
Relocate sits at index 0 and the persona write to an existing subject arrives
at index 1.

PA.f88 (partial, library side): the PLAY_TOOLS fixture in
ghostlight/src/table.rs is the library's own test copy, not a policy owner;
added a one-line comment saying so and naming Dungeon's PLAY_TOOLS in
ghostlight-dungeon/src/play.rs as the actual owner. The Dungeon-side pin test
lands in the Dungeon commit.

Tests: cargo test -p ghostlight-persona-projection (30 passed, was 26) and
cargo test -p ghostlight --lib (622 passed, was 621). Both new/changed tests
were run against their own reverting mutation (match_indices restored;
.take(1) added) and failed as expected before being restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:33:56 +02:00
Meta Vens
029970a2c5 Fix batch 8a-fix: PA.f54, f61-f66, f69
Repairs the Cut 8a play loop against the cut map's own fix batch:

- PA.f61: `communicate` leaves PLAY_TOOLS (24 tools, matching table.rs's own
  pinned list); `set_persona_material` is refused on an existing subject
  and stands only for a subject this same authoring run declares, using
  the new `WorldPatch::sets_persona_of_existing_subject`.
- Actor prefix: `execute_actor_call` passes the real handle prefix and the
  full, unstripped call name to `decode_actor_call`, so the library's own
  prefix strip is the only one; `round_tools`/`execute_actor_call` now
  share one `actor_prefix` helper so generation and decode can never drift.
- PA.f54: `commit_authoring_run` decodes a run through the library's
  `decode_authoring_calls`, commits the whole run as one shared patch under
  one command id (derived from the run's own anchor slot), and a
  `PatchRejected` refusal is described through the run's own `DecodedBatch`
  site map so it names the exact failing call. `execute_authoring_call` is
  now dead and removed.
- PA.f62/PA.f63: turn identity (`turn_id`) is separated from the request
  key. `PlayTurn.applied_keys` records every key this turn has answered to;
  a replayed key (open or closed) is a no-op making zero inference calls,
  a fresh key on an open turn continues it, and a fresh key with no open
  turn (none stored, or the stored one closed) opens a new turn under it.
- PA.f64: a dispatched Persona's own prose is the dispatch call's own tool
  result (labelled by subject), not "{label} acted" — `rebuild_conversation`
  was already generic over every call's recorded result, so this is the
  whole fix.
- PA.f65: an empty/whitespace answer is refused before anything is
  recorded; an inference exhausted beyond its retry budget closes the turn
  with `PlayTurn.fault` recorded instead of leaving it `Running`.
- PA.f66: (a) the round budget closes the turn and narrates through the
  same door `end_turn` uses; (b) a kernel `Invariant` fault closes the turn
  with the fault recorded, set directly in `execute_round` at each of its
  three poisoning sites; (c) `dispatched_subjects` was already
  record-derived in call order on every path — pinned with a direct test,
  no code change needed; (d) `dispatch_command_id`/`dispatch_command_id_for_handle`
  replace `dispatch_slot`, deriving from the dispatch call's own slot and
  the subject rather than the subject's position in that call's own JSON
  array, so two subjects or two dispatch calls in one round can never
  collide.
- PA.f69: strengthens `an_act_for_an_undispatched_subject_is_refused` (S2)
  and `the_turn_closes_with_the_players_narration` to assert real outcomes;
  adds a Persona-exact-quote-accepted test (S1); pins
  `commit_authoring_run`'s record-before-submit ordering at the source
  level (S4, no runtime seam exists to interrupt one synchronous call);
  and adds a fresh-turn-after-close test proving no transcript crosses
  turns, checked against the actual seen inference request (S7 / invariant
  1). S6 was already covered by the existing restart test.

Dungeon bin: 63 -> 78 tests. Library: 620 -> 621 tests (the new
WorldPatch predicate's own unit test, previous commit). Every mutation in
the cut map's fix list was reverted/loosened against the final code and
confirmed to fail, then restored; see the session report for the full list.

Discrepancy not covered by this batch: `round_tools` did not append
HANDLE_SEPARATOR to the prefix it handed `actor_tools`, which would have
made every real (non-scripted) actor tool name undecodable against the
`<handle>__<kind>` convention this file's own instructions document. Fixed
alongside the actor-prefix cut as a necessary companion, since decode and
generation must share one prefix by construction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:09:06 +02:00
Meta Vens
a5329a9ba3 Add WorldPatch::sets_persona_of_existing_subject (PA.f61 library seam)
Dungeon must refuse a play-agent authoring run that rewrites an existing
subject's persona material, but WorldPatch's declarations/operations fields
are pub(crate), so a consumer cannot inspect the decoded patch by hand. Add
one read-only query the library owns: true when any SetPersonaMaterial
operation targets Ref::Existing rather than a same-patch Ref::Draft handle.
Owner: ghostlight::patch::WorldPatch. Consumer: ghostlight-dungeon's play
agent (commit_authoring_run), landing in the next commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:08:37 +02:00
Meta Vens
3c2bdf3a7c One owner for the retired set and the exercise revision (PA.f82)
The resolver's retired set in patch.rs seeded itself with its own
`matches!(assignment, ControllerAssignment::Retired)`, a second reading of
the same predicate the kernel's `is_retired` already owns. Made
`is_retired` `pub(crate)` and had the resolver call it (over
`controller_assignments.keys()`, then `Key::Existing(scope.subject_id)`)
instead of matching the assignment enum itself, so "is this subject
retired" has exactly one reader — the kernel's own statement of
retirement, the one audience derivation and preconditions already trust.

The exercise revision had two independent computations of the same
`state.revision + 1` arithmetic (action.rs's `exercise`, written out
inline, and the kernel's own `resolution_revision`), and `apply_effect`'s
`DecisionExercised` arm then called `resolution_revision` a second time
per invocation even though `exercise` had already produced the identical
value into `event.revision` moments earlier (and the arm had just proved
`derived == *event`, so `event.revision` is provably that same value).
Collapsed to one arithmetic owner — `exercise` now calls
`super::resolution_revision(state)` instead of duplicating the `+1`
inline — and cut the redundant call site: `DecisionExercised` now reads
`event.revision` for `apply_operations`'s `resolves_to` rather than
recomputing it.

Mutation: `is_retired` forced to always return `false`. Both the kernel's
own retirement tests (`tests::a_retired_institution_lends_no_authority_to_
its_office_holder`, 12 of the `clock_tests::a_retired_*`/`retiring_*`
cases) and the resolver's own test
(`patch::tests::a_grant_after_retiring_the_lending_institution_is_admitted`)
fail together — 14 failures total — which is the shared-ownership proof;
restored after confirming.

PA.f48's and PA.f49's existing tests stay green (34 retirement-shaped
tests including both PA.f48 resolver tests; the two PA.f49
`acquired_at`-revision tests). Full lib suite: 620 passed, 0 failed, 1
ignored. Dungeon bin suite: 63 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:36:12 +02:00
Meta Vens
9b91940d9a Stop the play table's own tests from fooling themselves (PA.f77, PA.f79, PA.f81)
PA.f77: table_view_prints_only_a_subjects_own_granted_affordances found the
holder's row with an unscoped view.find("{label} [{id}]"), which can match
the same marker in the Places occupant list first and then read whichever
subject row sorts first out of that mismatch — flaky about 1 run in 4.
Route it through the existing subject_row helper, which searches only from
the Subjects section onward. Swept the rest of the test module for other
unscoped find/contains that could cross into the Places occupant list or a
fact's known-by list; every other one was already either an intentional
"appears anywhere" check or already scoped through subject_row/
view_section. Stability: 8 sequential runs of the table:: suite, 41/41
passing every time.

PA.f79: two of the table's own refusal tests could pass under a mutated
implementation.
describe_refusal_names_unresolved_drafts_handle_and_expected_kind used a
handle literally named "missing_place", so a mutation that replaced the
rendered expected-kind with a fixed "thing" still passed the "place"
containment check via the handle text alone. Renamed the handle to
"unresolved_target" and pinned the exact rendered sentence.
describe_refusal_names_both_calls_for_a_duplicate_handle only ever gave
describe_duplicate_handle_sites two declarations to filter, both
colliding, so a filter that matched every declaration in the batch
regardless of handle produced the same "call #0 and call #1" text. Added a
third, unrelated declaration and asserted it is not named.

PA.f81: nothing pinned describe_refusal_to_actor's exact wording — every
existing test used `contains`, so appending a `{:?}` dump of the failed
precondition (leaking internal Mismatch shape the actor's own call never
handed back) would have survived all of them. Added assert_eq! tests for
one role-bearing precondition failure (CanBroadcast/NoAudience, a `horn`
channel role) and one roleless one (HasStanding/NoStanding), pinning the
full string each renders.

Every fix above was run against its own revert/loosening mutation and
confirmed to fail before being restored: the raw is_alphanumeric-neighbour
scoping wasn't touched here (that's PA.f78, committed separately), the
fixed-"thing" mutation, the match-every-declaration filter, and the
{:?}-append mutation on both PA.f81 branches all failed as required.

Lib suite: 620 passed, 0 failed, 1 ignored (618 baseline + 2 new PA.f81
tests; the PA.f77 flake no longer reproduces).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:30:58 +02:00
Meta Vens
4fea8fdd36 Use UAX #29 word boundaries in SourceSpan::locate (PA.f78)
The hand-written is_alphanumeric neighbour rule in SourceSpan::locate got
several boundary cases wrong: it accepted "I can" inside "I can't go" (an
apostrophe is not alphanumeric, so it read as a boundary even though it
binds the contraction into one word), accepted "the cafe" ending right
before a combining acute mark, accepted "run" inside "run_fast", and
falsely refused every CJK quote because every ideograph counts as
alphanumeric and CJK prose carries no spaces.

Replace the rule with real UAX #29 word segmentation from the
unicode-segmentation crate: collect the boundary byte offsets
split_word_bound_indices() reports for the source, plus source.len(), and
accept an occurrence only when both its start and end land in that set.
Delete starts_on_word_boundary/ends_on_word_boundary; the crate is the
authority now, not a hand rule.

unicode-segmentation is pinned to 1.x in the workspace
[workspace.dependencies] table (Cargo.lock gains only that one package)
and added to ghostlight-persona-projection's own Cargo.toml.

Verified against UAX #29 with new tests: contractions (straight and curly
apostrophe) are refused, a quote ending before a combining mark is
refused, an underscore-joined word is refused, a leading-CJK two-character
quote is accepted, a quote with its own leading space is accepted, a
hyphen-bounded quote is accepted, and "Stop." in "Stop.Now" is refused
(the crate glues a MidNumLet full stop between two letters into one word,
so this is the crate's called shot, not a special case carved into the
rule).

Callers (Interpreter capture, Dungeon's span_is_exact) are unchanged; the
lib and Dungeon bin suites both still pass in full (618 lib tests with the
one known PA.f77 flake, 63 Dungeon bin tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:25:32 +02:00
Meta Vens
e383cdec7d Sharpen the play table's own test suite (PA.f75)
table_view_prints_every_id_the_tools_take's pressure and dependency checks
passed trivially: the counterparty id is also on the Subjects line, and a
commitment index of 0 matched almost any id's own "0". Both now check the
Pressures section and the holder's own "depends on" field directly, through
new view_section/subject_row helpers, and the commitment-key check matches
the exact "command/index" pairing instead of the index digit alone.
subject_row scopes its search to the Subjects section, since PA.f74 put an
id beside every occupant and knower label too and an unscoped search could
return a slice spanning unrelated sections. The tool list drops communicate
(removed from PLAY_TOOLS; Dungeon drops it too) and channel printing gets
its own direct assertion against the Channels section, since communicate
was the loop's only source of channel-id coverage.

Adds PA.f57's missing case: a failed precondition over a fact the actor
does not hold must reveal neither the fact's statement, its id, nor its
label, proven against a fixture where a real fact with all three exists.
Widens the actor-facing "every other error" coverage to all 31 KernelError
variants (all constructible), including PatchRejected, which is the one a
regression toward Display formatting would most plausibly hit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:54:20 +02:00