PA.f113: Dungeon's `full_id_text` and the fourth hand-copy of it in the test
module (`subject_id_text`) are deleted. `annotate_actor_tools` and
`parse_dispatch` now read/match ids through the library's own `id_text` and
`id_text_matches`, so a change to what `table_view` actually prints cannot
pass silently again. The test helper `subject_id_text` now reads a subject's
id out of a real `table_view` render's own `Subjects:` section (not the
`Places:` occupant listing, which prints the same "label [id]" shape earlier
in the string) instead of re-deriving it, and every call site was updated to
pass the snapshot. Verified live: mutating table_view's Subjects-section id
print to append a suffix made `dispatch_by_the_printed_full_id_runs_the_persona`
fail with a refusal quoting the mutated text; reverted before commit.
PA.f114: `parse_dispatch` used to silently drop a non-string subjects entry,
a malformed (non-object or non-JSON) body, and a missing `subjects` key,
contradicting PLAY_INSTRUCTIONS' promise that a bad id is refused rather than
dropped. Each shape is now refused in the tool result, quoting what was
given: invalid JSON, a non-object body, a missing `subjects` key, a
non-array `subjects`, and a non-string entry. Three new tests exercise
`parse_dispatch` directly, the exact function the dispatch tool call handler
runs arguments through.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
table.rs owns one id printer (id_text, now pub) and its matching parser
(id_text_matches), both against the same debug-paren strip. table_view
prints through id_text; id_text_matches is exact equality against it, so a
consumer reading an id an agent typed cannot drift from what the printer
prints without a shared edit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PA.f98 (high): dispatch takes each subject by its full id, exactly as
table_view prints it in brackets, never the short <handle>__ actor-tool
prefix. parse_dispatch now matches on a new full_id_text (mirrors the
library's own id_text, undocumented-but-identical, without handle_for's
8-hex truncation) and returns unmatched ids separately; the dispatch call
site folds them into the result as a named refusal ("`<text>` names no
dispatchable subject") instead of dropping them. Actor tool descriptions
now carry their own subject's label and full id (round_tools annotates the
library's own actor_tools output in place, so table.rs stays untouched).
dispatch's own tool description and PLAY_INSTRUCTIONS restated to match.
- PA.f104 (medium): dispatched is no longer snapshotted once before a
round's own calls execute; an actor call now derives it fresh from the
turn's own record at the point of resolution, so a same-round
[dispatch(Mara), <mara>__speak, end_turn] resolves speak identically on a
first pass and on resume.
- PA.f99 (medium, test gap): added a before-submit test hook
(#[cfg(test)] only) fired right before each of the three submit doors
(commit_authoring_run, execute_advance_time, execute_actor_call). It hands
the hook a fresh PlayTurnStore::pull_current_from_disk_for_test() read —
a fresh `pull_all` against the store's own already-owned redb Database,
never the cached self.state and never a second store (CultCache's
single-owner lock refuses a second OwnedRedbMessagePackBackingStore::new
on the same path outright; discovered building this seam). Three new
tests observe the durable body at the moment of submission.
- PA.f100 (medium, test gap): the same request key, reused only after
eviction from the KeyLedger window, must mint two distinct fresh turn_ids.
- PA.f101 (medium, test gap): answers: None while a question is open is
refused (StaleAnswer) and records nothing.
- PA.f102 (medium, test gap): split the old "exhausted beyond retries" test,
which actually only ever drove RecoveryRequired, into an honestly-named
RecoveryRequired test (asserts exactly one call) plus a new Retryable
budget-exhaustion test (asserts exactly ROUND_BUDGET + 1 calls, pinned to
a literal, not to the ROUND_RETRY_BUDGET symbol, so a budget mutation
can't drag the expectation along with it). retry_delay's zero-under-test
behavior is now its own small #[cfg(test)] bool (skip_retry_delay_in_test)
rather than a #[cfg(test)] swap over the whole formula; the formula itself
(backoff_delay) is a free function, always compiled, with its own unit
test.
- PA.f103 (medium, test gap): added tests for a narrate failure right after
end_turn and at the round budget, both closing with the fault recorded.
The snapshot-failure site in execute_round is reported, not covered: no
deterministic way to fail WorldMailbox::snapshot exists in this crate's
test harness without tearing down the mailbox's own background actor
independently of the fixture; documented in place.
- PA.f105 (low-medium): the resume path through execute_round now runs the
same handle-collision check round_tools runs (it never did). Added a real
test forcing a genuine collision between two real SubjectIds (spliced via
serde_json, since SubjectId is #[serde(transparent)]) and calling
round_tools directly — this was wrongly reported infeasible in the prior
pass. The resume-path (execute_round) wiring itself has no live-collision
test: forcing one needs the kernel's own snapshot to already carry one,
and the kernel mints every SubjectId internally; documented alongside the
precedent this codebase already uses for the same shape of gap
(a_refused_player_act_sets_the_refusal_line).
- PA.f108 (low): empty or whitespace-only text with no turn open to
continue (none at all, or Closed) is refused (PlayError::EmptyOpening)
before a fresh turn opens, making zero inference calls.
- PA.f109 (low): run's outcome is now RunOutcome::Ran or
Replayed { turn_id }; a replay reads ClosedTurnKeys::turn_id for the
first time anywhere in production (via a new turn_id_for_key on the
store and turn_id_for on the ledger).
- PA.f111: PLAY_INSTRUCTIONS' own doc comment rewritten off the "fresh
table_view per round" framing into the recorded-opening rule (S6).
Every mutation named in the task spec (delete/move each of the three
PA.f99 persists, turn_id=hash(key), delete the round_tools collision call,
accept a None answer, accept an empty opening, make RecoveryRequired retry,
raise the retry budget, remove the backoff cap, turn each of the two
testable close-with-fault sites back into a bare `?`) was applied, run
against its matching test, confirmed to fail, and reverted.
Test counts: Dungeon bin 98 -> 113, lib 622 -> 622 (one test renamed and
reshaped, no net add), persona-projection 30 -> 32.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- PA.f106: SourceSpan::locate no longer repeats a `find` over a shrinking
haystack on each rejected overlap candidate. It now tests `quote` at each
UAX #29 word boundary directly, dropping the repeated-find shape that made
a pathological input (a long single-word run) quadratic. Doc comment
rewritten off "used to return None" history into the current rule
(PA.f111). Added a 64k-char regression guard.
- PA.f107: added Soul's multibyte overlap case
("xéé éé éé", "éé éé"), which a byte-stepping mutation (iterating raw byte
offsets instead of the boundary list) fails against directly (verified,
reverted).
- PA.f110: sets_persona_of_existing_subject_scans_every_operation_not_just_the_first
put the persona write at the end of a 2-op patch, so a `.rev().take(1)`
mutation on the production scan would still pass. Renamed and rebuilt with
the persona write strictly in the middle of a 3-op patch (harmless
Relocate before and after), so both `.take(1)` and `.rev().take(1)` fail
it (verified both, reverted).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
describe_refusal's generic patch-mismatch path dropped detail three ways:
UnresolvedDraft lost the handle that never resolved and the kind it was
expected to resolve to; a handle declared twice across two calls could only
ever be blamed on one of them, through the same "the call that declared
`handle`" wording a single declaration gets; and EmptyEvidence named no
call at all, because DecodedBatch never recorded which call contributed
which evidence entry. UnresolvedDraft and DuplicateHandle now get their own
rendering, and DecodedBatch carries evidence_calls alongside site_calls so
EmptyEvidence can name its call the same way a declaration or operation
site does.
table_view printed nothing about a subject's own authority grants, the
offices it holds or constitutes, or the forums whose standing covers it,
even though create_commitment's own checks and a restricted route's
requires all resolve through exactly that structure. It also printed a
commitment's kind as Rust Debug, occupant and known-by lists as labels with
no id to disambiguate a duplicate label, and re-sorted a subject's persona
values and memories out of their authored order. Each subject row now
prints its authority grants, offices held/granted (with incumbent), and
forums (with grievance kind); commitments print their kind in words;
occupant and known-by entries carry an id beside the label; and persona
values/memories keep their stored order.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
decode_actor_call fell back to the bare tool name whenever strip_prefix
failed, so a name actor_tools never emits under a given prefix (the bare
granted-entry name, unprefixed) still decoded. A non-empty prefix must now
be carried, or the call is refused.
precondition_roles dropped CanBroadcast's and CanReach's channel role
entirely, and describe_refusal_to_actor's roleless wording spliced "a
precondition with no role" into the "a precondition over role ..." template,
producing a garbled sentence for NoStanding, NoAudience, and an
out-of-range precondition index. precondition_roles now reads the channel
role through a shared audience_role helper, and the actor-facing roleless
case gets its own plain sentence instead of being spliced into the
role-bearing template.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`SourceSpan::locate` in ghostlight-persona-projection was a raw substring
`find`: "I can" byte-exact matched inside "I cannot go" and was accepted as
the quoted span, even though it is not the word the model claims to be
quoting. "Speech is a verbatim span" only holds if the span's edges are word
edges.
`locate` now walks every occurrence of the quote (`match_indices`) and takes
the first one whose start and end each land on a word boundary: the edge of
the source, or a character that is not Unicode-alphanumeric
(`starts_on_word_boundary` / `ends_on_word_boundary`). A first occurrence
that fails either edge is skipped in favor of a later one.
The Interpreter (crates/ghostlight/src/controllers.rs) calls the same
function through `SourceRange`; `cargo test -p ghostlight --lib` still passes
at 603, so no existing test relied on a mid-word span.
Added five tests: a mid-word quote is refused, a whole-word quote is
accepted, a span equal to the whole source is accepted, a later
boundary-valid occurrence is found after an invalid first one (the first
occurrence fails only the start edge), and punctuation at a span's edges is
handled.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s integrity check —
replacing its `prepared_matches_request` call with `true` failed no test.
Added a synchronous `persona_checkpoint` fixture plus a test that persists a
Persona checkpoint, tampers its `guidance` after `invocation` was already
prepared from the honest one, and checks both `integrity_is_valid` and the
real store's own resume gate (`CultCacheControllerWorkStore::open`, which
decodes and re-checks every row's integrity) refuse it.
PA.f51: `a_forged_mirror_acted_event_is_refused_by_verify_history` in
consumer.rs recomputed the forged commit's own digest but left the head
snapshot's `last_commit_digest` pointing at the original, honest digest. That
mismatch alone made `verify_history` refuse the forgery earlier, as "head
state does not equal replayed history", so the test passed even with the
effect-equality check in `apply_committed_command` (journal.rs) disabled.
Pointing `head.last_commit_digest` at the forged digest closes that
unrelated gap, leaving the effect-equality check as the only clause that can
still refuse it.
Doc fix: the comment on `SubjectRow::last_acted_at` (lib.rs) still described
the recency marker `projector_knowledge` stamps as `minted_at > last_acted_at`;
the code (controllers.rs) has read `entry.acquired_at > acted_at` since
PA.f44. Corrected to match.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
P7.1 is falsified in a small way: a bare name still decodes (PA.f71).
Refusal wording, the table's missing authority, and test gaps are
PA.f72 to PA.f75, to fix in batch 7c. PA.f76 is recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every `Knowledge.acquired_at` write computed `state.revision + 1` inline, which
is right for every commit except genesis: genesis applies at revision 0 and
resolves to revision 0 (`resulting_revision: 0`, `state.revision` never
advances past 0 for the creating commit), so a fact a genesis patch both
declared and granted knowledge of landed at `acquired_at: 1` — one revision
ahead of the state it was written into.
`apply_operation` and `apply_operations` now take the resolution revision as
an explicit `resolves_to` parameter instead of deriving it locally. Genesis
passes `state.revision` (0); every other caller passes
`state.revision + 1` through a small `resolution_revision` helper, computed
once per `apply_effect` branch instead of four times inline. Callers that
invoke `apply_operation`/`apply_operations` directly for a forged effect
(tests, and `action.rs`'s component-writer probe) now supply the same value
explicitly.
`verify_state_shape` in journal.rs gained a companion check: a knowledge
row's `acquired_at` must not exceed the world's own revision, so a forged or
corrupt row of this shape cannot pass replay silently.
Added three tests: a genesis-seeded knowledge row is acquired at revision 0,
a later commit's row is acquired at that commit's resolved revision, and the
shape check refuses a row forged ahead of the world's revision.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`candidate_effective_authority` in patch.rs, the resolver's twin of
`delegated_authority`, still counted a retired institution's delegated
authority after fix batch 5 taught the canonical reducer to skip it. The
resolver and the kernel held two different opinions about whether a dead
institution's office still lent authority: a grant directly to the reeve
after retiring the treasury was refused as OverlappingJurisdiction against a
lend that no longer existed, and a Relocate admitted by the resolver on the
strength of that same dead lend blew up at apply time as a
KernelError::Invariant instead of a normal resolver refusal.
`candidate_effective_authority` now takes the same `retired` set the
resolver already threads through patch resolution and skips a retired
institution's office exactly as `delegated_authority` does. Both callers
(`graph_overlaps` and the Relocate restricted-route check) pass it through.
Added two tests: a grant to the reeve after retiring the treasury is
admitted, and a Relocate through a restricted route that relies only on a
retired institution's delegated authority is refused by the resolver as an
ordinary RouteAccessRestricted mismatch, not a later Invariant.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`the_elaborator_and_the_table_share_one_decoder` proved agreement between
`apply_tool_call` and `decode_authoring_call` for exactly one tool,
`open_route`. Renamed to `the_elaborator_and_the_table_agree_over_every_tool`
and widened to every non-session `PATCH_TOOLS` entry's own generated
example, asserting the whole `WorldPatch` — declarations, operations, and
evidence — agrees, not only operations.
patch.rs's `decodes` carried its own copy of the tag-injection-then-
deserialize logic `decode_authoring_call` already owns; it now builds the
same arguments string and calls `decode_authoring_call` directly (`Session`
still short-circuits to `Ok(())`, matching `decode_authoring_call`'s own
refusal of that shape). `every_declaration` and `every_component_op`
similarly deserialized `arguments_for`'s output straight into `Declaration`/
`ComponentOp`, bypassing the table's decoder entirely; both now decode
through `decode_authoring_call` and pull the single declaration or operation
out of its `WorldPatch`.
Corrected `authoring_tools_admits_every_known_name`'s doc comment, which
described the unknown-name-refusal test above it rather than its own
assertion.
Added `describe_refusal_never_swaps_declaration_and_operation_site_wording`
(mutation X6): a declaration site (`EmptyHandle`'s `position`) and an
operation site (`SubjectNotAtOrigin`'s `operation`) render "declaration tool
call #N" and "operation tool call #N" respectively, and must never trade
words.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f56: `describe_refusal` had six separate correctness bugs. `EmptyEvidence`'s
`position` is an index into the patch's evidence list, but the generic site
reader printed it as "declaration tool call #N" — the same text `EmptyHandle`'s
own, differently-meaning `position` produces; the two are now told apart before
the generic reader runs. `WrongKind` dropped the expected and actual kinds
entirely; it now renders both, in plain words. `CustodyNotConserved` printed no
site at all; it now names the resource whose ledger failed to balance.
`TargetKindMismatch` printed Rust `Debug` for its `RefKind`s; both `Mismatch`
and `ActionMismatch` render kinds in plain words now. The dead `RefName`
branch inside the old `describe_referent` — unreachable because `WrongKind`,
the only variant that carries a `RefName` referent, also carries a `site`
that the site-detection chain always matched first — is deleted; `WrongKind`'s
own arm resolves its `RefName` referent directly, through a new
`describe_ref_name`. `ActionRejected` read no precondition detail at all;
`describe_action_mismatch` now takes the failed entry and the actor's own
invocation and, when both are given, appends the label of what the actor
actually bound to the failed precondition's role. Declaration and operation
site indexes were raw indexes into the patch, not call indexes; when the
caller holds the `DecodedBatch` that produced the rejected patch,
`describe_refusal` now resolves them through its site map.
PA.f57, ruling: two functions. `describe_refusal` is for the agent, with full
detail (it gained `entry`, `invocation`, and `batch` parameters for the
PA.f56 fixes above). `describe_refusal_to_actor` returns only what the
actor's own attempt reveals: for `ActionRejected`, the failed precondition's
role names — bare, from the entry's own precondition list, never resolved to
a label, an id, or a fact's statement, because the actor's own call already
supplied whatever it bound there and nothing else is handed back. Every
other `KernelError` — `MissingApprovals`, a store or journal error, anything
else — returns one fixed "refused" line that names nothing.
PA.f60: Dungeon had no way to read an `InferenceFault`'s disposition —
`requires_recovery` and `integrity_was_violated` are both `pub(super)`.
`InferenceFaultDisposition` is now `pub`, and `InferenceFault::disposition()`
is a public, read-only accessor of the existing three-way value; no
constructor changed and no new one was added.
Dungeon's `play.rs` has one call site of `describe_refusal`, for an actor
call's own refusal; per the rule not to redesign Dungeon's dispatch in this
cut, it now passes `None` for `entry`, `invocation`, and `batch`, keeping
its current behaviour exactly (full detail, not yet the actor-narrow
function) until Cut 8b wires the new parameters and switches this call to
`describe_refusal_to_actor` properly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f54, ruling: in a round, each maximal run of consecutive authoring
calls is one patch, committed atomically under one command id, with
draft handles shared inside the run. Adds `decode_authoring_calls`,
which decodes a whole run into one shared `WorldPatch` plus a
`site_calls` map from each declaration/operation site back to the call
index that produced it, so a later call's reference to an earlier
call's draft handle resolves within the run and a decode error names
its own call index. `decode_authoring_call` stays as the one-call entry
point with its own unprefixed error text; both now read the same
`decode_one_authoring_call`, so there is one decoder rather than two
copies.
PA.f55: `actor_tools` offered `record_need` and `finish_without_proposal`
through `catalog_tools`, but those end a turn — the controller lane's own
vocabulary — and `decode_actor_call` has no arm for either, so the play
agent could be handed a tool call it would always refuse. Splits
`catalog_tools` into `affordance_tools` (one tool per granted entry, no
turn-enders) plus the two pushes `catalog_tools` still makes for its own
callers; `actor_tools` now reads `affordance_tools`. `decode_actor_call`
also gains a `prefix` parameter and strips it from the incoming name
before matching, so a name `actor_tools` emits (`{prefix}{kind}`) decodes
as emitted through the one decoder rather than a caller stripping the
prefix itself first.
PA.f58: `decode_actor_call` silently dropped a non-string `display`
(`and_then(Value::as_str)` swallows the wrong type) and the shared
`decode_catalog_call` silently dropped a `text` field on an entry that
carries no speech. Both are now refused. Also widens the
`table_view_never_reaches_persona_or_projector_code` pin from grepping
only `controllers.rs` to scanning every library source file except
`table.rs` for a `table_view(` call.
Dungeon's `play.rs` is adjusted only for the new `decode_actor_call`
signature: its one dispatch call site and one test call site pass `""`
for the new `prefix` parameter, since Dungeon already strips the
`<handle>__` prefix itself before calling. Behaviour is unchanged; Cut 8b
is the one that wires Dungeon's own dispatch through the table's prefix
handling properly.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
table_view omitted every id `PLAY_TOOLS` takes outside the plain
subject/place/route/resource/affordance rows: no commitment keys
(discharge_commitment), no pressure rows (advance_pressure,
reduce_pressure), no channels (communicate's audience), no persona
material (set_persona_material), and no subject dependencies (bind,
release). Facts were gathered only from subjects' own knowledge rows, so
a fact nobody held was invisible to the one reader that is supposed to
see everything.
WorldSnapshot gains two omniscient, crate-private fields built straight
from world state in `snapshot()`: `channels` (every declared channel,
independent of who controls it) and `facts` (every fact the world holds,
independent of who perceives it). SubjectSnapshot gains `dependencies`,
lowered from the private `components.dependencies` the same way
`commitments`, `pressures`, and `material` already are. `table_view` now
prints all of it, plus route access, subject kind, and controller mode in
plain words instead of Rust `Debug`.
`FactStandingView` gains a `Ruled` variant. `snapshot()`'s per-subject
knowledge projection still collapses `Ruled` to `Canonical` (a subject's
own perception cannot tell a ruling from an ordinarily canonical fact),
and `controllers.rs`'s `typed_knowledge` keeps that promise explicit with
its own arm. The new omniscient `WorldSnapshot::facts` does not collapse
it, so the play table's own view prints `ruled` for what it is.
Replaces `table_view_prints_every_id_the_tools_take` with a fixture
holding one of every kind PA.f53 named — a claimed fact, a ruled fact
nobody knows, a channel, a commitment, and a pressure row — and takes a
real example per `PLAY_TOOLS` entry, composite fields included, rather
than the placeholder `field_example` composites the old loop silently
accepted. Adds direct tests for the named mutations: dropping the
granted-affordance filter (X1), printing a claimed fact as canonical
(X3), dropping the known-by list (X4), and printing `Ruled` as canonical
in the table.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Answers and continues arrive with new request keys and discard the open
turn (PA.f62). Persona prose never reaches the agent (PA.f64). An empty
answer wedges the turn (PA.f65). communicate and set_persona_material
break invariants 3, 4 and 2 in substance (PA.f61): communicate leaves
PLAY_TOOLS, and persona material is limited to new subjects. Self's
rulings under the standing go are recorded in each finding.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlayTurnStore (one row, over service/play-turn-v1.cc) and PlayTable run
a role-playing turn over Dungeon's PLAY_TOOLS subset of the library's
authoring catalog plus dispatch/advance_time/ask_player/end_turn. The
agent's conversation re-derives each round from opening_prompt, the
recorded InferenceOutputs, and each call's persisted body/result, so
nothing but world state crosses a turn boundary. Authoring calls commit
in maximal per-round runs through one commit_authoring_run seam (fix
batch 7a's batching rule); each call still submits as its own one-item
patch until the library ships a batch decoder. Actor calls quote-check
against the player's own texts or a dispatched Persona's recorded turn
before submitting through submit_principal or ControllerPort, and a
kernel Invariant from any commit poisons the table until restart.
Wiring into AppState, GHOSTLIGHT_PLAY_MODEL, world.play routing, and
the Eve schema is Cut 8b.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The table view omits ids the play tools need (PA.f53), and decoded
calls cannot join one patch (PA.f54). Actor tools emit tools the decoder
refuses (PA.f55). Refusal text is wrong and serves both the agent and the
player (PA.f56, PA.f57). Self's rulings: one patch per run of authoring
calls, and an actor-facing refusal form.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
P4.1 is falsified: the resolver still counts a retired institution's
authority, and the kernel does not (PA.f48). The genesis acquired_at stamp
is off by one (PA.f49). The checkpoint check and the PA.f40 test are not
properly pinned (PA.f50, PA.f51). These are to fix; PA.f52 is recorded.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PA.f47: table_view cannot print ruled, because the snapshot collapses
Ruled to Canonical for every reader. The omniscient view gains Ruled;
subject-facing renderings keep it canonical.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Dungeon's play agent needs to build tools, decode calls, and read inference
output without ever touching a pub(crate) kernel type. crates/ghostlight/src/table.rs
is now the library's one owner of that vocabulary:
- authoring_tools/decode_authoring_call wrap PATCH_TOOLS. apply_tool_call in
elaboration.rs now calls decode_authoring_call for its Declare/Operate arms
instead of carrying a second copy of the same decode.
- actor_tools/decode_actor_call wrap catalog_tools/decode_catalog_call
(decode_catalog_call widened controllers.rs-crate-private to pub(super) so
the table module can reuse it). decode_actor_call resolves the subject's own
currently issued opportunity from the snapshot, never a value a caller
supplies (PA.f41's rule, applied here).
- describe_refusal renders ActionRejected by precondition/slot/role and
PatchRejected by the offending tool call's site; every other KernelError
falls back to Display.
- InferenceRequest::play and InferenceOutput::events/receipt_digest are the
two inference seams Dungeon needs to run and read a Play-purpose inference.
- table_view is the play agent's own richer renderer (ids, occupants,
holdings, granted names, retired flag, facts with standing and knowers,
clock, brief) and does not share text with render_world_structure, which
moved here unchanged so the seed prompt's bytes did not move with it.
Known gap: FactStanding::Ruled collapses to FactStandingView::Canonical at
snapshot construction (lib.rs), so table_view cannot yet distinguish a ruled
fact from an ordinarily canonical one. Widening that is a lib.rs change
outside this cut.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There is no world quarantine at the play table. Inference faults become
tool results for the agent, and only a kernel invariant stops the table.
Cut 8 is split into 8a (the loop) and 8b (the wiring).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tests only.
PA.f42: no prior test inspected the Projector request build_projector_invocation
assembles — the existing leak tests cover the Persona request built from its
prose, not the Projector request both turn and narrate build from a
SelectedDecision. the_projector_request_carries_no_structured_state_through_turn_or_narrate
asserts no subject id, controller id, world id, scope digest, state digest or
revision reaches it through either path. Kills the mutation that put the
full typed view into the Projector's guidance instead of the world's brief.
PA.f43, three untested PersonaLane paths, one test each:
- turn_refuses_an_operational_agent pins that turn refuses OperationalAgent
before any inference runs. Kills the mutation that let turn accept it
alongside NarrativePersona.
- turn_refuses_a_stale_scope_digest pins that a scope digest which moved
since the opportunity was issued is refused. Kills the mutation that
swapped PersonaLane::select's digest requirement for select_fresh's
looser scope-only match.
- narrate_succeeds_for_a_human_subject_with_only_its_own_slice pins the
success path for a real Human subject: its Projector request carries its
own heard line and never another subject's knowledge or an absent
subject's label.
mixed_controller_world adds a Human, a NarrativePersona and an
OperationalAgent subject to one fixture — reusing crate::tests's
human-approval draft flow lowered onto WorldMailbox — since no existing
fixture carried all three controller modes PA.f43 needs.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The recency marker was `minted_at > last_acted_at`, and minted_at is set only
by a speech or a display event's own fan-out. A row AcquireKnowledge lands, a
row Witness lands, and a row declaration-time seeding lands all carry no
minted_at, so `new` computed with `>` a `None` was always false for them: a
fact landed by a patch, a seed, or a ruling could never surface to a Persona
as new, however long after the subject's last act it arrived.
Knowledge now carries acquired_at: u64, the revision this write's own commit
resolves to (matching how DecisionEvent.revision and the minted_at scan built
from it are computed: one past the pre-increment state.revision apply runs
against). Every writer of the knowledge map sets it: AcquireKnowledge,
Communicate's fan-out, Display's fan-out, and Witness. The marker becomes
`acquired_at > last_acted_at`, with a subject that never acted (last_acted_at
is None) seeing every row it holds as new outright, matching the ruling
exactly rather than leaning on Option ordering.
minted_at keeps its own meaning — which act minted a spoken or displayed claim
— and stays on KnowledgeSnapshot and on the wire; no schema bump, since no
consumer.v6 store exists yet. AcquireKnowledge's own no-op refusal now
compares confidence and source alone, not the whole row, so a later
acquired_at cannot make an identical re-assertion stop refusing as
"knowledge operation changes nothing".
Tests: a row AcquireKnowledge lands after the subject's last act is new; one
that lands before it is not; the acquisition revision survives a journal
reopen; and the Cut 0 stimulus test's third row — seeded with no minted_at,
previously "new": false — now reads "new": true, because its subject never
acted and PA.f44 says every row such a subject holds is new.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
select_one matches subject, mode, controller, scope and digest, then
substitutes the caller's own copy of the opportunity into its result: correct
for ControllerRunner, which persists and resubmits one run's bound
opportunity, wrong for PersonaLane, which persists nothing. A forged
opportunity (new world_id, revision 999999, emptied affordance_ids) passed
select_scope's scope-and-digest match untouched and landed straight in the
PersonaTurnBinding turn records.
PersonaLane::select now calls select_scope directly, requires the caller's
opportunity to equal the snapshot's own issued copy exactly, and returns that
kernel copy. turn builds its binding from selected.opportunity, never from
the caller's parameter.
Cut the duplication PA.f41 touched alongside it: prepare_inference,
run_inference and snapshot_for_selection are now the one body
ControllerRunner and PersonaLane both call; record_persona_turn is the one
place a PersonaTurnBinding is built from an opportunity and a receipt pair,
used by both run_persona and PersonaLane::turn; and the NarrativeCheckpoint
Persona integrity check now proves its persisted invocation against
persona_inference_request, the same unprepared builder build_persona_invocation
prepares from, instead of rebuilding the prompt a second time.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PA.f39: each surviving mutation gets a test that fails under it.
- M2: `AcquireKnowledge` now has a test with an `Evidenced` source, so a
narrowing of the retired check to `source != Evidenced` is caught.
- M6: a mirror declared into a `Reach::Subjects` channel now has a test
proving it stays in the audience, so `is_retired` matching
`ExternallyControlled` is caught.
- M9: the Draft-only approval shape clause in `verify_state_shape` is now
pinned directly with a forged `draft_approvals` entry outside
`required_approvers`, rather than only exercised indirectly.
- Soul's probe S2: a placeless subject reachable only through a
`Reach::Subjects` channel is now proven reachable while live and refused
once retired, exercising `audience()`'s channel side, which PA.f33's
Colocated-only `CanReach` test never reached.
`WorldJournal` gains a `#[cfg(test)]` `commits_for_test` accessor and
`verify_history` becomes `pub(super)` (matching `verify_state_shape`'s
existing visibility) so a Soul-style forgery test outside `journal.rs` can
route its falsification through the function that actually judges committed
history.
PA.f40: the "mirror acted" test at consumer.rs claimed the shape check
refused it on a controller-id mismatch. That clause was deleted in PA.f31,
and the test passed only by accident through the unrelated speech-claim
clause. Renamed and routed through `verify_history`: it forges the committed
effect's event scope onto the mirror, recomputes the commit digest, and
proves replaying the original command no longer reproduces that effect.
All four PA.f39 mutations verified as killed by direct mutation testing
against the committed code, then restored by reverse edit. Lib census: 549
before batch 5, 555 after bc1852d/33fc9c6 (PA.f36-f38), 559 after this commit
(PA.f39 adds 4; PA.f40 renames one test in place).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>