Commit graph Ghostlight/crates/ghostlight
Author SHA1 Message Date
Meta Vens
c716b57730 Cut CodexConnector out of managed admission; fix Heimdall's dependency kind and recipe/binary drift
F1 (critical, Soul): managed admission still bailed without
GHOSTLIGHT_CONTROLLER_CONNECTOR and required Idunn's Expected to carry a
third, connector, dependency that no longer exists anywhere upstream.
idunn_health.rs now reads no connector env, requires exactly Ghostlight's two
real dependencies (Odin, Heimdall), and RuntimeDependencyBindings/
RuntimePresencePublisher carry no connector field. runtime.rs's
open_controller no longer takes an endpoint parameter for a lane it cannot
offer: Dungeon passes None for ConnectorBinding and says why in a comment.
configured_connector_endpoint is deleted. The library's CodexConnector
transport (crates/ghostlight) is untouched, and play.rs's own use of the
codex_connector crate for transcript types is unrelated and untouched.

F3: Heimdall is not an Idunn-v2-managed incarnation (every v2 deploy has
failed; it runs as legacy heimdall.service). Idunn projects an
[[external_capabilities]]-declared provider as provider_authority =
"external-operator-binding" with no provider_expected_projection_sha256
(Idunn's DependencySelection::expected_projection), never the managed shape a
"required" dependency expects. Recipe's Heimdall dependency kind is now
"external-operator-binding"; idunn_health.rs's required_external_dependency
checks exactly that shape. Odin stays a managed shared-infrastructure
dependency.

F2: the recipe disagreed with the binary on every declared contract, so
Warming would fail even with F1/F3 fixed. schema_generation was "world-v2"
against the daemon's "world-v3"; ghostlight.world-service provided
"foundation-v0" against the binary's actual ghostlight.world_state.consumer.v6
/ consumer-v6 (STATE_SCHEMA); controller-work's slot schema was pinned at
"v3" against the code's "v17". Fixed all three against the source constants,
and recomputed idunn_health.rs's STATE_CONTRACT_SHA256 against the corrected
[state] block using Idunn's own canonical_state_contract_sha256 (msgpack +
sha256 of the recipe's StateDeclaration) run against the finalized recipe —
not guessed. Promoted CONTROLLER_WORK_SCHEMA (ghostlight) and STORE_SCHEMA
(ghostlight-dungeon's app_session) from private to pub/pub(crate) so a test
can check itself against them instead of hand-copying version numbers.

Added one Dungeon test, recipe_matches_the_daemon_it_deploys, pinning recipe
strings against the daemon's own constants: state generation, provided
schema/compatibility, all four slot schemas, the exact dependency count and
kinds, and the absence of the retired CodexConnector dependency. No TOML
parser exists in this workspace's Cargo.lock, so it string-matches the exact
values rather than parsing into a struct — it catches value drift but not
field-placement drift a real parse would also catch (documented in the test's
own comment). Mutation-verified: corrupting schema_generation fails it;
reverted (diff-clean).

Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
185 passed, 0 failed, 4 ignored (was 184; +1 for the new test). cargo test -p
ghostlight --lib controllers:: -> 131 passed, 0 failed, 1 ignored, unchanged.
Negative grep for CONNECTOR in crates/ghostlight-dungeon/src: zero survivors.
sudo -n idunn validate on Yggdrasil against the updated recipe + binding:
both ok.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:59:09 +02:00
Meta Vens
06a5edc6b7 Place the acceptance fixture's subjects so the gate can pass
The NoAudience rejection Soul flagged (fresh-workspace-handoff.md) was a
missing position, not a transport problem: all three subjects in
real_local_model_cognition_modes_commit_speech declared position: None, so
the kernel had no audience for the seed speech. Add a Place ("roll-hall")
and set each subject's position to it, copying the pattern already used by
the passing persona-lane fixture (~line 7550). No other test body or
assertion changes.

Verified against real Bonsai over the Raven tunnel: passes in 284.68s,
persona prose "Mara hears Iris. She says, \"I hear you.\"" Mutation
re-verified with open_inference actually reached this time: pointing
LocalBinding at the closed loopback port 127.0.0.1:9 fails at inference
("NarrativePersona acceptance did not complete"), not at NoAudience;
reverted by reverse edit (diff-clean).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:22:25 +02:00
Meta Vens
22ec118d72 Swap the real cognition acceptance test to the local Bonsai transport
CodexConnector is not a real option for Dungeon inference (operator ruling
2026-09-23): Bonsai 2 reaches Yggdrasil over a restricted reverse SSH tunnel
at loopback 127.0.0.1:18080. Rename
real_codex_connector_cognition_modes_commit_speech to
real_local_model_cognition_modes_commit_speech, open inference with
LocalBinding instead of ConnectorBinding, and read
GHOSTLIGHT_LOCAL_ENDPOINT/GHOSTLIGHT_LOCAL_MODEL_PREFIX in place of the
connector endpoint and credential. Update the Idunn recipe's acceptance step
and service required_environment to match (drop the connector env, add the
elaborator model var the test already required but the recipe omitted), and
drop the gamecult.codex.subscription-inference dependency: Dungeon no longer
depends on CodexConnector for inference. Update the remaining live references
to the old test name in notes/map.

CodexConnector's transport code and the v1 actuator are untouched; both are
separate follow-up cuts.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:54:20 +02:00
Meta Vens
6d6897445e Name a call and a subject's civic standing in the agent-facing refusal and table (PA.f73, PA.f74)
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>
2026-09-22 15:44:45 +02:00
Meta Vens
dc43be5d8a Refuse a mismatched actor prefix and stop garbling roleless preconditions (PA.f71, PA.f72)
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>
2026-09-22 15:30:12 +02:00
Meta Vens
56020d4155 Pin the Persona checkpoint's request check and the forged-effect replay refusal (PA.f50, PA.f51)
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>
2026-09-22 15:07:55 +02:00
Meta Vens
7d59e41869 Stamp Knowledge.acquired_at with the revision a commit actually resolves to (PA.f49)
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>
2026-09-22 13:52:37 +02:00
Meta Vens
1d50509535 Share one predicate for a retired institution between the resolver and the kernel (PA.f48)
`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>
2026-09-22 13:31:20 +02:00
Meta Vens
2035fe8722 Widen the shared-decoder test to every tool and route two more copies through it (PA.f59)
`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>
2026-09-22 13:12:57 +02:00
Meta Vens
1847974b62 Fix refusal text, split its actor-facing twin, and read a fault's disposition (PA.f56, PA.f57, PA.f60)
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>
2026-09-22 13:05:04 +02:00
Meta Vens
71a5c81240 Add the batch decoder and fix the actor vocabulary (PA.f54, PA.f55, PA.f58)
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>
2026-09-22 12:42:43 +02:00
Meta Vens
8de082a36c Widen table_view to the whole PLAY_TOOLS vocabulary (PA.f53, PA.f47)
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>
2026-09-22 12:21:35 +02:00
Meta Vens
7d40393bcd Add the table module: one consumer-neutral vocabulary for the play agent (Cut 7)
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>
2026-09-22 10:42:44 +02:00
Meta Vens
55f8bc679d Cover the assembled Projector request and three untested PersonaLane paths (PA.f42, PA.f43)
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>
2026-09-22 09:47:55 +02:00
Meta Vens
3b1f412b92 Stamp every knowledge row with its own acquisition revision (PA.f44)
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>
2026-09-22 09:33:13 +02:00
Meta Vens
14d85dac59 Refuse a forged opportunity in the Persona lane, and cut its duplication (PA.f41, PA.f46)
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>
2026-09-22 09:18:46 +02:00
Meta Vens
662a51f647 Close four retirement test gaps and route the mirror-forgery test through replay (PA.f39-f40)
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>
2026-09-22 08:34:52 +02:00
Meta Vens
33fc9c671d Cover the dependency loop's retired-subject freeze (PA.f37)
The first PA.f37 freeze test only retires the farmer, who holds a routine and
an obligation but no dependency, so it never exercised `derive_motion`'s
dependency-loop retired check. Mutation testing against commit bc1852d
confirmed the gap: disabling that loop's `is_retired` skip passed the full
suite. This adds the other half — retiring the reeve, who holds the goal and
the Bind dependency, while the farmer stays live — which kills that mutation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 08:05:22 +02:00
Meta Vens
bc1852dfef Refuse a genesis self-retiring human, a retired promisor, and a retired institution's lent authority (PA.f36-f38)
PA.f36: the resolver tracked a freshly declared mirror in `mirrors` but never
tracked a freshly declared human, so a genesis patch that declared and retired
the human in the same patch was admitted; only the apply-time re-check caught
it, after `open_owner` had already committed an empty world.cc. The resolver
now tracks a `humans` candidate set the same way, seeded from the committed
controller assignment and extended for a subject this patch declares as
`NewController::Human`, and refuses its retirement in Draft with
`RetiresAnApprover`.

PA.f37: a retired subject could still act through the clock and through
commitments. `CreateCommitment` now refuses a retired promisor with
`RetiredSubjectActed`, and `derive_motion` now skips a retired subject's
commitments and dependencies entirely, so its routines stop rolling and its
obligations, goals, and dependencies stop pressing. Nothing is discharged or
deleted; a retired subject's commitments stay on record exactly as they were
the moment it retired.

PA.f38: `delegated_authority` read a retired institution's grants, so a live
office-holder kept a dissolved institution's authority. `Retire` does not
vacate offices — the incumbency stays on the ledger — but a retired
institution now lends nothing: `delegated_authority` skips a retired
institution's offices at the one site that turns an office into effective
authority.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:53:53 +02:00
Meta Vens
c4334cce3b Add the Persona lane and the recency marker (Cut 6)
PersonaLane (crates/ghostlight/src/controllers.rs) is the unpersisted
Projector-then-Persona turn PA-Q3 A called for: no work-store row, no
persisted state, a restart just repeats the two inferences. Its `turn`
runs Projector then Persona for a NarrativePersona opportunity and
returns a receipt-bound PersonaTurn; `narrate` runs the Projector alone
over a Human opportunity for the player's own view. Both refuse the
wrong mode before touching the mailbox, and a retired subject is
refused by `select` itself (no live opportunity survives retirement).

The Projector-then-Persona prompt builders in `run_narrative` and
`run_projector` move into two private functions, `build_projector_invocation`
and `build_persona_invocation`, so PersonaLane and the narrative lane
build both prompts through the same one seam: a Persona's input is
Projector prose and nothing else (invariant 2).

PA-Q2's recency marker: `SubjectSnapshot.last_acted_at` is derived in
`snapshot` from one pass over `state.events` (the greatest
DecisionEvent.revision scoped to that subject), and `projector_knowledge`
stamps each row `"new": minted_at > last_acted_at`. A subject that never
acted sees every minted row as new; an unminted background fact (no
revision) is never new, having no arrival time to compare.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:38:06 +02:00
Meta Vens
e18aa03878 Refuse retirement in channel reach, speech, knowledge, and office (PA.f33-f35)
`audience()`'s Reach::Subjects arm returned channel members without
filtering retirement, so a retired subject remained a Communicate audience
and a CanReach target (audience() is the one statement both preconditions
and Communicate read, so one filter, `is_retired`, fixes both). A retired
Communicate speaker was also resolved rather than refused. Audience
derivation stays the one owner of reachability, so a channel may still
declare a retired member; retirement is filtered at that single read site.

AcquireKnowledge, Forget, and InstallIncumbent all accepted a retired
subject as the one learning, forgetting, or taking office. All four ops now
push the new `Mismatch::RetiredSubjectActed`. Transfer is untouched: a
retired holder's holdings remain transferable, so a body can still be
looted.

Adds permanent tests for PA.f33-f35, including named pins for the five
mutations Soul's probes found live: MuA (retire-then-relocate), MuB (an
elaborator revoking outside its jurisdiction), MuC (a mirror flagged
retired), MuD (three revokes in one patch), and MuE (retiring the same
subject twice in one patch).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:53:47 +02:00
Meta Vens
fd64595908 Stop retirement and revocation from corrupting a subject's own history (PA.f31, PA.f32)
`verify_state_shape` judged every past DecisionEvent against the subject's
*current* controller and affordance grants, so retiring a subject that had
acted, or revoking a verb it had used, failed `verify_append` with
CorruptJournal. Authority is judged once, at commit, by `reduce`, and after
that by replay (`verify_history` re-runs `reduce` for every commit) and the
digest chain; the shape check now holds only the time-invariant facts (scope
resolves to a live assignment, affordance is a catalog entry). Every
production caller of `verify_state_shape` (`verify_append`, `verify_history`,
both reached from `recover`/`initialize`) already replays, so no load path
loses coverage.

`required_approvers` reads the live controller assignment, and `Retired`
carries no human principal, so an owner could retire the human's subject in
Draft, approve alone, and activate without the human's approval. `Retire` now
refuses a Human-controlled subject in Draft with a new `RetiresAnApprover`
mismatch, re-decided at apply beside the resolver. In Active the human's
subject may die: the journal's approval-shape check now holds only in Draft,
since Active approvals are frozen history judged once by `ActivateWorld`'s
`reduce` arm and after that by replay.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:49:24 +02:00
Meta Vens
6e7c73bdc4 Give the local port's response id the same null tolerance as tool_calls (PA.f23, PA.f24)
PA.f23: LocalChatResponse.id already tolerated an absent field through
#[serde(default)], but not an explicit "id": null, since #[serde(default)]
only fires when the key is missing. Give it the same null_as_default
treatment tool_calls already has, with a_null_response_id_decodes_as_empty
mirroring the existing tool_calls coverage.

PA.f24: add ipv6_loopback_opens_and_a_non_loopback_ipv6_address_is_refused,
pinning that the loopback check is IP-address loopback and not "any IPv6
address": [::1] opens and a routable IPv6 address such as
[2001:db8::1]:8080 is refused the same way a non-loopback IPv4 address is.
Test-only; the loopback check itself was already correct.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 06:01:07 +02:00
Meta Vens
c97fad2483 Add missing coverage for the play authority's admission gates (PA.f27, PA.f29, PA.f30)
Test-only. No production code changes: nothing here found a real bug.

PA.f27: require_ruler's own coverage gaps.
- apply_effect_re_decides_the_ruler_for_the_owner_too: a forged
  PatchAdmitted under the owner (CallerId::Principal), carrying a Ruled
  fact, is refused at apply exactly as it is for an elaborator. Kills an
  exemption for Principal in apply_effect's re-decision.
- require_ruler_catches_a_mint_past_the_first_operation: a harmless
  Relocate at index 0 followed by a Mint at index 1, from a non-Play
  author, is refused at index 1. Kills a scan narrowed to .take(1).
- require_ruler_reports_every_ruled_fact: two Ruled facts from a non-Play
  author are both reported. Kills a check narrowed to the first fact.

PA.f29: the evidence gate, extended and carried to the document path.
- the_evidence_gate_is_unchanged_for_every_author now also covers the
  owner in Draft (the seed's caller), the owner in Active with a real
  boundary answer, an answered elaborator, and a consumer on its own
  mirror. Each pair proves an uncited Canonical fact or Admit is refused
  (FactWithoutEvidence / AdmitWithoutEvidence) and the same content, cited,
  is admitted. The owner/elaborator "cited" patches also seat a fresh
  occupant at the dead end so the Boundary answer they must supply in
  Active is genuinely cleared, rather than masked by AnswerNotSatisfied.
- a_document_carrying_a_mint_is_refused_through_admit_document (in
  consumer.rs): a consumer document carrying a Mint is refused through the
  real decode path, admit_document, not only the in-process as_consumer
  fixture caller other tests use.

PA.f30: confinement and controller edge cases.
- a_consumer_cannot_retire_grant_or_revoke (consumer.rs): Retire,
  GrantAffordance and RevokeAffordance from a consumer are refused.
  Soul: every subject choice available to a consumer is also caught by an
  upstream structural gate (a mirror can never hold a grant or be retired;
  no non-mirror subject can ever bind to a consumer's own ground), so this
  specific narrowing — confine_to_ground confining only for an elaborator
  jurisdiction, not a consumer — is not independently killable from the
  consumer side. The refusal itself is real and the finding calls for the
  test, so it is kept; the redundancy is recorded in the test's own
  comment rather than dropped silently.
- a_revoke_cannot_leave_a_human_subject_mute: the last-verb refusal for
  RevokeAffordance holds for a Human-controlled subject exactly as for any
  other controller kind.
- retired_expects_no_caller: a direct unit pin that
  ControllerAssignment::Retired.expected_caller() is None.

All three production-touching mutations described by the finding (the two
require_ruler scans, and a hypothetical Human exemption on the last-verb
check) were applied by hand, confirmed to fail the new test, and reverted;
git status is clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:56:20 +02:00
Meta Vens
c8b9aad5f1 Close the genesis lane's require_ruler gap (play agent PA.f26)
prepare_creation and WorldState::genesis both admitted a creation patch
without calling require_ruler, so a genesis patch could carry a Ruled fact
or a Mint outright, and journal replay would happily reproduce it. Call
require_ruler in both places: prepare_creation maps its Vec<Mismatch> to
KernelError::PatchRejected, the same way every other reduce-site refusal
does; WorldState::genesis maps it to KernelError::Invariant, the same way
its own derivation check already does, since genesis has no patch-rejection
channel of its own.

Three new tests. Two exercise the full WorldKernel::create path with a
Ruled-fact and a Mint-carrying genesis patch. A third calls
WorldState::genesis directly with a hand-resolved effect, bypassing
prepare_creation entirely, so it is not satisfied by prepare_creation's own
check alone — this is what actually protects journal replay of a forged or
corrupted genesis commit, not just a freshly admitted one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:18:49 +02:00
Meta Vens
732b1d33ad Add granting and revoking affordances to the world kernel (play agent Cut 5, PA-Q10)
ComponentOp::GrantAffordance{subject, affordance} and RevokeAffordance
resolve beside GrantAuthority/RevokeAuthority: a retired or externally
controlled subject, or an unknown catalog entry, is refused (the new
Mismatch::GrantsOutsideControl); a grant already held or a revoke not held is
NoOperationEffect; a revoke that would leave a controlled subject with no
affordance at all is refused with the new Mismatch::WouldLeaveSubjectMute,
because derive_opportunities requires at least one. Applying either writes
state.affordance_grants directly, re-deciding the same three refusals against
committed state. confine_to_ground now refuses both, unconditionally, beside
Retire: a grant is authority over the catalog, not structure over ground, so
the owner (including the seed lane, the owner in Draft) and the play
authority are the only callers.

PATCH_TOOLS gains grant_affordance and revoke_affordance (Operate, subject
plus an affordance reference); catalog counts move to (7, 34, 43). Same
consumer.v6 / controller_work.v17 as Cuts 3 and 4.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:54:35 +02:00
Meta Vens
3725ba80fb Add subject retirement to the world kernel (play agent Cut 4, PA-Q6)
ControllerAssignment::Retired is a new value of the existing controller
assignment map, not a second partition: id() and mode() return None for it
like a mirror, so derive_opportunities mints nothing and it is never an
audience once its position is gone. expected_caller becomes Option<CallerId>
(None only for Retired; a mirror still returns Some, the one caller that
could ever act for it) and both exact-caller comparison sites move to
Some(&caller).

ComponentOp::Retire{subject} resolves beside Relocate: refuses an already
retired subject with NoOperationEffect and a mirror with the new
Mismatch::RetiresAMirror. Applying it sets the assignment to Retired and
removes the subject's positions and affordance_grants entries, so a witness
at its former place never reaches it while its holdings, commitments, and
knowledge stay untouched. confine_to_ground refuses Retire unconditionally
for every confined author, the same unconditional shape require_ruler already
gave a Ruled fact and a Mint: the owner and the play authority are the two
callers left. SubjectSnapshot gains pub retired: bool, set in snapshot().

PATCH_TOOLS gains retire (Operate, one subject field); catalog counts move to
(7, 32, 41). Schema and controller_work row version are unchanged: this rides
Cut 3's consumer.v6 / controller_work.v17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:46:43 +02:00
Meta Vens
4ac7b828e0 Cut 2 fix batch: decode tolerance, open-time refusals, and dedup (PA.f18-f20, PA.f22)
Soul's Cut 2 verdicts left five findings after PA.f16/f17: two decode gaps,
two missing open-time refusals, one duplication/placement cleanup, and four
surviving mutations with no test naming them. All fix here.

PA.f18: LocalChatResponse.id and LocalChatMessage.tool_calls both integrity-
violated on shapes a nonstandard local server can plausibly send. An id is
used only for receipt bookkeeping, so #[serde(default)] tolerates an absent
one. tool_calls already tolerated an absent field through #[serde(default)]
but not an explicit "tool_calls": null, since default only fires when the
key is missing; deserialize_with = "null_as_default" covers both. Tool-call
ids still integrity-violate on absence, since the tool loop must correlate
them.

PA.f19: open_inference now refuses an empty local or SDK model prefix
(ControllerOpenError::EmptyModelPrefix) instead of letting it match every
model and silently take a transport's lanes. assemble_output now refuses a
reply carrying the same tool-call id twice, as integrity_violation, since the
port owns reply integrity and duplicate ids would otherwise reach the
evaluator unchecked.

PA.f20: call_id_is_valid and tool_name_is_valid are hoisted to one
pub(super) copy in controllers.rs; local_inference.rs and sdk_inference.rs
both import it instead of keeping their own. The loopback check moves out of
open_inference and into LocalInferencePort::new itself, so it holds by
construction for every caller, not only the one open_inference exercises;
open_local_port and open_inference propagate the constructor's Result. A
request offering no tools now omits `tools` and `parallel_tool_calls` from
the lowered body instead of sending an empty array. Test helper
two_tool_call_reply is folded into tool_call_reply, which now takes a slice
of calls.

PA.f22: one test per mutation that survived Soul's Cut 2 pass, each run by
hand against this commit and reverted after killing its test:
offered_tools_are_declared_in_the_request_body (S2, empty tools array),
the_model_prefix_is_stripped_in_the_request_body (S3, unstripped model),
infer_refuses_a_foreign_caller and infer_refuses_an_expired_invocation (S4,
both infer gates), the_receipt_digest_changes_with_the_response_id (S6,
response_id dropped from the receipt).

Local lib census: 9 (after the prior commit) -> 21 in local_inference::;
488 -> 502 in the full `cargo test -p ghostlight --lib` run (501 passed, 1
ignored).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:52:05 +02:00
Meta Vens
a062b56e1f Stop the local inference port leaking to a proxy or a redirect (PA.f16, PA.f17)
Soul falsified P2.2 against 182f803: reqwest::Client::builder() kept the
environment proxy, so an HTTP_PROXY variable sent the whole loopback request
to the proxy and a proxy URL carrying userinfo added a proxy-authorization
header, a credential, from the environment, sent by Ghostlight (PA.f16). A
307 reply was followed and re-POSTed the body to a second port (PA.f17).

Fix both by construction in LocalInferencePort:🆕 .no_proxy() so the
client never resolves a proxy from the environment, and
redirect::Policy::none() so a 3xx reply falls through the existing non-2xx
status handling in send() as a recovery-required fault, the same disposition
every other unsuccessful status outside 429/503 already gets, rather than
being followed.

Tests: the_client_never_honors_an_environment_proxy sets HTTP_PROXY to a
second scripted responder and asserts the real endpoint was hit directly and
the proxy endpoint received nothing. HTTP_PROXY is process-global and
reqwest resolves it only at Client::build time, so the test and every other
local-port client construction in this module (through the port() helper)
share one client_build_lock for the whole window the variable is set,
serializing against the one place this module builds a reqwest client,
instead of adding a dev-dependency. a_redirect_is_a_fault_and_the_redirect_target_is_never_reached
points a 307 at a second responder and asserts that responder never receives
a request. Both mutations (removing .no_proxy(), removing
redirect::Policy::none()) were run by hand against this change and killed
each test, then reverted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:49:57 +02:00
Meta Vens
4c4973b7e8 Add the play authority, Ruled facts and Mint (kernel), schema consumer.v6 (play agent Cut 3, PA-Q5/PA-Q15)
SystemCapability::Play is a new system capability, minted only by the new
PlayPort (mailbox.rs): unconfined, declares in Active with no answer, may
advance time, and is the sole author of FactStanding::Ruled and
ComponentOp::Mint (quantity with no evidence receipt). One new admission rule,
require_ruler, refuses a Ruled fact or a Mint from any other caller in both
reduce and apply_effect; the evidence gate on Canonical facts and Admit is
untouched for every author, Play included. controller_work rows bump to v17
and the consumer schema bumps to consumer.v6 in the same commit, since the
shape change and the version are one bump.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:11:37 +02:00
Meta Vens
182f8034f7 Add the local inference port (play agent Cut 2, PA-Q1 A)
A third `InferencePort` beside the connector and the SDK sidecar: one
loopback-only OpenAI-compatible `POST /v1/chat/completions` call per
prepared request, tool calls returned inert for Rust's own evaluator to
drive the next round, exactly as the connector lane already does. No
credential is read or sent; `open_inference` refuses a non-loopback
`LocalBinding` and a model prefix two transports both claim.

`RoutedInferencePort` gains the local slot and routes by longest claimed
prefix, refusing rather than falling back to the connector when a claimed
prefix has no configured port behind it (the same rule the SDK prefix
already had, now made explicit for both). `ControllerModels::each` is
`pub` and `open_inference` takes `models: &[&str]` instead of the whole
struct, so a caller can extend the list with a model the struct does not
carry.

Dungeon's runtime reads `GHOSTLIGHT_LOCAL_ENDPOINT` and
`GHOSTLIGHT_LOCAL_MODEL_PREFIX` and wires the binding through; no other
Dungeon behavior changes in this cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:12:10 +02:00
Meta Vens
872ba3525c Drop the answer digest from same_answer (L1 Cut 4, c4.s2.f3)
`step_session` compares a row found by command id against the fresh session
with `same_answer`. The command id is `session_command_id(world_id,
jurisdiction, answer_digest)`, so a row under the fresh session's id carries
the fresh session's digest unless its bytes were forged or the hash collided;
the digest clause never decided and survived as an equivalent mutant in two
Soul passes. It is deleted; world, jurisdiction and answer remain.

The id derivation is unchanged, and
`soul_the_session_command_id_derivation_moved_under_the_refactor` still pins
that a moved digest moves the id. Suite counts are unchanged and the distinct
warning messages equal those at 01312dd.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:52:35 +02:00
Meta Vens
2b4b833d1d Pin that a retryable fault does not stop the sweep (L1 Cut 4, c4.s2.f1)
The session permit's rule that a retryable error does not raise the sweep's
stop was unpinned: the existing retryable test uses a pool of two, so both
sessions spawn before either fails, and `held.stop = outcome.is_err()`
survived. Under a pool of one the second entry is spawned only after the
first session's permit is released; the first call faults as retryable and
the second session must still run and commit in the same sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:45:24 +02:00
Meta Vens
635123fbf4 Sweep Uncovered beside a root in deficit in a test (L1 Cut 4, c4.s2.f2)
`Uncovered` was pinned only for a world with no deficit row, so pushing it
only when no jurisdiction was named survived. The new test sweeps a world
whose commons root is in deficit and whose unpositioned debtor holds a
MissingStructure boundary no root covers; one sweep must run the deficit
session and a session under `Uncovered` for that boundary.

The unpositioned-debtor world moves into `unpositioned_debtor_world`, taking
the scale intent, and the existing no-root test uses it unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:43:51 +02:00
Meta Vens
0764842f6b Return a session panic from the sweep instead of unwinding it (L1 Cut 4, c4.s1.f4)
A panic inside one elaboration session used to resume-unwind out of
sweep, abort its siblings mid-inference, and end the runtime's
elaboration driver task, whose handle is dropped: elaboration stopped
silently until restart.

Now each session holds its permit inside a SessionPermit guard whose
Drop raises the sweep's stop before the permit is released, on every
exit but a clean return. A quarantine-class error disarms nothing, and a
panicking task never reaches the line that disarms it, so no session
starts after a panic, by the same path that stops sessions after a
quarantine-class error. The join loop turns a task that did not return
into ControllerError::Serialization("an elaboration session did not
return: ...") and returns it after every started sibling has finished.

The class is quarantine-class on purpose: sweep returns only
quarantine-class errors and debug-logs the rest, so this keeps that one
rule, and the driver's handling of any returned error stays exactly as
it is (debug log). Whether that error quarantines cognition is L1.f1's,
and is not decided here. Serialization is the variant the elaboration
lane already uses for its local invariants.

elaborate_world takes its interval, as drive_cover_tick does, so a test
can drive it at one millisecond; production passes
ELABORATION_SWEEP_INTERVAL unchanged.

Tests: a_panicking_session_lets_its_sibling_finish (the held sibling
commits in the panicking sweep, the error is returned, permits are
free, and a second sweep resumes and commits the panicked session);
no_session_starts_after_a_panic; and in Dungeon
the_elaboration_driver_survives_a_panicking_sweep, which waits for a
second sweep's listing and inference against the driver task ending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:19:34 +02:00
Meta Vens
a3d109bd5e Sweep work under the Uncovered jurisdiction in a test (L1 Cut 4, c4.s1.f7)
the_sweep_claims_work_no_root_covers builds a world with no declared
root, so no deficit row names any jurisdiction, and one open boundary
only Uncovered can claim: a positionless debtor's obligation to a
creditor who can neither command nor sue it (MissingStructure). One
sweep runs one session for it, recorded under Uncovered with that
boundary as its answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:10:49 +02:00
Meta Vens
c90b5a046c Pin the sweep's Active-only gate (L1 Cut 4, c4.s1.f3)
a_draft_world_is_not_swept sweeps a Draft world holding an open dead end
and a deficit: no listing read (so no draw), no inference, no row, the
pool whole, and the world's revision unchanged. WorldPhase has only
Draft and Active, so Draft is the whole non-Active case.

dead_end_world now delegates to dead_end_world_in(phase, ...), which
skips approval and activation for Draft; every existing caller still
gets the Active world it had.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:08:35 +02:00
Meta Vens
6f35229f24 Pin Cut 3's adoption rule on the NoPatch path (L1 Cut 4, c4.s1.f1)
The sweep hands step_session an adopted row's own session, so the
adoption rule is reachable with a differing fresh session only through
rows the in-flight listing excludes: ReadyToSubmit and NoPatch. Until
now only the ReadyToSubmit test killed whole-session equality (M3.1).

a_finished_boundary_session_stays_finished_after_the_world_moves ends a
boundary session NoPatch, narrows the weights to a lens it did not draw
(moving the ancestry), and steps the entry again: the row is found by
id, recognised as this answer's row, ends NoPatch with no inference, and
its bytes are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:06:52 +02:00
Meta Vens
3e35b3d006 Pin the id-only resume path and single-session permits (L1 Cut 4)
Test-only; no production change.

- a_boundary_row_ready_to_submit_is_submitted_after_a_tick: the listing
  returns only ElaboratorInFlight rows, so a boundary session that stopped
  between ReadyToSubmit and its submit is found by its id alone, and Cut 3's
  adoption rule decides whether it resumes. The test advances a refused
  session to that row through the real store and ticks the clock. The next
  sweep must submit the row under its id with no new inference.
  The test targets the mutation that restores whole-session equality in
  step_session. At bc31d11 that mutation survived every test, because
  adopting from the listing hands step_session the row's own session.
- one_answer_is_one_session_per_sweep now also records how many permits
  are free while its one session infers, and expects one. The target is a
  claim set that holds one entry twice. At bc31d11 that mutant passed this
  test, because the duplicate session resumed the first session's committed
  row.

This commit builds. Library 458 + 1 ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 18:02:14 +02:00
Meta Vens
bc31d1196f Put the elaboration call site and the quarantine stop under test (L1 Cut 4)
- Dungeon: the body of elaborate_world's loop becomes
  run_elaboration_sweep(&state). It checks quarantine, runs the sweep on
  state.elaboration_permits, and logs any error at debug, exactly as before.
  The interval loop is unchanged.
- saturating_one_pool_never_delays_the_other now runs its second half
  through run_elaboration_sweep. The production call site is therefore the
  one tested: passing controller_permits there fails the test, which was
  not true when the test called sweep directly.
- Library: no_session_starts_after_a_quarantine_error pins the sweep's stop
  rule. The pool holds one permit and the first session faults with an
  integrity violation, so the second entry never reaches the provider.

This commit builds. Library 457 + 1 ignored, Dungeon 53 + 1 ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:51:31 +02:00
Meta Vens
b3963f911d Run elaboration sessions concurrently over a per-sweep claim set (L1 Cut 4)
Rulings L1-Q4 (revised) and L1-Q9 (A).

Library (elaboration.rs):
- The sequential per-jurisdiction loop and step(jurisdiction) are deleted.
  ElaborationOutcome loses Clean and Inactive, which nothing produces now.
- sweep(permits) takes one snapshot and returns Ok unless the world is
  Active. It reads the store's in-flight listing once, builds the claim set,
  and spawns one step_session per entry into a JoinSet. Each session holds a
  permit from the caller's pool for its whole step.
- Every spawned session finishes before sweep returns. A quarantine-class
  error is returned after that, and no session starts after one has returned
  such an error. The sequential loop's early return also started no further
  session. Any other error is logged at debug.
- demand_entries is the claim set. For each swept jurisdiction it takes
  every covered boundary in derivation order, then the deficit if nonzero.
  Each entry is matched against the listing by world, jurisdiction and
  answer before anything is drawn. A match adopts the row's session and
  command id; if several match, the oldest is adopted and the rest are
  logged as orphans. An unmatched entry derives its id and draws its lens
  (fresh_session). An answer is claimed once, and so is a command id.
- step_session(command_id, session, snapshot) is the old step body. Its
  lookup and Cut 3's adoption rule are unchanged.
- step_in(jurisdiction), test-only, steps the first entry of one
  jurisdiction's claim set, so the Cut 3 tests keep reading one session's
  outcome.

Store: the listing now returns only ElaboratorInFlight rows. A committed
session ends in ReadyToSubmit, so listing that stage adopted a committed
deficit row, and every later sweep resubmitted it (AlreadyApplied) instead
of opening the next deficit session. That departs from the map's listing
contract.

Dungeon: elaborate_world and the ignored road test pass
state.elaboration_permits to sweep. The driver doc states that sweeps never
overlap.

Tests: 12 library tests.
- Concurrency: distinct_entries_run_at_once_under_the_ceiling,
  the_sweep_never_exceeds_its_ceiling, a_sweep_holds_a_permit_while_it_infers,
  one_answer_is_one_session_per_sweep.
- Claim set: a_boundary_under_nested_roots_is_claimed_once,
  a_deficit_entry_is_one_per_jurisdiction_per_ancestry.
- Errors: a_retryable_fault_ends_only_its_own_session,
  a_quarantine_error_is_returned_after_every_session_finishes.
- Rediscovery: a_stranded_deficit_session_is_adopted_with_its_refusal_intact,
  a_boundary_session_is_repaired_after_a_commit_and_a_tick,
  the_store_gains_no_orphan_per_commit, a_row_for_another_answer_is_not_adopted.

Dungeon adds saturating_one_pool_never_delays_the_other. The dead-end
fixture can now declare its commons (or commons and a ward) as a
jurisdiction root, with a subject that holds a goal, because a sweep visits
only jurisdictions that have a deficit row. The Cut 3 tests call step_in.

Docs: ghostlight-world-ontology.md states the concurrent sweep as the
shipped rule.

Counts: library 456 + 1 ignored, Dungeon 53 + 1 ignored. This commit
builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:44:04 +02:00
Meta Vens
8538fab9a7 List in-flight elaboration sessions from the work store (L1 Cut 4)
ControllerWorkStore gains elaboration_in_flight(), a required method with
no default body. It returns every elaboration row in ElaboratorInFlight or
ReadyToSubmit, never NoPatch, with its command id, least recently stored
first.

- The CultCache store answers from its in-memory work map in one pass,
  after the same custody check lookup makes, and orders the rows by their
  stored_at.
- RecordingWorkStore answers over its map, in map order, because it keeps
  no write time.
- Dungeon's AlwaysFreshWorkStore returns an empty list.
- ElaboratorSession is now re-exported, because Dungeon implements the
  trait and must name the type. ElaborationCheckpoint::session is
  pub(super).

Nothing calls the method yet; the elaboration sweep reads it in a later
commit. This commit builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:09:00 +02:00
Meta Vens
2c3b513b17 Pin that a resumed session keeps its stored text (L1 c3.s1.f3)
Ruling L1-Q7 (a) persisted the instruction text so that editing a lens's
text in code neither breaks nor alters an in-flight session, but no test
proved a resume reads the stored text. The new test takes a real refused
row, rewrites it as a later build would find it (stored text differs from
the recorded lens's current text, invocation prepared under the stored
text), pushes it through the real store file, reopens, and resumes with a
fresh runner. Every inference after the resume carries the stored text,
the session commits under the same command id, and the row still records
the stored text and the drawn lens.

It pins resume only; nothing ties stored text to the recorded lens, which
remains a recorded cost.

Test-only; no production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:52:26 +02:00