Commit graph

1,249 commits

Author SHA1 Message Date
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
5b1f55c30c Rule PA.f170: an opaque per-question token, and delete the version comparison
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:07:51 +02:00
Meta Vens
54ed2b3df0 Record Soul on Cut 11 (PA.f169-PA.f175)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:02:44 +02:00
Meta Vens
d8b8e86100 Record Cut 11 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:41:24 +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
c3da080f85 Record Soul on Cut 10 (PA.f161-PA.f168)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:00:50 +02:00
Meta Vens
0c0932427a Record Cut 10 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:38:21 +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
d9f01595a0 Add the Eve client bridge Cut 10 verification runs on
Drives the real vendored eve-browser-lowering (submodule pin unmoved)
over a jsdom-rendered surface document: types into controls, clicks
buttons, and prints the resulting gamecult.eve.command_invocation.v1
intents. tools/eve_client_bridge.mjs is the harness the runtime.rs
round-trip test (next commit) uses to prove the client round trip
against Dungeon's own served surfaces, not a hand-built payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 00:36:11 +02:00
Meta Vens
aa8cb86ddb Record Soul on 8b-fix and Cut 9 (PA.f148-PA.f160)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:55:43 +02:00
Meta Vens
50cde3fd47 Record Cut 9 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:35:00 +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
4a1a700005 Rule the play row's own revision counter for Cut 9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:07:04 +02:00
Meta Vens
46c09f1d48 Record fix batch 8b-fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:59:50 +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
4e8f36ca80 Amend Cut 9 for the question binding and the deletion's sequencing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:27:04 +02:00
Meta Vens
064c031c62 Record Soul on 8a-fix5 and Cut 8b (PA.f134-PA.f147)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:25:57 +02:00
Meta Vens
e35454c3d6 Record Cut 8b landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:08:27 +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
032cb3e738 Record play loop fix 8a-fix5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:32:46 +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
75a154a1a9 Record Soul on 8a-fix4 (PA.f125-PA.f133)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:11:13 +02:00
Meta Vens
647917d53e Record play loop fix 8a-fix4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:46:05 +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
2305ae7651 Record Soul on 8a-fix3 (PA.f113-PA.f124)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:55:59 +02:00
Meta Vens
3b156c0a28 Record play loop fix 8a-fix3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:26:43 +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
cd7bcaa523 Record Soul on 8a-fix2 (PA.f98-PA.f112)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:31:55 +02:00
Meta Vens
935c9d061e Record play loop fix 8a-fix2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:16:11 +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
cdf48f4a80 Record Soul on 7d and 8a-fix (PA.f83-PA.f97)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:28:19 +02:00
Meta Vens
80e66496cc Record play loop fix 8a-fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:10:17 +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
5e0ae55050 Record play agent library fix batch 7d
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:37:08 +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
149178eb64 Record Soul on play agent fix batches 7b and 7c (PA.f77-PA.f82)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:18:41 +02:00