Ghostlight/docs/architecture/ghostlight-play-agent-cut.md
Meta Vens 43f0eb75f7 Record PA.f197: Heimdall documents read as a schema, not as a consumer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:39:57 +02:00

249 KiB
Raw Blame History

Ghostlight Dungeon play agent — cut map (plan step 16)

Ends are owned by ghostlight-play-agent.md; this document owns the means. Written 2026-09-22 against Ghostlight cb0b604 on codex/ghostlight-dungeon-mvp, and re-read against 5ff1d54 (the target's Q1 premise correction and the Q11 ruling). cb0b604..5ff1d54 changes no file under crates/, sidecar/ or web/, so every line number below is against cb0b604 = 5ff1d54 source. Hands start at Cut 1 from the commit that lands this file. Rulings recorded at 37be2ce (PA-Q1, Q3, Q7, Q10, Q12, Q13 accepted; PA-Q15 new) were folded in the same day: PA-Q5, PA-Q7, PA-Q10, PA-Q12, PA-Q13, Cuts 3, 4, 5 and 7, the ledger and the findings were revised, and superseded text is kept only under History.

How status is read

Status is derived from what this document records; nothing here is a maintained header.

  • A question (PA-Qn) is open exactly when it has no Ruling field. Authority is operator, self-under-standing-go, or defaulted. A defaulted question is a conventional choice the map made and recorded; it is open to the operator but blocks nothing.
  • A cut is landed when its Landed field names commits, and closed when every promise (P<cut>.<n>) in its Verdicts table has a verdict (HOLDS | FALSIFIED | UNPROVEN). The root agent fills Landed and the Verdicts; Hands and Soul do not edit this file.
  • A mutation (M<cut>.<n>) names file, line, before and after; the commit it ran against is filled at execution. Mutations run against committed code and are restored with a reverse edit, never git checkout.
  • A finding (PA.f<n>) is something no cut owns; each carries introduced-or-pre-existing, severity, and triage (fix | operator | recorded).
  • The ledger carries an estimate per cut; the Actual column is computed from the diff at landing and is empty until then.

The pass is open until every cut is closed and every question has a ruling.

Every build and test named here runs on the Windows workstation and proves Windows only. The Linux release is Idunn's build on Yggdrasil (gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md); the Dungeon bin lists four more tests there (#[cfg(target_os = "linux")], see the stock-lenses map).

What the probes established

  • Probe B, the public boundary (compile, external crate). A scratch crate outside the workspace (session scratchpad probe-boundary, path dependency on crates/ghostlight, cargo check --offline, scratch target removed; repo untouched) tried, from outside the library:

    • build an InferenceRequest: E0451, fields purpose and provider are private (controllers.rs:115-118); there is no public constructor (tool_request is pub(super), controllers.rs:6061);
    • read InferenceOutput.events: E0616 (controllers.rs:148-151, pub(super));
    • read PreparedInference.invocation: E0616 (controllers.rs:132-135). So a port "a consumer implements outside this crate" (the doc comment at controllers.rs:376-380) cannot read the request it must lower;
    • read SubjectSnapshot.position: E0616 (lib.rs:978);
    • read AffordanceSnapshot.entry.roles: E0616 (patch.rs:889).

    And these compile from outside: ControllerPort::new(mailbox) .submit_controller(id, &opportunity, invocation) (mailbox.rs:688-731); WorldMailbox::submit_clock (mailbox.rs:356); serde_json::from_str::<WorldPatch> and ::<DecisionInvocation> (both derive Deserialize with pub(crate) fields).

    Consequence: Dungeon today can exercise for a Persona and submit a patch it deserialized, but it cannot run an inference, read an inference's output, read where anyone stands, or read what an affordance's roles are. Every surface the play agent reads or writes is library-private. This is PA-Q12.

  • Catalog size (measured from the checked-in fixture). sidecar/claude-sdk/test/schemas.json is regenerated from patch_tools() by sdk_inference.rs:1874-1913. The 39 patch tools total 54,810 characters (parameters plus descriptions parsed from patch.rs:5600-6128). Dropping declare_affordance (8,055 characters of parameters on its own), record_gap and submit leaves 36 tools and 46,428 characters, about 13,300 tokens at 3.5 characters per token. The largest are create_commitment (6,373), the three pressure tools (about 2,950 each), bind/release (1,625). A 22-tool core (relocate, transfer, consume, admit, bind, release, communicate, witness, acquire_knowledge, forget, create/discharge_commitment, advance/reduce_pressure, open/close_route, the five light declarations, set_persona_material) is 31,609 characters, about 9,000 tokens. This core was measured before PA-Q15 replaced admit with mint; the ruled starting list is under PA-Q7. A granted speak tool is 160 characters. Script: scratchpad catalog_size.py.

  • Source-verified facts the design rests on (read, not probed):

    • Rust drives every connector round: an inert port returns one round's calls and the lane's evaluator re-derives the conversation from completed (evaluate_elaboration_loop, elaboration.rs:1127-1223; evaluate_operational_loop, controllers.rs:5829). The SDK port instead runs the loop inside one sidecar query and asks a lent ToolResultOracle (sdk_inference.rs:417-498); it holds one gate mutex for a whole query (:423), and ToolResultOracle::answer is synchronous (controllers.rs:251-260).
    • Routing is a model-name prefix with two slots, connector and SDK (sdk_inference.rs:715-773); open_inference refuses at open any ControllerModels lane no slot claims (controllers.rs:2336-2376).
    • The SDK sidecar is a private MessagePack child-process pipe (SidecarFrame, sdk_inference.rs:36-70) that borrows codex_connector::CodexInputItem; the connector port is the only user of the connector's socket client (controllers.rs:406-527). The one shared seam is the in-process InferencePort trait. This confirms the target's corrected Q1 premise (5ff1d54).
    • The Projector's typed context carries no knowledge (projector_context, controllers.rs:4144-4166); its visible stimulus is the subject's whole knowledge, each row rendered said | seen_doing | known with the speaker's label, certainty and text (visible_stimulus/projector_knowledge/perceived_from, controllers.rs:4604-4655). Rows carry minted_at (lib.rs:1044-1047) but nothing marks what arrived since the subject last acted. The kernel records every exercise as a DecisionEvent { revision, scope, .. } in WorldState.events (lib.rs:774-793, :873, pushed at :4881); last_opportunity_at is fictional minutes, not a revision (lib.rs:853).
    • Projector then Persona runs as two checkpointed stages of the narrative lane (run_narrative controllers.rs:2770-2840, run_projector :3530, run_persona :3604), followed by the Interpreter. select_one (:1261) matches any mode the opportunity names, so the Projector can run for a Human-mode opportunity; only run_narrative's lane gate (:2776-2780) restricts it.
    • An exercise commits only if exact_opportunity finds the scope's current digest (lib.rs:4244-4270) and the caller equals the assignment's expected_caller (lib.rs:738-752, checked at :1613 and :1631 and re-decided at :4866 and :4897). A NarrativePersona's expected caller is Controller(controller_id), which ControllerPort::submit_controller mints from the kernel-issued opportunity (mailbox.rs:316-332); a forged controller_id fails ControllerMismatch. A human's expected caller is its Principal.
    • Retry identity is caller plus body (lib.rs:1466-1476); an ExerciseDecision body carries its opportunity (revision and digest), so a retry rebuilt from a later snapshot is CommandIdConflict, not AlreadyApplied. A retry must resubmit the persisted body.
    • require_system_capability (lib.rs:1703-1720) and its twin (apply_effect, :4768-4780) admit a system caller for exactly the listed pairs. require_answer (:1740-1764) refuses every unanswered Active patch that declares or carries evidence, from every caller, including the owner. require_patch_author (:4284-4315) returns unconfined only for the owner.
    • verify_state_shape demands controller_assignments.len() == subjects.len() (journal.rs:488-495); opportunities are minted for every assignment whose mode() is Some (lib.rs:4115-4156).
    • No operation grants or revokes an affordance: grants are written only by a subject's declaration (ComponentOp variants patch.rs:1014-1188; catalog "Draft-only", lib.rs:868). The genesis human subject is declared with kernel_speak_grant() only, at the commons (mailbox.rs:115-145), and speak has one precondition, CanBroadcast { Colocated } (patch.rs:2283-2297). Relocate needs a route from the subject's current place and cannot place an unplaced subject (patch.rs:3613-3660).
    • render_world_structure (elaboration.rs:2172-2280) is an existing whole-world renderer with bracketed ids, used by the seed lane only.
    • SourceSpan::locate (ghostlight-persona-projection/src/lib.rs:366-375) is the existing verbatim-quote locator.
    • The seed lane's key hashes ancestry (seed_command_id, elaboration.rs:1545-1551). Seeding runs as sweep(1) inside the owner's request (runtime.rs:1786-1826), one session at a time.

Questions

PA-Q1 Where the local backend lives

  • Asked: 2026-09-22 (target Q1, premise corrected at 5ff1d54).
  • Context: the only shared seam is InferencePort. Every lane's request is a CodexProviderRequest (instructions, input items, tools, tool choice, parallel calls, effort, output cap); lowering that to OpenAI chat completions is mechanical (instructions → system, UserText → user, AssistantText → assistant, ToolCall → assistant tool_calls, ToolResult → tool message; response text → InferenceEvent::Text, tool_calls → InferenceEvent::ToolCall, inert). Probe B: an external port cannot read PreparedInference.invocation, and routing is a private two-slot router. Ghostlight never holds a credential; a local llama-server on loopback needs none.
  • Options:
    • A: a third port in the library, crates/ghostlight/src/local_inference.rs, beside sdk_inference.rs. Loopback-only endpoint (refused at open otherwise), no credential header, inert tool calls, receipt digest over a typed receipt (request id, model, response id, usage, finish reason). A third routing slot claimed by a model prefix. Adds reqwest to the library (already in the workspace lock for Dungeon). About 250 lines with tests.
    • B: a port in Dungeon. Needs a public accessor on PreparedInference and a public routing seam that takes a foreign port. Two changes to the library's inference boundary so the port can live outside it; the port itself is the same code.
    • C: a CultNet connector daemon (a new service speaking a new CultNet contract, like CodexConnector). No invariant it protects exists here: a local model server is already its own process, and no credential needs isolating. It buys third-party credentialed endpoints, which the map says are needed "before third parties use the deployment" — not for the gate.
  • Recommendation: A. Smallest surface, one owner for routing, and the loopback-and-no-credential rule keeps "Ghostlight never holds a credential" true by construction. C remains the path for credentialed third-party endpoints and is recorded as PA.f1.
  • Depends on it: Cut 2.
  • Ruling: A, as recommended.
  • Authority: operator.
  • Ruled: 2026-09-22.

PA-Q2 What a Persona perceived

  • Asked: 2026-09-22 (target Q2).
  • Finding: partly yes. The visible stimulus already carries every row the subject holds, with speaker and said | seen_doing | known (controllers.rs:4616-4638), which is what speech heard and acts seen are (Told { by }, Seen { by }). It carries all of it, in minted_at order, with nothing marking what arrived since the subject last acted, so a Persona cannot tell the player's line of this turn from one heard ten turns ago.
  • Smallest addition (default): a derived, per-row marker. The snapshot derives last_acted_at: Option<u64> for each subject as the greatest DecisionEvent.revision whose scope is that subject (one pass over state.events, no new state, no schema change), and projector_knowledge adds "new": minted_at > last_acted_at to each row (a subject that never acted sees every minted row as new). Nothing about who hears what changes; the kernel's knowledge stays the sole perception owner.
  • Cost: the Projector's stimulus bytes change for every lane that renders one, including the offline narrative lane. Stored NarrativeCheckpoint::Projector rows stay valid: their integrity check does not rebuild the stimulus (controllers.rs:788-822).
  • Depends on it: Cut 6.
  • Ruling: default as above.
  • Authority: defaulted.

PA-Q3 The callable Persona turn

  • Asked: 2026-09-22 (target Q3).
  • Context: the stages exist; what binds them to ControllerRunner is the checkpoint store, the Interpreter stage and the runner's five-model open.
  • Options:
    • A: an unpersisted lane. pub struct PersonaLane in controllers.rs, built from a ControllerPort, an Arc<dyn InferencePort>, a projector model and a persona model. Two methods: pub async fn turn(&self, command_id, &DecisionOpportunity) -> Result<PersonaTurn, ControllerError> (Projector then Persona, the existing prompt builders, returns the existing receipt-bound PersonaTurn), and pub async fn narrate(&self, command_id, &DecisionOpportunity) -> Result<String, ControllerError> (Projector only, for the player; Q11). No work-store row. A restart mid-turn repeats the two inferences; nothing was committed, and the returned turn is persisted by its caller (Dungeon's turn record, Cut 8).
    • B: persisted. Reuse the Projector and Persona checkpoints and end at a new terminal stage (Spoken { turn }). Resumable, but it needs a new stage, its integrity arm, its progression rule, and a controller_work row bump, and it gives the turn two durable owners (the work store and Dungeon's turn record).
  • Recommendation: A. The prose is what is worth keeping and its caller keeps it; in-flight inference on a local machine is the cheap thing to lose. One owner for turn state.
  • Depends on it: Cut 6.
  • Ruling: A, as recommended.
  • Authority: operator.
  • Ruled: 2026-09-22.

PA-Q4 Exercising on an actor's behalf

  • Asked: 2026-09-22 (target Q4).
  • Finding: no kernel change. Probe B compiled ControllerPort::submit_controller from an external crate. A Persona's act commits as that Persona's own ExerciseDecision, under the caller the mailbox mints from the kernel-issued opportunity, and admission is unchanged: exact_opportunity (scope digest), expected_caller, require_granted, action::exercise (preconditions, ceilings, band draw). The player's act commits through submit_principal with the player's own VerifiedPrincipalEvidence, which Dungeon already holds for the request (runtime.rs:1571-1585); the evidence's own expiry bounds it (mailbox.rs:227-233). Sealed admission is not weakened because nothing new is admitted.
  • What the kernel cannot check, Dungeon checks (Cut 8): that the actor was dispatched this turn (or is the player), and that speech and display are literal spans of that actor's prose (invariant 4). Tool identity carries the actor (<handle>__<kind>, the grouped lane's precedent, controllers.rs:73-75), never a model-written argument.
  • Retry rule (Cut 8): the exact CommandBody is persisted before it is submitted, and a resumed call resubmits those bytes under the same command id, so the kernel answers AlreadyApplied (lib.rs:1466-1476).
  • Ruling: finding; nothing to rule.
  • Authority: self-under-standing-go.

PA-Q5 The play authority's shape

  • Asked: 2026-09-22 (target Q5).
  • Default design: SystemCapability::Play (lib.rs:467-484), unit-shaped. Minted by exactly one public method on a narrowed port, pub struct PlayPort { mailbox } in mailbox.rs with snapshot(), submit_patch(command_id, WorldPatch) and advance_time(command_id, TickMinutes), both stamped (submit_stamped, mailbox.rs:267). The clock precedent maps one-for-one: a system capability with no session evidence, minted by one method, admitted for listed bodies only. Admission:
    • require_system_capability and its apply_effect twin admit (Play, AdmitPatch) and (Play, AdvanceTime) and nothing else;
    • require_patch_author returns Ok(None) (unconfined) for Play;
    • require_answer admits (Active, None) with declarations for Play and for no other caller; Play is refused in Draft (WrongPhase);
    • require_clock_caller admits Play;
    • Play, and no other caller, may declare a Ruled fact and Mint quantity (PA-Q15, ruled; shape under PA-Q15 "Design"). Play carries no evidence receipts, so its Admit is refused by the existing gate like anyone's without a receipt; it uses Mint instead.
  • History (superseded by PA-Q15): this default first refused any Play patch carrying evidence (PlayCarriesEvidence), so that Play could not mint quantity. The operator ruled the opposite.
  • "Only one thing holds it": the capability is compile-time narrowed like every other port; exclusivity is Dungeon constructing one PlayPort in AppState, pinned by a negative grep (Cut 3, Cut 8).
  • Option not taken: binding PlayPort to the owner's verified evidence, like SeedPort. It would end a long play session at the owner's session expiry and buys nothing the journal's System(Play) attribution does not.
  • Depends on it: Cut 3.
  • Ruling: default as above; the name is the operator's to change.
  • Authority: defaulted.

PA-Q6 Retirement's shape

  • Asked: 2026-09-22 (target Q6).
  • Finding and default: retirement is a subject state written by a component operation.
    • State: ControllerAssignment::Retired (lib.rs:700-722). id() and mode() return None, so derive_opportunities mints nothing for it (lib.rs:4125) with no new filter, and controller_assignments stays the sole answer to who may call (lib.rs:836-838). expected_caller becomes Option<CallerId> and returns None for Retired; both call sites compare Some(caller). The shape check's len == clause is unchanged.
    • Operation: ComponentOp::Retire { subject: Ref<SubjectId> } resolving to ResolvedOp::Retire { subject }. Applied: the assignment becomes Retired, the subject's positions entry and affordance_grants entry are removed. subjects, events, knowledge, holdings, facts and the journal keep everything, which is "stays in history".
    • Refusals: retiring an already retired subject is NoOperationEffect; retiring an ExternallyControlled mirror is refused (another world owns it); a confined author (elaborator, consumer) is refused by confine_to_ground (OutsideJurisdiction) whatever the subject's place. Owner and Play may retire.
    • What readers see: no opportunity, so no dispatch (Dungeon dispatches from opportunities) and no act (exact_opportunity → OpportunityMismatch); no position, so audience (lib.rs:3053) never reaches it and the Projector's present omits it; SubjectSnapshot gains pub retired: bool so the agent's view and the surface can say so. Preconditions read the actor's own state and are never reached for a retired actor.
    • Replay: apply_effect applies the resolved op like any other, so replay reproduces it.
    • A body that matters afterwards is the agent's declare_resource in the same patch, as the target says.
  • Schema: ControllerAssignment, ComponentOp, SystemCapability all change shape, so STATE_SCHEMA/COMMIT_SCHEMA bump consumer.v5 → v6 (lib.rs:172-173, pinned at consumer.rs as for L1), in one cut with Q5.
  • Not done (recorded, PA.f4): a retired subject's commitments, pressures and dependency edges stay and the clock keeps deriving motion over them.
  • Depends on it: Cut 4.
  • Ruling: default as above.
  • Authority: defaulted.

PA-Q7 The agent's catalog, and whether a small model plausibly handles it

  • Asked: 2026-09-22 (target Q7).
  • Measured: above. The kernel side without declare_affordance is 36 tools, ~13,300 tokens; a 22-tool core ~9,000 tokens. Dungeon adds dispatch, ask_player, end_turn (small), plus one <handle>__<kind> tool per granted entry per actor in the scene (160–1,500 characters each). With Cut 4 and Cut 5, retire and grant_affordance/revoke_affordance join the kernel side.
  • Plausibility: nobody has measured Bonsai 2 on this. A 13k-token stable tools prefix is reused from a llama.cpp slot's KV cache when the prefix is byte-identical, so its cost is paid once per slot; the risk is selection accuracy over ~40 tools, not tokens. Strict-schema grammar enforcement on llama-server tool calls is a property of the server build and must be checked on Raven's machine (PA.f2).
  • Options: A: the whole kernel catalog minus declare_affordance. B: a curated Dungeon list of PATCH_TOOLS names (Dungeon policy, pinned ⊆ PATCH_TOOLS by test, the lens leads_with precedent), starting from the 22-tool core plus retire and grant_affordance.
  • Revised by PA-Q15 (the starting list Cut 8 pins): admit leaves the list, because Play holds no receipts and every Admit it wrote would be refused; mint takes its place. declare_fact stays, and its standing now offers ruled. witness and acquire_knowledge (Witnessed) stay, because they are how a ruled fact reaches a subject and so the Projector. The list: relocate, transfer, consume, mint, bind, release, communicate, witness, acquire_knowledge, forget, create_commitment, discharge_commitment, advance_pressure, reduce_pressure, open_route, close_route, declare_place, declare_route, declare_resource, declare_fact, declare_subject, set_persona_material, retire, grant_affordance, revoke_affordance (25 tools). Its size is re-measured from the fixture after Cut 5.
  • Revised by PA.f61 (Self, under the standing go, 2026-09-22): communicate leaves the list, which becomes 24 tools. set_persona_material is limited to subjects declared in the same patch.
  • Recommendation: B, measured at the gate against A on the same turn script; the list is one constant and widening it is a one-line change.
  • Depends on it: Cut 7 (the library takes a name list), Cut 8 (the list).
  • Ruling: B, as recommended.
  • Authority: operator.
  • Ruled: 2026-09-22.

PA-Q8 The turn lifecycle, and what survives a restart

  • Asked: 2026-09-22 (target Q8).
  • Default design: Dungeon owns one open turn at a time in its own CultCache store, service/play-turn-v1.cc, on the app_session.rs pattern (OwnedRedbMessagePackBackingStore, one row). The row, PlayTurn: turn_id (the world.play idempotency key), opening_prompt (built once from the snapshot and the player's text, then never rebuilt), player_prose: Vec<String> (the first text and each answer), rounds: Vec<InferenceOutput>, calls: Vec<CallRecord> (per tool call: its call id, the exact CommandBody or play patch persisted before submission, and its result string once known), persona_turns: Vec<(SubjectId, PersonaTurn)>, question: Option<String>, refusal: Option<String> (Q13), narration: Option<String>, state: Running | AwaitingPlayer | Closed.
    • The agent's conversation is re-derived from opening_prompt, rounds and the recorded results, the connector posture; nothing crosses from one turn to the next except world state. When a turn closes, the next turn's opening_prompt depends only on the snapshot and the new text, which is invariant 1's test.
    • Commands inside a turn carry ids derived from (turn_id, round, call_index) (sha256 → UUID, CommandId::parse_uuid); a call whose body is recorded and whose result is not is resubmitted with the recorded body.
    • After a restart, a Running turn resumes on the player's next world.play (the player's evidence rides that request; an empty text means "continue"), and an AwaitingPlayer turn waits for the answer. Nothing runs a player act without a live principal.
  • Depends on it: Cut 8.
  • Ruling: default as above.
  • Authority: defaulted.

PA-Q9 The Eve play surface

  • Asked: 2026-09-22 (target Q9), narrowed by Q11's ruling.
  • Default design: in Active, for the principal whose subject is human_controller, the surface is: the latest narration (the Projector's prose for the player; Q11) as a text card; the agent's question when one is open; the refusal line (Q13) when present; the turn state; a control.input.textarea bound to text and one world.play button (ghostlight.world_play.v0, bindings ["text"]). Place, co-presence, perceived speech with speaker and displays arrive through the narration, which renders them from committed state. The owner keeps world.advance_time. The story card (eve.rs:436-455) is deleted from the play surface: it is the whole-world operator log and shows the player speech it never perceived.
  • Depends on it: Cut 9.
  • Ruling: default as above.
  • Authority: defaulted.

PA-Q10 The player's subject

  • Asked: 2026-09-22 (target Q10).
  • Finding: the genesis human subject serves as the player's body, but not as it stands. It holds speak only (mailbox.rs:115-124), and no operation in any lane can grant it another verb; a physical act refused by a precondition, and moving by the player's own act (invariant 3), both need verbs it cannot get. Placement is fine: the agent, holding Play, declares a route from the commons and relocates the subject as its own ruling.
  • Design (ruled A): ComponentOp::GrantAffordance { subject, affordance } and RevokeAffordance write affordance_grants for an existing subject and an existing catalog entry. The owner (including the seed lane, which is the owner in Draft) and Play may use them. Confined authors are refused, because a grant is authority, not structure. The seed grants the player the world's verbs in Draft, and the agent grants or takes verbs in play (bound hands). Cut 5 specifies it.
  • History (not taken): B, rebind a seeded Person to the human principal. Binding a human is genesis-only on purpose (admits_human, lib.rs:1775-1778), because a human joins the required approvers. C, let the agent rule the player's physical acts as raw ops, which violates invariant 3.
  • Depends on it: Cut 5.
  • Ruling: A, as recommended.
  • Authority: operator.
  • Ruled: 2026-09-22.

PA-Q11 The player's prose

  • Ruling: the player's perception is rendered by the same Projector a Persona gets. "Render it with the Projector; we're paying for it on every Persona already." The Projector is the player's narration; the play agent writes none. Plan: one PersonaLane::narrate call for the player subject at the end of every turn (Cut 8), after the last commit. Its serial cost is measured and recorded at the gate beside seconds per turn, not decided here.
  • Authority: operator.
  • Ruled: 2026-09-22 (4e61f58).

PA-Q12 Where the agent's read and write vocabulary lives

  • Asked: 2026-09-22, from probe B. The target says the library gains three pieces and Dungeon owns the agent's loop, prompt and catalog. The Body says Dungeon cannot build or read an inference, cannot see positions, roles, resources, routes or knowledge, and cannot decode a patch tool call, because every one of those is pub(crate).

  • Design (ruled A): a consumer-neutral table module in the library, crates/ghostlight/src/table.rs. It holds the vocabulary any unconfined author running actors needs, and nothing about turns:

    • pub fn table_view(&WorldSnapshot) -> String: the whole-world render with bracketed ids. It grows from render_world_structure, which moves here and which the seed lane then calls here. It must print every id an authoring tool takes, which today's renderer does not: resources are printed by label only (elaboration.rs:2257-2263) and facts not at all. table_view prints resource ids and holdings, and every fact with its id, its standing (canonical | claimed by X | ruled) and who knows it.
    • pub fn authoring_tools(names: &[&str]) -> Result<Vec<CodexToolDefinition>, TableError>: a named subset of PATCH_TOOLS, refusing unknown names.
    • pub fn decode_authoring_call(name, arguments) -> Result<WorldPatch, String>: the existing apply_tool_call decode, one call to a one-item patch, and the one decoder apply_tool_call itself then calls.
    • pub fn actor_tools(prefix, &WorldSnapshot, SubjectId) -> Vec<CodexToolDefinition> and pub fn decode_actor_call(&WorldSnapshot, SubjectId, kind, arguments) -> Result<(DecisionOpportunity, DecisionInvocation), String>: the existing catalog_tools/decode_catalog_call, plus an optional display.
    • pub fn describe_refusal(&WorldSnapshot, &KernelError) -> String.
    • Two inference seams: pub fn InferenceRequest::play(..), which wraps tool_request under a new InferencePurpose::Play, and pub fn InferenceOutput::events(&self)/receipt_digest(&self).

    It is a fourth library piece the target's first draft did not list. Cut 7 specifies it.

  • History (not taken): B, widen pub(crate) to pub and let Dungeon render and decode. That gives one vocabulary two renderers and two decoders, one on each side of the boundary, which is split authority. C, move the loop into the library as a table runner, which contradicts the operator's ownership ruling.

  • Depends on it: Cut 7, Cut 8.

  • Ruling: A, as recommended.

  • Authority: operator.

  • Ruled: 2026-09-22.

PA-Q13 How the player sees why an act was refused

  • Asked: 2026-09-22, from Q11's ruling meeting gate item 3 ("does something physical that a precondition refuses, and sees why"). A refused act commits nothing, and the Projector renders committed state only, so the narration cannot say why. The agent writes no narration. (When this was asked, the play authority could not state a world truth at all. PA-Q15 has since given it Ruled facts, which is option C's vocabulary; see "After PA-Q15" below.)
  • Options:
    • A: the surface shows the kernel's refusal of the player's own attempt, rendered by describe_refusal (Q12) from the ActionRejected mismatch set and the affordance's precondition list ("you are not at the cellar"). It is the world's verdict on the player's own act, derived from state, not a claim of world truth. Invariant 8's sentence "The agent's questions are the one thing shown that is not derived from state" still holds; the target should add that kernel refusals of the player's own act are shown.
    • B: the agent asks the player a question that carries the reason. Puts the agent's paraphrase of a kernel verdict in front of the player.
    • C: a Ruled fact standing for play-authority facts, so the agent can commit "the door is barred" and the Projector narrates it. New kernel vocabulary for one gate item.
  • Recommendation: A.
  • After PA-Q15: A stands, and it remains the only way the gate's "sees why" is guaranteed: the kernel's refusal is shown whatever the agent does. The agent may also rule a consequence. For example, it declares a Ruled fact, "the cellar door is barred from inside", and witnesses it at the player's place. That fact is committed, so the next narration renders it as world truth. It is an agent choice, not a Dungeon rule, and it adds nothing to this cut map beyond Cut 3.
  • Depends on it: Cut 7 (describe_refusal), Cut 8 (refusal on the turn), Cut 9 (the line).
  • Ruling: A, as recommended.
  • Authority: operator.
  • Ruled: 2026-09-22.

PA-Q14 L1.f13 and the gate

  • Asked: 2026-09-22 (target exception: in scope if it bites seeding on the gate path).
  • Finding: it does not bite. L1.f13 orphans a refused seed row only when an owner Draft commit lands between the refused round and the retry (seed_command_id hashes ancestry, elaboration.rs:1545-1551). The gate's seeding is the owner pressing Seed, one sweep(1) per request (runtime.rs:1786-1826); consecutive presses with no commit between them find the same row. Its cost when it does bite is a repeated inference and an orphan row, never a wrong commit. It stays deferred.
  • Ruling: finding.
  • Authority: self-under-standing-go.

PA-Q15 What the play authority may assert and mint

  • Asked: 2026-09-22, from PA.f7: the play authority could state no fact of its own (a fact is Canonical with an evidence receipt, or Claimed by a subject) and could mint no quantity (Admit requires evidence), so the residue rule (anything untyped becomes a fact) had no standing for the agent's rulings.
  • Ruling: the play authority's facts stand as Ruled by it, and the play authority mints quantity. The operator: "shackling the Ghostlight Dungeon agent to Njordr's invariants is crazy, if the agent can invent people, it can invent loot." Only Play gains either. Elaborators, consumers and the seed keep the evidence gate, so a consumer feeding Njordr keeps quantity provenance.
  • Authority: operator.
  • Ruled: 2026-09-22.
  • Supersedes: PA-Q5's PlayCarriesEvidence refusal ("so it cannot mint quantity") and PA.f7. Cut 3 is respecified to match.
  • Depends on it: Cut 3.
  • Body facts the design rests on (read at 5ff1d54):
    • The evidence gate is structural in the types, not a caller check. FactStanding is Canonical { evidence } or Claimed { by } (patch.rs:298-303), with FactStandingRef as its proposal twin (:308-311). ComponentOp::Admit and ResolvedOp::Admit carry a required evidence: EvidenceRef (patch.rs:1055-1060, :1721-1726). The resolver refuses a canonical fact or an Admit whose receipt is not in the same patch's evidence list (FactWithoutEvidence, :3046-3052; AdmitWithoutEvidence, :3938-3944). Apply re-checks both (lib.rs:2044-2060, :2373-2390), and so does replay's shape check (journal.rs:580-590).
    • The resolver is actor-blind. resolve_patch(state, command_id, patch, scale_intent, lens_weights) (patch.rs:2947-2953) is never told the caller. Every per-author rule runs after it, in reduce, and is re-decided in apply_effect: require_patch_author and confine_to_ground (lib.rs:1655-1685, :4786-4802).
    • The quantity ledger has one owner: check_ledger (patch.rs:1877-1891) credits admitted, and apply_operations recomputes the deltas (lib.rs:2124-2150).
    • FactCandidate (patch.rs:2111) is the resolver's view of a fact's standing. Its only standing-dependent rule is "evidenced knowledge is knowledge of canon" (:4288-4296), which apply repeats (lib.rs:2638-2646).
    • Subjects never see standing in prose. The snapshot lowers standing to FactStandingView { Canonical, Claimed { by } } (lib.rs:1056-1059, written at :3660-3663). The Projector's stimulus renders how (said | seen_doing | known, from KnowledgeSource), certainty and text, never standing (controllers.rs:4616-4638). Only the typed views (Interpreter, operational) print it (controllers.rs:4675-4695).
  • Design (for Cut 3): two additive variants plus one author rule. Admit is not reshaped.
    • Facts: FactStanding::Ruled and FactStandingRef::Ruled, both unit variants. There is exactly one play authority and the journal's commit records its caller, so a field naming the ruler would be a second copy of the caller that could disagree with it. Lowering maps FactStandingRef::Ruled → FactStanding::Ruled (patch.rs:4965-4972), and FactCandidate gains Ruled (:2111, :3184, :3340). Canonical stays the only standing that evidenced knowledge may name, so a Ruled fact can be known Witnessed but never Evidenced.
    • Quantity: a new ComponentOp::Mint { holder, resource, qty } and ResolvedOp::Mint. It resolves like Admit without the receipt clause: holder and resource must resolve and qty must be nonzero. It credits the ledger as admitted (the ledger has no "minted" term to add; the equation before + admitted + gained - consumed - spent = after is unchanged), and operation_ground treats it like Admit (lib.rs:4604-4606).
    • One author rule: require_ruler(caller, &ResolvedPatch) -> Result<(), Vec<Mismatch>> collects every Ruled fact and every Mint op and refuses them with Mismatch::RuledWithoutAuthority { site } unless caller == System(Play). It is called in reduce's AdmitPatch arm after resolve_patch, beside confine_to_ground, and re-decided in apply_effect's PatchAdmitted arm as an Invariant error, like confinement.
    • Why this shape keeps the gate for everyone else: Canonical and Admit keep their required receipts in the type, so every other author's evidence gate is exactly as it is today: the same predicate, at the same three sites, still reached for every author including Play. What Play gains is two new spellings that only require_ruler can admit. Neither existing check becomes "evidence or else".
    • Subjects: the snapshot maps FactStanding::Ruled to FactStandingView::Canonical (lib.rs:3660-3663). A ruled fact is world truth inside the fiction. Which door admitted it is non-fictional provenance, just as a receipt is (lib.rs:1036-1039), so no subject-facing surface distinguishes it. The Projector needs no change: a subject told or shown a ruled fact renders it known with certainty.
  • Option not taken: turning Admit.evidence into an enum or Option (Evidence(EvidenceRef) | Ruled). That changes the wire shape of a consumer's Admit (consumer.rs:547-552, ghostlight.consumer_patch.v0) and the admit tool schema. It also moves the evidence gate out of the type and into a caller check, so every author's gate would depend on one condition that stays right.

Order, and why

Subtraction first, then library, then Dungeon's table. The automatic cover tick and the elaboration sweep are writers the play agent must not share the world with: a cover tick makes Personas act outside any turn, and an Active elaborator declares structure the agent did not rule. They are forbidden writers under the target, so they go before the owner that replaces them exists (Loud Rebuild: cut obsolete authorities first). world.speak and the story card stay until Cut 9 replaces them, because between the cuts they are the only way to use the world.

Cut Kind Repo Depends on
0 captures Ghostlight rulings
1 subtraction Dungeon 0
2 behaviour library (inference) PA-Q1
3 behaviour library (kernel: play authority, Ruled facts, Mint; schema v6, work rows v17) PA-Q5, PA-Q15
4 behaviour library (kernel: retire) 3, PA-Q6
5 behaviour library (kernel: grant/revoke affordance) 4, PA-Q10
6 behaviour library (Persona lane, recency marker) PA-Q2, PA-Q3
7 behaviour library (table vocabulary) 2, 5, 6, PA-Q12, PA-Q13
8 behaviour Dungeon (play table and turn lifecycle) 1, 7, PA-Q7, PA-Q8
9 behaviour + subtraction Dungeon (Eve play surface; speak and story deleted) 8, PA-Q9

Cuts 3–5 may land as one commit if Hands prefers one schema bump; the map splits them so each has its own promises. Cut 2 and Cut 6 are independent of 3–5 and may land in either order.

Cut 0. Captures

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp at the map's commit. No commit; output in the session scratchpad.
  • First: git status clean.
  • Captures:
    • test census with -- --list for -p ghostlight --lib, --doc, --test external_admission, -p ghostlight-dungeon --bin ghostlight-dungeon, --test build_provenance, -p ghostlight-persona-projection; per-module tally as the stock-lenses map did;
    • base-stimulus.txt: from a temporary test beside the narrative lane's leak tests, the rendered visible_stimulus for a subject with one Told row, one Seen row and one held fact. Cut 6 pins that the new text equals this plus exactly one "new" field per row;
    • base-catalog.txt: patch_tool_signatures() and sha256(serde_json::to_vec(&patch_tools())). Cuts 4 and 5 change it by exactly the added tools;
    • distinct warnings from a forced rebuild of both crates;
    • repo target size.

Cut 1. Delete the autonomous drivers and owner controls from Dungeon

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp from the map's commit. One commit; builds alone. Library untouched.

  • Deletes first (crates/ghostlight-dungeon/src/runtime.rs unless noted; line counts approximate, measured at landing):

    • the tick driver: CoverSummary, InterruptedCell, cover_panel, impl CoverSummary (:104-154, ~50); CLOCK_TICK_INTERVAL, CLOCK_TICK_MINUTES, submit_clock_tick (:1747-1770, ~24); configured_cover_budget, configured_tick_interval (:1864-1888, :1908-1922, ~40); drive_cover_tick, drive_one_tick, run_cover_tick (:1924-2125, ~200); the spawn at :408; AppState.cover, AppState.cover_budget (:89-95) and their construction;
    • the Active elaborator: ELABORATION_SWEEP_INTERVAL, elaborate_world, run_elaboration_sweep (:1828-1862, ~35); configured_elaboration_ceiling (:1900-1906); AppState.elaboration_permits (:84-86); the spawn at :409;
    • the owner's "Let X act": ControllerActPayload (:249-254), dispatch_controller (:1048-1195, ~148), admit_controller_command (:1197-1231), ControllerHttpResult and controller_narrative_result, controller_operational_result, controller_pending_result, controller_submission, controller_error_disposition (:1233-1427, ~195), the branch at :1000-1002;
    • crates/ghostlight-dungeon/src/eve.rs: CoverPanel, CoverPanelTick, cover_card (:119-178, ~60), the cover parameter of authenticated_surface (:242), the owner's controller buttons and descriptor (:483-560, ~78), "world.controller.act" in operation_schema (:633);
    • tests that pin only deleted behaviour: a_clock_tick_submits_the_configured_span_and_nothing_measured (:3106), the_tick_driver_never_exceeds_its_controller_permit_pool (:3317), saturating_one_pool_never_delays_the_other (:3353), a_speak_turn_and_a_sweep_draw_only_from_their_own_pools (:3522), the_elaboration_driver_survives_a_panicking_sweep (:3666), quarantine_raised_mid_tick_stops_the_sibling_cell_and_every_later_tick (:3750), a_second_mid_turn_change_reaches_the_drivers_interrupted_arm (:3914), drive_one_tick_runs_every_cell_before_the_clock_and_all_share_one_tick (:4028), the ignored live_smoke_seeds_then_ticks_a_world_against_the_connector (:4153), and every fixture only they use (Hands lists them from the compiler's dead-code warnings, not by name). eve.rs tests naming cover are updated, not deleted.
  • Keeps: controller_permits and configured_controller_concurrency (:1889-1898): Cut 8 draws Persona dispatch from this pool. Between Cut 1 and Cut 8 only runtime_readiness (:1712-1716) reads it; that interval is stated here so no one reads it as a leftover. ControllerRunner stays for seeding and custody_probe. world.speak, the story card and world.advance_time stay until Cut 9.

  • Docs in the same commit: docs/architecture/ghostlight-dungeon-mvp.md:477-484 drops the cover, tick-interval and elaboration-ceiling variables; docs/architecture/ghostlight-world-ontology.md:676-677 and the handoff line 311 lose the elaboration pool. gamecult-ops sets none of these (grepped).

  • Authority map:

    • Owner: after this cut, nothing in Dungeon runs a Persona, an operational agent or an elaborator on its own; the owner's world commands and seeding remain.
    • Forbidden writers (deleted): the wall-clock AdvanceTime loop, cover cells, the Active elaboration sweep, owner-requested controller turns.
    • Derived state: CoverSummary is gone with the driver it summarised.
    • Deletion line: every path above.
  • Rules losing their only test (Hands must list them in the report): pool separation (both pool tests) and quarantine-on-tick go with the pools and the tick they described; none survives in code.

  • Verification:

    • builds: cargo check -p ghostlight-dungeon --bin ghostlight-dungeon; distinct warnings no larger than Cut 0's.
    • tests: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon; the census drops by exactly the listed tests; every_operation_the_panel_emits_has_a_schema (:4558) still passes with world.controller.act gone.
    • negative: rg -n 'derive_cover|drive_cover_tick|elaborate_world|run_elaboration_sweep|elaboration_permits|world\.controller\.act|submit_clock\(' crates/ghostlight-dungeon/src = 0, except exactly one hit for world\.controller\.act: M1.1's required test. The paren in submit_clock\( keeps the library method name from matching a doc word. Corrected after Soul, 2026-09-22 (PA.f11 replaces this grep with a source-scan test). rg -n 'GHOSTLIGHT_(COVER_|TICK_INTERVAL|ELABORATION_MAX)' crates docs/architecture/ghostlight-dungeon-mvp.md = 0.
    • mutation: M1.1 re-add "world.controller.act" => "ghostlight.world_controller_act.v0" to operation_schema: removed_legacy_operation_is_denied_before_dispatch (:3052) must be extended in this cut to name world.controller.act, and must then fail.
  • Promises:

    • P1.1 No Dungeon code path submits AdvanceTime except the owner's world.advance_time command.
    • P1.2 No Dungeon code path runs run_cell, run_narrative, run_operational or ElaborationRunner::sweep.
    • P1.3 world.controller.act is denied before dispatch.
    • P1.4 The library diff is empty.
  • Landed: d69e9d4 (Hands, 2026-09-22). −2184 net lines; the Dungeon bin census went 56 → 46 (ten tests, one more than listed: no_proposal_projection_carries_the_canonical_world_commit tested the deleted controller_submission). controller_quarantined went too, because its only writers were deleted. Its readiness arm and seed-gate filter went with it. The negative grep's world\.controller\.act term conflicts with M1.1's required test, so it holds with exactly one hit, at that test. M1.1 failed under mutation. Distinct warnings: 19 before, 19 after.

  • Verdicts (Soul, 2026-09-22, against d69e9d4, Windows):

    Promise Verdict Note
    P1.1 HOLDS By grep and inspection; unguarded: a startup submit_clock loop passed 46/46 (PA.f11)
    P1.2 HOLDS By grep and inspection; unguarded: a seed-spawned sweep passed 46/46 (PA.f11)
    P1.3 HOLDS M1.1 caught at runtime.rs:2226
    P1.4 HOLDS Library diff empty

    The negative grep's world\.controller\.act term is corrected to exclude the test module. It must hit only the M1.1 test (removed_legacy_operation_is_denied_before_dispatch).

  • Soul findings, triaged: PA.f11 and PA.f12 are fix; PA.f13 and PA.f14 are assigned to Cut 8; PA.f15 is fix.

Cut 2. The local inference port

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp. One commit. Written for PA-Q1 A.

  • Deletes first: none.

  • Adds:

    • crates/ghostlight/Cargo.toml: reqwest.workspace = true.
    • crates/ghostlight/src/local_inference.rs (~180 lines plus tests): pub struct LocalBinding { pub endpoint: SocketAddr, pub model_prefix: String, pub caller_runtime_id: String }; pub const DEFAULT_LOCAL_MODEL_PREFIX: &str = "local/"; struct LocalInferencePort { client, endpoint, prefix, caller_runtime_id }. prepare calls PreparedInference::prepare (the one builder). infer refuses a foreign caller id and an expired invocation exactly as the SDK port does (sdk_inference.rs:680-690), lowers the request to one POST /v1/chat/completions (model = name with the prefix stripped, messages from instructions and input, tools with strict: true, parallel_tool_calls, max_tokens), and returns text and tool calls as events, inert. It holds no mutex across a request (several slots). Receipt: sha256 over rmp_serde::to_vec_named of LocalInferenceReceipt { schema_id, request_id, conversation_id, caller_runtime_id, native_request_sha256, provider_request_sha256, model, response_id, finish_reason, prompt_tokens, completion_tokens }, the SDK receipt's shape. Faults: connection refused and 429/503 → retryable; 4xx other → recovery_required; a response that is not the declared shape, a tool call naming no offered tool, or an invalid call id → integrity_violation.
    • Loopback rule: open_inference refuses a LocalBinding whose endpoint is not loopback (ControllerOpenError::LocalEndpointNotLoopback). No header other than content type is ever sent.
  • Per-file changes:

    • sdk_inference.rs:715-773: RoutedInferencePort gains a local: Option<(String, Arc<dyn InferencePort>)> slot; route checks the local prefix first, then the SDK prefix, then the connector. A prefix claimed by two slots is refused at open.
    • controllers.rs:2336-2376: open_inference(connector, sdk, local: Option<LocalBinding>, models: &[&str]); ControllerModels::each (:2294) becomes pub so callers pass &models.each() plus the play model (Cut 8).
    • lib.rs:116: pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding}; and mod local_inference;.
    • runtime.rs:440-478: read GHOSTLIGHT_LOCAL_ENDPOINT and GHOSTLIGHT_LOCAL_MODEL_PREFIX; no other Dungeon change in this cut.
  • Authority map:

    • Owner: RoutedInferencePort stays the one router; the model name still carries the transport.
    • Forbidden writers: no port reads a credential; no lane chooses a port.
  • Verification:

    • builds: cargo check -p ghostlight; cargo check -p ghostlight-dungeon --bin ghostlight-dungeon.
    • tests (cargo test -p ghostlight --lib local_inference::), against a scripted HTTP responder on 127.0.0.1:0 (tokio listener in the test; no new dev-dependency): a_scripted_backend_drives_an_elaboration_round_through_the_existing_evaluator pins invariant 7 (two rounds, calls inert, evaluate_elaboration_loop re-derives the conversation, the second request carries the first round's tool results); the_request_lowers_every_input_item pins the message mapping item by item; a_non_loopback_endpoint_is_refused_at_open; a_call_to_an_unoffered_tool_is_an_integrity_violation; routing_prefers_the_longest_claim_and_refuses_a_shared_prefix; no_request_carries_an_authorization_header (the responder asserts on the raw header block).
    • negative: rg -n -i 'authorization|bearer|api[_-]?key' crates/ghostlight/src/local_inference.rs = 0 outside the test that asserts absence.
    • mutations: M2.1 drop the loopback check: the open test fails. M2.2 return ToolCall events only for the first call: the elaboration-round test fails on the second call. M2.3 map a 4xx to retryable: the fault test fails.
  • Promises:

    • P2.1 A scripted local backend carries a full tool round through the existing evaluator with calls returned inert (invariant 7).
    • P2.2 The port refuses non-loopback endpoints and sends no credential.
    • P2.3 Each lane routes by its model name to exactly one port.
  • Landed: 182f803 (Hands, Sonnet, 2026-09-22).

    • Lines: +985 / −34. local_inference.rs is 860 lines, the scripted HTTP responder and its tests included, against an estimate of ~180 plus tests.
    • Lib census: 466 → 473 (472 pass, 1 ignored). A seventh test pins M2.3's fault dispositions, which the list of six did not cover.
    • The lock gained one line; reqwest 0.12.28 was already resolved with the same features.
    • Hands caught its own draft falling back to the connector on a claimed but unconfigured prefix. The existing soul_routing_refuses_rather_than_falling_back test and an_unroutable_model_fails_at_open caught it.
  • Verdicts (Soul, 2026-09-22, against 182f803, Windows):

    Promise Verdict Note
    P2.1 HOLDS M2.2 killed
    P2.2 FALSIFIED PA.f16: an HTTP_PROXY variable sent the whole request to a proxy, plus proxy-authorization from the proxy URL. PA.f17: a 307 re-POSTed the body to another port
    P2.3 HOLDS An empty prefix is accepted (PA.f19)
    Invariant 7 HOLDS

    Soul's own mutations S1 and S7 were killed. S2 (the tools array sent empty), S3 (the model prefix not stripped), S4 (the foreign-caller gate removed) and S6 (response_id dropped from the receipt) survived; see PA.f22.

  • Soul findings, triaged: PA.f16 to PA.f20 and PA.f22 are fix, as one batch after Cut 3 lands, because they touch files Cut 3 is editing. PA.f21 is recorded.

  • Fix batch landed: a062b56 (PA.f16, PA.f17) and 4ac7b82 (PA.f18–f20, PA.f22), by Hands on Sonnet, 2026-09-22.

    • The client is built with .no_proxy() and redirect::Policy::none(). Client construction in tests is serialized on a module mutex, because reqwest reads the proxy variables at build time.
    • A 3xx takes the ordinary recovery_required disposition.
    • An absent response id and a null or absent tool_calls are tolerated. A tool-call id stays required, because the loop correlates on it.
    • An empty model prefix is refused at open. Duplicate call ids are refused as integrity_violation.
    • The id validators have one copy, in controllers.rs.
    • The loopback check is in LocalInferencePort::new.
    • A request with no tools omits tools and parallel_tool_calls.
    • Every fix was killed by its own mutation. local_inference:: went 7 → 21 tests; lib 488 → 502.
  • Re-verification (Soul, Sonnet, 2026-09-22, against 4ac7b82): P2.2 HOLDS. .no_proxy() disables environment proxy resolution as a whole, and Policy::none() refuses every 3xx. A SocketAddr cannot carry userinfo. P2.3 HOLDS: a whitespace-only prefix matches no model, because model names are validated whitespace-free. S2, S3, S4 and S6 are now killed. Soul's new mutation N2 (3xx retryable) was killed. N1 (any IPv6 address counts as loopback) survived: see PA.f24. Residuals are PA.f23, PA.f24 and PA.f25.

Cut 3. The play authority, Ruled facts and Mint (kernel), schema consumer.v6

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp. One commit. Written for PA-Q5's default and PA-Q15's ruling and design. The state, commit and tool-catalog changes land together because they are one bump: a schema bump without its shape change would be a lie, and so would a shape change without one.

  • First: Cut 0's captures. git status clean.

  • Deletes first: none. Everything here is an admission clause or an additive variant.

  • Per-file changes (line numbers against 5ff1d54):

    • The capability. lib.rs:467-484: SystemCapability::Play, with the doc "The play table. Admitted for unanswered, unconfined Active patches, the only author of Ruled facts and Mint, and for the clock. Minted only by PlayPort."
    • lib.rs:1703-1720 and :4768-4780: admit (Play, AdmitPatch) / (Play, PatchAdmitted) and (Play, AdvanceTime) / (Play, TimeAdvanced), and nothing else.
    • lib.rs:1740-1764: require_answer(state, caller, answers, declares). (Active, None) with declares is Ok exactly when caller == System(Play). Play in Draft is WrongPhase. Both call sites (:1663, :4791) pass the caller.
    • lib.rs:4284-4315: CallerId::System(SystemCapability::Play) => Ok(None) (unconfined).
    • lib.rs:1725-1731: require_clock_caller admits Play.
    • mailbox.rs: pub struct PlayPort with new(WorldMailbox), pub async fn snapshot, pub async fn submit_patch(&self, CommandId, WorldPatch) and pub async fn advance_time(&self, CommandId, TickMinutes), both through submit_stamped (:267) under CallerId::System(Play). lib.rs:94: export PlayPort.
    • Ruled facts. patch.rs:298-303: FactStanding::Ruled, doc "Stated by the play table. The kernel does not evaluate it; only Play may write it." patch.rs:308-311: FactStandingRef::Ruled. patch.rs:2111: FactCandidate::Ruled, set at :3184 and :3340. The evidenced-knowledge clause at :4288-4296 is untouched: it compares against Canonical, so a ruled fact still cannot be known Evidenced. patch.rs:4965-4972 lowers Ruled → Ruled. patch.rs:3046-3052 is untouched (an if let on Canonical).
    • lib.rs:2044-2060 (apply's fact check): the Ruled arm is false, which is not a stranger. journal.rs:580-590 (verify_state_shape): a Ruled standing is live. lib.rs:4500-4515 (confine_to_ground, facts): Ruled under any confined author is OutsideJurisdiction. This is redundant with require_ruler and exists only so the match stays total. lib.rs:3660-3663: FactStanding::Ruled => FactStandingView::Canonical. lib.rs:2638-2646 is untouched.
    • Mint. patch.rs:1014-1188: ComponentOp::Mint { holder: Ref<SubjectId>, resource: Ref<EntityId>, qty: Quantity }, doc "Creates quantity with no receipt. Only Play may write it; every other author creates quantity through Admit and its evidence." patch.rs:1689-1780: ResolvedOp::Mint { holder, resource, qty }. Resolution beside Admit (:3913-3960): the same holder and resource resolution, the ZeroQuantity and QuantityOverflow checks, the same candidate_set and admitted delta, and no receipt clause. Lowering beside :5138-5148. lib.rs:2124-2150: Mint credits admitted. lib.rs:2178: Mint's referent is its resource. lib.rs:2373-2390: apply Mint like Admit without the evidence.contains check. lib.rs:4604-4606: operation_ground treats Mint like Admit. patch.rs:6922: "mint".
    • The author rule. lib.rs, beside confine_to_ground (:4411): fn require_ruler(caller: &CallerId, resolved: &ResolvedPatch) -> Result<(), Vec<Mismatch>>. It returns Ok when the caller is System(Play). Otherwise it returns one Mismatch::RuledWithoutAuthority { site } per Ruled fact (Site::Declaration(handle)) and per Mint (Site::Operation(index)), and Ok if there are none. Called in reduce's AdmitPatch arm (:1655-1685) right after resolve_patch and before confinement, mapped to PatchRejected. Re-decided in apply_effect's PatchAdmitted arm (:4786-4802), mapped to KernelError::Invariant("admitted patch rules or mints without the play authority"). patch.rs:1285: Mismatch::RuledWithoutAuthority { site: Site }.
    • Catalog. patch.rs:5799 region: PATCH_TOOLS gains mint (Operate { variant: "mint" }, fields holder, resource, qty, the same field kinds as admit without evidence). Its description says only the play table may mint. patch.rs:6270-6282: the FactStandingRef composite gains ("ruled", vec![]), and declare_fact's description says only the play table may rule. The catalog test (patch.rs:6837) goes to (7, 31, 40). assert_exhaustive and the round-trip test cover the new variants. The sidecar fixture is regenerated (GHOSTLIGHT_WRITE_SIDECAR_FIXTURES=1, sdk_inference.rs:1898).
    • Row version. controllers.rs:77-78: controller_work.v16 → v17. An in-flight elaboration or seed row stores its tool list, and this cut is the first to change that list (stock-lenses probe Q7). The bump moved here from Cut 4. Cuts 4 and 5 ride this v17 if they land before a v17 store exists anywhere; otherwise each bumps again.
    • Schema. lib.rs:172-173: consumer.v5 → consumer.v6. consumer.rs:2237 and the length pin are updated as in L1. CONSUMER_PATCH_SCHEMA stays ghostlight.consumer_patch.v0. A consumer can now decode Mint and ruled, but admission refuses both for it, so nothing a consumer could have sent before changes meaning.
  • Authority map:

    • Owner: the kernel's admission functions. PlayPort is the only minter of System(Play). require_ruler is the only admitter of a Ruled fact or a Mint.
    • Inputs: caller, phase, answer, the resolved patch.
    • Outputs: patches and clock advances attributed System(Play), Ruled facts and minted quantity. Every one is journaled with the caller, which is the provenance.
    • Derived state: FactStandingView::Canonical for a ruled fact is display only. It decides nothing, and the kernel reads FactStanding itself.
    • Forbidden writers: the owner, the seed lane (which is the owner in Draft), elaborators and consumers may not write Ruled or Mint. They keep Canonical { evidence } and Admit { evidence } with the receipt check exactly as it is. Every caller except Play still meets AnswerRequired for an unanswered Active declaration.
    • Shared paths: reduce and apply_effect (and replay through it) call the same require_ruler. The resolver stays actor-blind.
    • Deletion line: none. PA-Q5's PlayCarriesEvidence was never built.
  • Verification (Windows; proves Windows only):

    • builds: cargo check -p ghostlight; cargo check -p ghostlight-dungeon --bin ghostlight-dungeon; distinct warnings compared with Cut 0.
    • tests (library), each pinning one rule:
      • the_play_authority_declares_in_active_without_an_answer; an_elaborator_declaration_in_active_without_an_answer_is_still_refused; the_owner_still_cannot_declare_unanswered_in_active (invariant 5, both halves);
      • the_play_authority_is_unconfined (a place outside every jurisdiction); the_play_authority_is_refused_in_draft; the_play_authority_advances_time; the_play_authority_cannot_approve_activate_exercise_or_decline (each Unauthorized);
      • the_play_authority_rules_a_fact_and_a_witness_knows_it_as_canon: a Ruled fact, witnessed at a place, appears in the subject's snapshot as FactStandingView::Canonical, and in its Projector stimulus as known with no author;
      • the_play_authority_mints_and_the_ledger_balances: before + qty = after, and check_ledger passes on replay;
      • no_one_else_may_rule_a_fact, table-driven over the owner in Draft (the seed's caller), the owner in Active, an elaborator with a valid answer, and a consumer on its own mirror. Each is refused with RuledWithoutAuthority, carrying the fact's declaration site;
      • no_one_else_may_mint, the same four callers, refused with RuledWithoutAuthority at the op's index;
      • the_evidence_gate_is_unchanged_for_every_author: for the same four callers and for Play, a Canonical fact or Admit whose receipt is not in evidence is still FactWithoutEvidence / AdmitWithoutEvidence, and with the receipt in evidence is admitted for the owner in Draft and for an answered elaborator, as today;
      • a_ruled_fact_cannot_be_known_evidenced (EvidencedKnowledgeOfClaim);
      • apply_effect_re_decides_the_ruler: a forged PatchAdmitted under an elaborator caller, carrying a Ruled fact that the resolver would accept, is refused at apply. This follows the apply_effect_re_decides_elaborator_authority pattern (lib.rs:15415);
      • a_journal_holding_a_ruled_fact_and_a_mint_replays: reopen the journal and compare the state digest;
      • the existing suite, including the catalog, fixture and consumer pins, with counts updated.
    • external: tests/external_admission.rs gains play_port_is_the_only_play_minter (compile-pass use of PlayPort) and a consumer document carrying Mint, refused.
    • negative: rg -n 'SystemCapability::Play' crates/ghostlight/src outside #[cfg(test)] lists only the admission sites (require_system_capability, its twin, require_answer, require_patch_author, require_clock_caller, require_ruler) and PlayPort's two methods. rg -n 'PlayCarriesEvidence' crates docs/architecture/ghostlight-play-agent.md = 0 (the cut map keeps the name only in History). Tested: the pattern matches no other identifier.
    • mutations (each a loosening, not only a revert):
      • M3.1 require_answer: admit any System(_) instead of Play. Killed by the elaborator test.
      • M3.2 require_ruler: caller == System(Play) → matches!(caller, CallerId::System(_)). Killed by no_one_else_may_rule_a_fact (elaborator and consumer rows).
      • M3.3 require_ruler: collect Ruled facts only, not Mint. Killed by no_one_else_may_mint.
      • M3.4 require_ruler: admit Principal(owner) too. Killed by the owner rows of both tests; this is the seed's caller, which the ruling keeps gated.
      • M3.5 apply_effect: drop the require_ruler re-decision. Killed by apply_effect_re_decides_the_ruler.
      • M3.6 Admit resolution (patch.rs:3938): evidenced = true for Play. Killed by the_evidence_gate_is_unchanged_for_every_author (Play row). This is the spelling that would turn Admit into "evidence or else".
      • M3.7 Mint apply: credit consumed instead of admitted. Killed by the ledger test.
      • M3.8 snapshot (lib.rs:3650-3668): filter out knowledge rows whose fact is Ruled, which is how a view could hide a ruling from the subjects who hold it. Killed by the witness test.
      • M3.9 require_system_capability: admit (Play, ActivateWorld). Killed by the "cannot approve" test.
  • Promises:

    • P3.1 Play declares in Active unanswered and unconfined; no other caller does.
    • P3.2 Play alone writes Ruled facts and Mint quantity. The owner, seed, elaborators and consumers are refused both at reduce and at apply_effect.
    • P3.3 The evidence gate on Canonical facts and Admit is byte-for-byte the same predicate at the same sites for every author, Play included.
    • P3.4 A ruled fact is world truth to every subject that knows it: its view is Canonical, and the Projector renders it as known.
    • P3.5 Minted quantity balances the ledger, and replay reproduces it.
    • P3.6 Play cannot act in Draft, approve, activate, exercise or decline.
  • Landed: 4c4973b (Hands, Sonnet, 2026-09-22). It was amended and force-pushed over its own first push 9fbdde8, and no other commit was lost. Future Hands briefs forbid amending and force-pushing.

    • Lines: +993 / −19.
    • Census: lib 473 → 488 (487 pass, 1 ignored); external_admission 12 → 13; doc 10; Dungeon bin 48.
    • The regenerated fixture is the only fixture change.
    • M3.1–M3.9 were all killed, and two needed test corrections:
      • M3.5: confine_to_ground's redundant Ruled arm masked a dropped apply-time require_ruler for a Ruled fact. The test forges a Mint effect instead.
      • M3.9: require_owner independently refuses Play activation, so the pipeline could not observe the mutation. A unit assertion on require_system_capability was added.
    • Not done as specified:
      • The Projector-stimulus half of P3.4 is untested; only the snapshot's Canonical view is asserted.
      • The external consumer-document Mint refusal was not written, because the receipt's outcome is pub(crate). The consumer row of no_one_else_may_mint covers the refusal internally.
  • Verdicts (Soul, Opus, 2026-09-22, against 4c4973b, Windows; the first attempt died on a server error):

    Promise Verdict Note
    P3.1 HOLDS M3.1 killed
    P3.2 HOLDS in code; UNPROVEN by tests Genesis ungated (PA.f26). Owner at apply, op index > 0 and a second fact untested (PA.f27)
    P3.3 HOLDS By diff inspection; the four-caller table is missing (PA.f29)
    P3.4 HOLDS Soul's Projector probe: a witnessed ruled fact renders known, by: null; the non-witness does not see it
    P3.5 HOLDS Overflow at u64::MAX refused, zero refused, u128 world totals, replay equal
    P3.6 HOLDS MS5 killed

    Soul's mutations:

    • MS1 (Draft pass), MS5 and MS6 (exempt elaborators) were killed.
    • MS2 (exempt Principal at apply) and MS3 (first op only) survived Hands' suite; Soul's probes kill them.
    • MS4 (first ruled fact only) survived every test.

    require_ruler is load-bearing for confined authors too. Under MS6 an elaborator's Mint passes, because confinement has no Mint veto.

  • Soul findings, triaged: PA.f26, PA.f27 and PA.f29 are fix, as one batch after Cuts 4–5 land, together with PA.f23 and PA.f24. PA.f28 is defaulted and recorded.

Cut 4. Retirement (kernel)

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp from Cut 3. One commit (shares Cut 3's v6). Written for PA-Q6's default.

  • Per-file changes:

    • lib.rs:700-760: ControllerAssignment::Retired; id, mode, consumer, human_principal return None; expected_caller -> Option<CallerId>, None for Retired and ExternallyControlled keeps Some(System(Consumer)); call sites :1613, :1631, :4866, :4897 compare Some(&caller).
    • patch.rs:1014: ComponentOp::Retire { subject: Ref<SubjectId> }; patch.rs:1689: ResolvedOp::Retire { subject: SubjectId }; resolution beside Relocate (:3613): subject resolves, is not retired (NoOperationEffect), is not externally controlled (Mismatch::RetiresAMirror); lowering at :5093 region.
    • lib.rs:2115 apply_operations: set Retired, remove positions and affordance_grants entries.
    • lib.rs:4411 confine_to_ground: every Retire under any confined author is OutsideJurisdiction.
    • lib.rs:966-1015: SubjectSnapshot.retired: bool (pub), set in snapshot.
    • patch.rs:5600: PATCH_TOOLS gains retire (Operate { variant: "retire" }, one field subject); counts at patch.rs catalog test go to (7, 32, 41) (Cut 3 added mint); the sidecar fixture is regenerated (GHOSTLIGHT_WRITE_SIDECAR_FIXTURES=1).
    • Row version: rides Cut 3's controller_work.v17 (see Cut 3 for when a second bump is due).
  • Authority map:

    • Owner: controller_assignments stays the sole answer to who may call; Retired is a value of it, not a second partition.
    • Derived state: opportunities, audiences and presence are derived and read the assignment and position; none stores "retired" apart from it.
    • Forbidden writers: elaborators and consumers.
  • Verification:

    • tests: a_retired_subject_holds_no_opportunity_across_a_replay (invariant 6: retire, reopen the journal, assert no opportunity and retired); a_retired_subject_cannot_act (its last opportunity is OpportunityMismatch); a_retired_subject_hears_nothing (speech in its former place gives it no knowledge row); a_retired_subject_keeps_its_history (events and knowledge unchanged); retiring_twice_is_no_effect; a_mirror_cannot_be_retired; an_elaborator_cannot_retire_inside_its_jurisdiction; the_owner_and_the_play_authority_may_retire; the state-shape check accepts a world with a retired subject.
    • mutations: M4.1 leave the position in place: the "hears nothing" test fails. M4.2 make mode() return the old mode for Retired: the opportunity test fails. M4.3 drop the confinement clause: the elaborator test fails.
  • Promises:

    • P4.1 A retired subject gets no opportunity, cannot be an actor, and is never an audience, and replay reproduces it (invariant 6).
    • P4.2 Retirement erases no history.
    • P4.3 Only the owner and Play retire.
  • Landed: 3725ba8 (Hands, Sonnet, 2026-09-22). Lib census 502 -> 513 across Cuts 4 and 5; catalog (7, 32, 41); fixture additive only. verify_state_shape needed no new arm. The loosening of M4.2 (expected_caller still Some for Retired) survived: mode() gates first. The loosening of M4.3 (confine only elaborators) survived: no consumer-authored Retire test. See PA.f30.

  • Verdicts (Soul, Opus, 2026-09-22, against 732b1d3, Windows):

    Promise Verdict Note
    P4.1 FALSIFIED A retired subject is still an audience, and a raw Communicate speaker, through a Reach::Subjects channel (PA.f33). No opportunity, cannot act, and replay: these hold
    P4.2 FALSIFIED in practice Retiring any subject with history fails verify_append (PA.f31). Forget can still edit a retired subject's knowledge (PA.f34)
    P4.3 HOLDS as written The owner's Draft retire of the human removes the consent gate (PA.f32)

    M4.1 was killed. Soul's MuA, MuC and MuE survived the Hands suite (PA.f35).

  • Soul findings, triaged: PA.f31–PA.f35 are fix, in one batch after the current fix batch lands. The design is settled by Self under the standing go. It is recorded in each finding.

Cut 5. Granting and revoking affordances (kernel)

  • Repo/branch: from Cut 4. One commit. Written for PA-Q10 A (ruled).
  • Per-file changes: ComponentOp::GrantAffordance { subject, affordance: Ref<AffordanceId> } and RevokeAffordance; resolution refuses a retired or external subject, an unknown entry, a grant already held or a revoke not held (NoOperationEffect), and a revoke that leaves a subject with no grant (derive_opportunities requires one, lib.rs:4142-4146); apply writes affordance_grants; confine_to_ground refuses both for every confined author. PATCH_TOOLS gains grant_affordance, revoke_affordance ((7, 34, 43)), fixture regenerated. Same v6/v17 as Cuts 3–4 if landed before either ships; otherwise its own bump.
  • Verification: the_seed_grants_the_player_a_verb_in_draft; the_play_authority_grants_and_revokes_in_active (the scope digest moves, the old opportunity is ScopeChanged); a_revoke_cannot_leave_a_subject_mute; an_elaborator_cannot_grant. Mutations: M5.1 allow the last revoke: its test fails. M5.2 drop the confinement clause: the elaborator test fails.
  • Promises: P5.1 Grants change only through owner, seed-in-Draft or Play. P5.2 No subject with a controller is left with no grant.
  • Landed: 732b1d3 (Hands, Sonnet, 2026-09-22). Catalog (7, 34, 43). The loosening of M5.1 (allow a Human's last revoke) survived, and so did the loosening of M5.2 (confine only elaborators). See PA.f30.
  • Verdicts (Soul, Opus, 2026-09-22, against 732b1d3, Windows):
    Promise Verdict Note
    P5.1 HOLDS Revoke confinement is untested: MuB survived (PA.f35)
    P5.2 HOLDS Structurally, and the replay shape check enforces it. A used verb cannot be revoked at all (PA.f31). MuD survived (PA.f35)

Cut 6. The Persona lane and the recency marker (library)

  • Repo/branch: Ghostlight codex/ghostlight-dungeon-mvp. One commit. Written for PA-Q2's default and PA-Q3 A.

  • Deletes first: none. (Moves: the Projector-then-Persona request building in run_narrative :2818-2840 and run_projector :3530-3580 becomes two private functions both paths call, so the prompts have one builder.)

  • Adds:

    • lib.rs:966: SubjectSnapshot.last_acted_at: Option<u64> (pub(crate)), filled in snapshot from one pass over state.events.
    • controllers.rs:4616-4638: each row gains "new": bool.
    • controllers.rs: pub struct PersonaLane { mailbox: ControllerPort, inference: Arc<dyn InferencePort>, projector_model: String, persona_model: String }, pub fn new(..) refusing non-canonical models; pub async fn turn(&self, command_id, &DecisionOpportunity) -> Result<PersonaTurn, ControllerError> (refuses any mode but NarrativePersona); pub async fn narrate(&self, command_id, &DecisionOpportunity) -> Result<String, ControllerError> (refuses any mode but Human). Requests are projector_request/persona_request (:5934, :5949) under the given command id. No work-store access; the type holds no ControllerWorkStore.
    • lib.rs:80-87: export PersonaLane.
  • Authority map:

    • Owner: the library builds every Persona and Projector input from the subject's own slice; PersonaLane has no parameter through which a caller can pass text into either prompt.
    • Forbidden writers: the caller (the play agent) of any Persona input.
  • Verification:

    • tests: a_persona_turn_carries_no_structured_state (the Persona request's only input item is prose; none of the subject's ids, the scope digest, the revision, or any key of the typed view appears; invariant 2); a_persona_turn_never_sees_a_secret_the_subject_does_not_hold (a fact another subject holds is absent from both requests); the_marker_flags_rows_since_the_last_act (act, then hear two lines: those two are new, earlier rows are not); a_subject_that_never_acted_sees_every_row_new; narrate_refuses_a_persona_and_turn_refuses_a_human; the stimulus equals Cut 0's capture plus the marker; the existing narrative-lane tests pass unchanged except where they pin stimulus bytes.
    • negative: rg -n 'ControllerWorkStore|persist' over the PersonaLane impl block = 0.
    • mutations: M6.1 pass typed_view into the Persona prompt: the no-structured-state test fails. M6.2 compute new with >=: the marker test fails on the subject's own spoken row. M6.3 take last_acted_at from last_opportunity_at (minutes): the marker test fails.
  • Promises:

    • P6.1 A Persona's input is Projector prose; the Projector's input is the subject's own slice (invariant 2).
    • P6.2 The marker is derived from the journal's events and stores nothing.
    • P6.3 The player's narration is the same Projector over the player's slice (Q11).
  • Landed: c4334cc (Hands, Sonnet, 2026-09-22). controllers.rs +1011/−31, lib.rs +21/−1. Lib 542 → 549 (548 run, 1 ignored).

    • The Projector and Persona builders are extracted into build_projector_invocation and build_persona_invocation, which the narrative lane and PersonaLane share.
    • turn and narrate also refuse a retired subject.
    • The stimulus equals Cut 0's capture plus one "new" per row. A row with no minted_at is never new.
    • M6.1–M6.3 were killed.
    • Correction to the spec: M6.2's rationale ("the subject's own spoken row") is impossible, because fan_out excludes the speaker from its own telling. The > boundary is pinned by a constructed case instead.
  • Verdicts (Soul, Opus, 2026-09-22, against c4334cc, Windows):

    Promise Verdict Note
    P6.1 HOLDS Probed: the full Persona request carries only instructions, the brief, identity and prose. Mara's Projector request carries no ids, digests, other subjects' knowledge or Vault text. The assembled request is untested (PA.f42); the brief is world-public (PA.f45)
    P6.2 HOLDS Derived from events and replay-deterministic. Rows with no minted_at are never new (PA.f44)
    P6.3 HOLDS By code and probe; no committed test (PA.f43)

    M6.1–M6.3, MB and MD were killed. MA (turn accepts OperationalAgent), MC (the typed view in the Projector guidance) and ME (select_fresh instead of select_one) survived.

  • Soul findings, triaged: PA.f41–PA.f44 and PA.f46 are fix, in batch 6 after batch 5. PA.f45 is settled by a target wording change. The idempotency note is carried into Cut 8.

Cut 7. The table vocabulary and the inference seams (library)

  • Repo/branch: from Cuts 2, 5 and 6. One commit. Written for PA-Q12 A and PA-Q13 A.

  • Keeps and moves: render_world_structure and id_text (elaboration.rs:2164-2280) move to table.rs; the seed prompt calls them there.

  • Adds: crates/ghostlight/src/table.rs with the functions named in PA-Q12's design. table_view extends the moved renderer with each subject's place, mode, retired, granted entry names and holdings, and the places' occupants. It prints resource ids (the moved renderer prints labels only, elaboration.rs:2257-2263) and every fact with its id, its standing (canonical | claimed by <label> | ruled) and who knows it, because mint, transfer, witness and acquire_knowledge all take those ids. It ends with the clock and the brief. This is the agent's whole-world view. Standing is printed here and nowhere subject-facing. decode_authoring_call is apply_tool_call's decode arm (elaboration.rs:1339-1420) called for one call, so there is one decoder: apply_tool_call calls it. decode_actor_call is decode_catalog_call (controllers.rs:6279-6360) plus a verbatim optional display, and looks the subject's current opportunity up in the snapshot. describe_refusal renders ActionRejected mismatches through the entry's preconditions, and PatchRejected mismatches by site with the offending tool call's index, so the agent can repair it; every other KernelError renders by its Display. The agent holds Play, so it never meets RuledWithoutAuthority or AdmitWithoutEvidence for its own mint. It does meet AdmitWithoutEvidence if it calls admit, which is why admit is not in its list (PA-Q7). controllers.rs:99-112: InferencePurpose::Play; pub fn InferenceRequest::play(command_id, round, model, instructions, input, tools, max_output_tokens) over tool_request with parallel calls on; InferenceOutput::events() and receipt_digest().

  • Authority map:

    • Owner: the library owns the only renderer and the only decoders of its vocabulary; Dungeon composes them.
    • Forbidden writers: Dungeon code building a ComponentOp, Declaration or DecisionInvocation by hand.
  • Verification:

    • tests: authoring_tools_refuse_an_unknown_name; every_authoring_tool_decodes_its_own_example (reuses field_example); the_elaborator_and_the_table_share_one_decoder (the same arguments give the same operation through both); an_actor_tool_decodes_to_the_subjects_current_opportunity; describe_refusal_names_the_failed_precondition; table_view_prints_every_id_the_tools_take.
    • negative: in crates/ghostlight-dungeon/src, rg -n 'ComponentOp|Declaration|RoleBinding|ProposedEffect' = 0 after Cut 8.
    • mutations: M7.1 give decode_actor_call the snapshot's first opportunity instead of the subject's: its test fails. M7.2 let authoring_tools skip unknown names: its test fails. M7.3 print resources by label only, as the moved renderer does: table_view_prints_every_id_the_tools_take fails on a mint example's resource id. (That test takes one generated example per listed tool and asserts that every referenced id appears in the view.)
  • Promises: P7.1 One renderer and one decoder per vocabulary. P7.2 Dungeon can run an inference and read its output without widening any kernel type.

  • Landed: 7d40393 (Hands, Sonnet, 2026-09-22), +1180/−190.

    • table.rs is 1132 lines. render_world_structure and id_text moved there byte for byte, and the seed prompt is unchanged. table_view is a separate renderer, so the seed bytes never move when it changes.
    • apply_tool_call now calls decode_authoring_call. decode_catalog_call, render_jurisdiction and render_kind are widened to pub(super) for reuse.
    • Lib 570 → 579. M7.1–M7.3 were killed.
    • Not as specified: table_view prints a Ruled fact as canonical, because snapshot() maps Ruled to FactStandingView::Canonical for every reader (PA.f47).
  • Verdicts (Soul, Opus, 2026-09-22, against 7d40393, Windows):

    Promise Verdict Note
    P7.1 FALSIFIED The authoring decode has one production owner (Soul's every-tool probe gave identical patches), but actor_tools emits tools decode_actor_call refuses (PA.f55)
    P7.2 UNPROVEN Everything named compiles and runs from outside the crate on public API alone. A round of several calls cannot become one patch (PA.f54)
    table view never reaches a Persona FALSIFIED as a guarantee Unreached today. The pin greps one file (PA.f58). The structural guard is PersonaLane's signature, which takes no text
    issued opportunity HOLDS Against the snapshot passed in; kernel admission is the real guard
    seed bytes unchanged HOLDS

    M7.1–M7.3 were killed. Soul's X1 (the affordance filter), X2 (display), X3 (claimed shown as canonical), X4 (the "known by" list) and X6 (site wording) survived. So did X5 and X7 (evidence dropped), with respect to the table's own tests.

  • Soul findings, triaged: PA.f53–PA.f59 are fix, in library batch 7a (the table and the batch decoder), which lands before 8b. PA.f48–PA.f51 are batch 7b (the kernel). Cut 8a was told the batching rule and the seams mid-flight.

Cut 8. The play table and the turn lifecycle (Dungeon)

  • Repo/branch: from Cuts 1 and 7. Two commits allowed: (a) the store and the loop with scripted-port tests; (b) wiring into AppState and world.play. Written for PA-Q7 B and PA-Q8's default.

  • Deletes first: none beyond Cut 1.

  • Adds: crates/ghostlight-dungeon/src/play.rs (~600 lines plus tests):

    • PlayTurnStore over service/play-turn-v1.cc, the app_session.rs:56-80 pattern; one row, PlayTurn as in PA-Q8.
    • const PLAY_TOOLS: &[&str] (PA-Q7 B), pinned ⊆ PATCH_TOOLS.
    • PlayTable { play: PlayPort, world: WorldMailbox, personas: PersonaLane, inference, model, permits: Arc<Semaphore>, store } with async fn run(&self, principal: &VerifiedPrincipalEvidence, text: String). Per round: re-derive the conversation, InferenceRequest::play, infer, persist the output, then execute each call in order:
      • an authoring tool → decode_authoring_call → persist → PlayPort::submit_patch;
      • <handle>__<kind> → the handle must be the player or a subject dispatched this turn, else refused; decode_actor_call; text and display must SourceSpan::locate in that actor's prose (the player's texts, or the Persona's turn), else refused; persist the body; submit (submit_principal for the player, ControllerPort::submit_controller for a Persona); on ActionRejected for the player, set refusal from describe_refusal;
      • dispatch { subjects } → for each subject with a NarrativePersona opportunity, PersonaLane::turn concurrently under permits; results are the prose, recorded;
      • advance_time { minutes } → PlayPort::advance_time;
      • ask_player { question } → AwaitingPlayer, loop stops;
      • end_turn → loop stops. Round budget 12; exhausting it ends the turn. On close, PersonaLane::narrate for the player writes narration, and the turn is Closed.
    • The prompt (instructions) is a Dungeon constant stating the table rules: speech and display are quotes; an actor acts only through its own tools; rulings are authoring tools; ask when an outcome needs negotiating.
  • Per-file changes: runtime.rs:68-103 AppState.play: Option<Arc<PlayTable>> plus a Mutex guarding one running turn; open_controller (:440-479) reads GHOSTLIGHT_PLAY_MODEL and passes it to open_inference's model list; execute_world (:1439) routes world.play to the table in a spawned task and returns accepted; eve.rs:622-636 adds "world.play" => "ghostlight.world_play.v0".

  • Authority map:

    • Owner: PlayTable owns the turn; the kernel owns every consequence.
    • Inputs: world snapshot, the turn record, the player's text.
    • Outputs: kernel commands (as the actor or as Play), the turn record.
    • Derived state: the agent's conversation (from the record); narration (from the Projector over committed state).
    • Forbidden writers: nothing but PlayTable constructs PlayPort; no transcript crosses turns; the agent never writes a Persona input.
    • Shared paths: a first run, a resumed run after restart, and a run resumed by the player's answer all execute the same round function over the same record.
    • Deletion line: none new.
  • Verification (scripted InferencePort in tests, no model):

    • restart_equivalence_of_the_agent_input (invariant 1: close a turn, drop and reopen the store and the world, start the next turn; the first request is byte-identical to the one built without the restart);
    • a_paraphrased_quote_is_refused (invariant 4, for the player and a Persona; the forgery differs by one word, same length);
    • an_act_for_an_undispatched_subject_is_refused and a_raw_op_is_journaled_as_play_and_never_as_an_actor (invariant 3: the commit's caller is System(Play));
    • a_resumed_call_resubmits_its_recorded_body (kill after persist, before result: AlreadyApplied, one commit);
    • a_question_holds_the_turn_open_across_a_restart;
    • a_refused_player_act_sets_the_refusal_line;
    • a_mint_and_a_ruled_fact_commit_as_play (the agent's mint and a declare_fact with ruled standing commit with caller System(Play); admit is not in PLAY_TOOLS);
    • the_turn_closes_with_the_players_narration;
    • negative: rg -n 'PlayPort::new' crates/ghostlight-dungeon/src = 1.
    • mutations: M8.1 skip the span check for Personas: the quote test fails. M8.2 rebuild opening_prompt on resume: the question-across-restart test fails on a moved snapshot. M8.3 rebuild the body from a fresh snapshot on resume: the resume test fails (CommandIdConflict).
  • Carried in from findings (Self, under the standing go, 2026-09-22):

    • PA.f14, quarantine: there is no world-level quarantine at the play table. A Persona dispatch or play-lane inference error is handled by its disposition:

      • Retryable is retried inside the round budget.
      • RecoveryRequired or IntegrityViolation from inference becomes that tool call's recorded result for the agent, for example "Mara could not act: ", and the turn continues.
      • A kernel Invariant from any commit closes the turn with the fault recorded, and the table refuses further turns for that world until the daemon restarts.

      Reason: a local model's malformed reply is routine, and quarantining the world on it would end play. ControllerError::requires_quarantine stays unread in Dungeon.

    • PA.f13, the permit barrier: Persona dispatch draws from controller_permits again (P8.5). The route-observation test's permit barrier must then guard something real, or it is deleted in 8b. Readiness's "active" arm becomes reachable again.

    • The Persona turn's command id: each Persona turn gets a fresh command_id, derived from (turn_id, round, call_index). PersonaLane is not idempotent for a repeated id (Cut 6 Soul).

    • The split: Hands runs 8a and 8b as separate passes, to fit one worker's context. 8a is play.rs, with the store, PLAY_TOOLS, the loop and the scripted-port tests. 8b is the AppState wiring, GHOSTLIGHT_PLAY_MODEL, world.play routing and the Eve schema.

  • Promises: P8.1–P8.4 invariants 1, 3, 4 and restart survival as pinned above; P8.5 Persona dispatch draws only from controller_permits.

  • Landed (8a): 6b69270 (Hands, Sonnet, 2026-09-22).

    • play.rs is 2477 lines: about 740 of production code, about 1740 of tests. The Dungeon bin went from 48 to 63 tests. The Dungeon crate now depends on ghostlight-persona-projection (for SourceSpan).
    • M8.1–M8.3 were killed. The other rules have no mutations yet; Soul covers them.
    • commit_authoring_run is the seam that 8b switches to decode_authoring_calls (PA.f54). Until then it commits each call as its own patch.
    • run takes a turn_id, the world.play idempotency key.
    • ControllerPort is built on demand for a Persona's act.
    • Owed to 8b:
      • a_refused_player_act_sets_the_refusal_line is not written, because the genesis fixture has no refusable verb. 8b seeds an affordance with a precondition in Draft.
      • Persona dispatch runs sequentially. 8b holds the lane in an Arc and runs dispatches concurrently under the permits.
      • The refusal line switches to describe_refusal_to_actor (PA.f57).
    • Library gap: PA.f60.
  • Verdicts (8a) (Soul, Opus, 2026-09-22, against 6b69270, Windows):

    Promise Verdict Note
    Invariant 1 HOLDS in code, UNPROVEN by test S6 (the opening rebuilt from a fresh table view) and S7 (the previous turn's opening prepended) survive the restart test (PA.f69)
    Invariant 3 HOLDS on the actor-call path; FALSIFIED in substance by communicate PA.f61
    Invariant 4 HOLDS to the letter, weak in intent A substring can invert meaning (PA.f67). S1 (a Persona's span checked against the player's prose) survives
    P8.4 FALSIFIED PA.f62–PA.f66. The narrower rule holds: a recorded body is resubmitted once, with no double commit
    P8.5 HOLDS Dispatch is sequential
    Agent never writes a Persona's input FALSIFIED in substance by set_persona_material on an existing subject PA.f61
    table_view never reaches a Persona HOLDS
    No player act without a live principal HOLDS

    M8.1–M8.3 were killed. S1, S2, S4, S6 and S7 survived; S3 and S5 were killed.

  • Soul findings, triaged: PA.f61–PA.f70 are fix. The play.rs semantics fixes (8a-fix) come after library batch 7a, and 8b follows. SourceSpan boundaries (PA.f67) are fixed in their owner, ghostlight-persona-projection.

Cut 9. The Eve play surface; world.speak and the story card deleted

  • Repo/branch: from Cut 8. One commit. Written for PA-Q9's default.
  • Deletes first: SpeakPayload (runtime.rs:225-231), the world.speak arm (:1520-1538), eve.rs:436-455 (story card) and :457-501 (speak control), "world.speak" in operation_schema (:632), current_operator_view's log read for the surface (runtime.rs:1630-1640; operator_log stays in the library), speak_through and tests that drive speech through Eve (the HTTP journey test is rewritten to world.play).
  • Adds: in eve.rs Active arm, the play card as PA-Q9 describes, read from the store's row and the snapshot; surface_version also moves when the turn row changes (the row's own counter), so the SSE path wakes the client.
  • Verification: the_play_surface_shows_only_the_projection (no speech the player did not perceive appears: a Persona speaks elsewhere, the surface omits it); every_operation_the_panel_emits_has_a_schema; negative rg -n 'world\.speak|operator_log' crates/ghostlight-dungeon/src = 0; mutation M9.1 render the operator log into the card: the projection test fails.
  • Operator (the gate, on Raven's machine, local lanes only): create; seed; approve and activate; play the five items; restart mid-session; record seconds per turn and the narration call's share of it.
  • Amended by Soul on Cut 8b (Self, under the standing go, 2026-09-22):
    • PA.f134. The card binds ["text", "answers"], not ["text"]. It renders the open question's id and text, and binds answers from the id it is showing. Without this, ask_player wedges the world: nothing else can tell a client the QuestionId to answer with.
    • The card reads the turn row for the turn's state, the open question, the narration and the refusal line. 8b-fix makes RunOutcome and a small read on the table carry the same, so nothing but the card reads the store.
    • PA.f135, sequencing. The deletion of world.speak and the story card does not land until this cut's own verification shows a turn closing with its narration visible through the new card, and a question answered through the card's own binding. Until then the player has no surface at all.
    • PA.f138. The rewritten HTTP journey test observes the play table, not just an accepted response. A world.play that never reaches the table must fail it.
  • Fork raised by Hands and ruled (Self, under the standing go, 2026-09-22): the play row's counter. The cut asked surface_version to move with "the row's own counter", and the row has none. Hands checked whether one could be derived from existing fields and found that it cannot: a refusal sets turn.refusal after that call's record already exists, so calls.len(), rounds.len(), question.is_some() and narration.is_some() are all unchanged on exactly the write invariant 8 cares most about.
    • PlayTurnStoreState carries a persisted revision: u64, incremented once per committed write in PlayTurnStore::commit.
    • It never resets, not on a new turn and not on a restart. An in-memory counter is refused: it would reset on restart, and an SSE client that reconnects would see the version go backwards and miss a change.
    • PlayTurnView carries it, so eve.rs reads it through current_turn_view() and nothing outside PlayTable touches the row.
    • surface_version moves when either the world's revision or the play row's moves.
    • The store has never been deployed, so no row lacks the field. It stays play-turn-v1, the field is declared with no #[serde(default)], and the canonical re-encode check stays strict (PA.f95).
    • The test is the case that drove the fork: a refusal must move the revision and the surface version even though nothing else about the row changes.
  • The card's projection. WorldSnapshot.facts is pub(crate) so that only table_view renders it, and table_view is the agent's omniscient view. The card shows the world summary's safe fields plus the turn view's narration, question and refusal. No operator log, no facts, no ids.
  • Landed (Hands, Sonnet, 2026-09-22): 322bdf6 (the card and the row's revision) and f1732c0 (the deletion).
    • eve::authenticated_surface takes a PlayTurnView, read in runtime.rs's current_play_view through PlayTable::current_turn_view(). Nothing outside the table touches the row.
    • The card shows the world summary's safe fields plus the view's narration, question and refusal.
    • authenticated_surface_version(snapshot, play) adds the play row's revision to the world's, so it moves when either does. The world-only surface_version still serves mesh, readiness and broadcast.
    • M9.1 (render the operator log into the card) and the revision mutation (increment only when calls grows) were both killed.
    • Deleted: SpeakPayload, the world.speak arm, the story card, the speak control, "world.speak" from operation_schema, and current_operator_view entirely. authenticated_surface lost its operator_log parameter, and world.summary no longer counts committed events, which was the same log read and a leak of its own.
    • Dungeon bin 137 → 139. Net +571/-250.
    • Discrepancy, reported not resolved: the negative grep is 2, not 0. Both hits are tests: the assertion that the world.speak operation id is gone, and the operator_log() call that produces the durable text the_play_surface_shows_only_the_projection proves the card cannot reach. operator_log stays in the library by the cut's own wording. Accepted (Self): obfuscating them to satisfy a literal grep would trade real coverage for a number.
    • Inference to check in the playtest: world.play.answers is a bound control.input.text over a "json" draft carrying the QuestionId, following how lens_weights and jurisdictions encode such a field. No CultUI DSL doc exists in the repo for a hidden bound field, so this is the most literal reading, flagged in the code.
  • Promises: P9.1 Nothing shown is world truth unless committed, except the question and the refusal of the player's own act (invariant 8 with Q13 A).
  • Landed: 322bdf6 and f1732c0; the landing note is above, with the amendment that ruled the card's binding and the deletion's sequencing.

Subtraction ledger

Estimates; Actual is filled from each landing diff.

Cut Removed (est.) Added (est.) Dependencies, formats, targets Actual
1 Dungeon ~800 source, ~1,100 test, eve ~150 ~10 env vars −6
2 0 library ~250 library +reqwest; routing slot +1
3 0 library ~220 + tests consumer.v5 → v6; controller_work.v16 → v17; PATCH_TOOLS 39 → 40 (mint); declare_fact schema +ruled; fixture; FactStanding/FactStandingRef/FactCandidate +1 variant each; Mismatch +1
4 0 library ~150 + tests PATCH_TOOLS 40 → 41; fixture (rides Cut 3's v6/v17)
5 0 library ~120 + tests PATCH_TOOLS 41 → 43; fixture
6 ~40 moved library ~120 + tests none
7 ~120 moved library ~300 + tests InferencePurpose +1
8 0 Dungeon ~600 + tests store play-turn-v1.cc; env +1
9 Dungeon ~150 Dungeon ~120 Eve op world.speak → world.play

What Dungeon stops using, and whether it is deleted from Dungeon:

Surface Other consumer in Dungeon Fate
owner-only "Let X act", world.controller.act none deleted (Cut 1)
cover tick dispatch and the wall-clock tick none deleted (Cut 1)
Active elaboration sweep and its pool none deleted (Cut 1)
Interpreter pass only through run_narrative/run_cell, both unreached unreferenced after Cut 1; library keeps it
per-subject action catalogs (run_operational) none unreferenced after Cut 1; library exposes the catalog again through table.rs
world.speak, story card none after Cut 9 deleted (Cut 9)
ControllerRunner seeding, readiness custody_probe kept
interpreter/operational_agent model env ControllerRunner::open requires all five kept; follow-up PA.f5

The library keeps cover, grouped cells, the controller runner, the Interpreter, the elaborator sweep and lenses.

Findings

  • PA.f1 (pre-existing, operator, recorded): state/map.yaml says every backend shares "one standardized CultNet connector API"; the Body has one in-process trait and a private sidecar pipe. Resolved at 5ff1d54 (Self): the target's premise and the map's sentence were both corrected there, and the map now records the shared API as operator direction with what is true today. A CultNet connector for credentialed third-party endpoints remains the deployment gate's item.

  • PA.f2 (new, medium, recorded): strict tool-argument enforcement on llama-server depends on its build and flags; an argument that fails decode is a tool-result string, never a commit, but a model that loops on it burns rounds. Measure on Raven's machine at the gate.

  • PA.f3 (new, medium, recorded): the play lane on the SDK sidecar. The SDK port runs a tool query inside the sidecar and asks a synchronous oracle, and holds one mutex per query, so a play tool that commits or dispatches (async, and a Persona dispatch is two more inferences through the same port) cannot run inside it. A lent oracle with remaining_rounds() = 1 answering a fixed placeholder might make the SDK return one round inert; it is unproven and off the gate's path (every play lane is local). Projector and Persona are prose-only and need no oracle, so they fall back to the SDK as they are.

  • PA.f4 (introduced by design, low, recorded): a retired subject's commitments, pressures and dependency edges stay, and the clock keeps deriving motion over them.

  • PA.f5 (pre-existing, low, recorded): ControllerRunner::open requires all five models, so Dungeon keeps configuring the Interpreter and operational lanes it no longer runs.

  • PA.f6 (pre-existing, low, recorded): the Projector's stimulus is every row the subject has ever held; it grows without bound over a long session. The recency marker does not bound it.

  • PA.f7 (superseded by PA-Q15's ruling; history): the play authority could neither create quantity nor state a canonical fact. PA-Q15 gave it Ruled facts and Mint; Cut 3 builds them.

  • PA.f9 (introduced by PA-Q15, low, recorded): PATCH_TOOLS has one owner and is every authoring lane's catalog, so the elaborator's and the seed's prompts now list mint and a ruled fact standing, which admission refuses them. The descriptions say "only the play table". A lane that reaches for either earns a RuledWithoutAuthority refusal and one repair round. If the gate's seed runs show it happening, a per-lane exclusion list is the follow-up; a second catalog is not.

  • PA.f10 (introduced by PA-Q15, info): minted quantity has no provenance besides the commit's System(Play) caller. The quantity has the same shape as admitted quantity once held. A consumer contract that must tell loot from evidenced custody (Njordr's outbound batch) reads the commit's caller, or asks for a distinct holding kind. That is the outbound pass's question, not this map's.

  • PA.f11 (introduced by Cut 1, medium, fix): no test pins P1.1 or P1.2. Soul re-added a 30-second submit_clock loop beside maintain_mesh_projection, and a sweep spawned from seed_once, and both passed 46/46. Fix: a source-scan test, on the precedent of soul_no_credential_name_appears_in_the_runtimes_own_source. It bans submit_clock(, .elaborator(, run_narrative(, run_operational(, run_cell( and ElaborationRunner from Dungeon's non-test source. Cut 8's Persona dispatch goes through PersonaLane, which the ban does not name.

  • PA.f12 (introduced by Cut 1, low, fix): the dropped no_proposal_projection_carries_the_canonical_world_commit was the only test of submit_receipt's applied / already_applied kinds and revision (runtime.rs:1124). The fix is a test that submits a principal command twice with one idempotency key and asserts both kinds and the revision.

  • PA.f13 (introduced by Cut 1, low, Cut 8): the route-observation test (runtime.rs:1966-1984) drains controller_permits as a provider barrier. Nothing in Dungeon draws that pool until Cut 8, so for now the barrier guards nothing, and readiness's "active" arm is unreachable. Cut 8 restores the draw and must make this test meaningful again, or delete it.

  • PA.f14 (pre-existing, low, Cut 8): nothing in Dungeon quarantines. ControllerError::requires_quarantine has no reader, and the seed lane has always turned every error into a payload string. The library still refuses CustodyUncertain itself. Cut 8 decides whether a Persona dispatch whose error requires quarantine closes that world's play table.

  • PA.f15 (introduced by Cut 1, medium, fix): stale docs.

    • notes/local-live-smoke.md:3-8, 64, 195 is a runbook for the deleted live smoke. Its command now runs zero tests and exits 0.
    • docs/architecture/ghostlight-dungeon-mvp.md:584-595 (Verification contract).
    • notes/ghostlight-implementation-plan.md:412-427 (ControllerHttpResult::Interrupted, run_cover_tick).
    • docs/architecture/ghostlight-stock-lenses.md:61 (Dungeon's elaboration pool).
    • The handoff line about concurrent elaboration under Dungeon's ceiling.
  • PA.f16 (introduced by Cut 2, high, fix): local_inference.rs:130-133. reqwest::Client::builder() keeps the environment proxy. With HTTP_PROXY set, the whole request went to the proxy, and a proxy URL carrying userinfo added proxy-authorization: a credential, from the environment, sent by Ghostlight. The fix is .no_proxy(), plus a test that sets a proxy variable and asserts that the endpoint is hit directly.

  • PA.f17 (introduced by Cut 2, medium, fix): redirects are followed and the body is re-POSTed; Soul's probe saw a 307 carry the prompt to another port. The fix is redirect::Policy::none(), with a 3xx as a fault, and a test.

  • PA.f18 (introduced by Cut 2, medium, fix): local_inference.rs:77-78. An explicit "tool_calls": null fails to decode and becomes IntegrityViolation, which quarantines the lane. A missing id does the same. The fix is to accept null and absent for both, where the reply's shape allows it.

  • PA.f19 (introduced by Cut 2, low-medium, fix): an empty local or SDK prefix matches every model and silently takes the connector's lanes. Open must refuse an empty prefix. Duplicate call ids in one reply pass the port and the evaluator. Because the port owns the reply's integrity, the port refuses them as integrity_violation. The SDK and connector ports are outside this fix, as recorded under PA.f21.

  • PA.f20 (introduced by Cut 2, low, fix): duplication and placement.

    • local_inference.rs:103-113 copies call_id_is_valid and tool_name_is_valid from sdk_inference.rs:579-590; hoist one copy.
    • Fold two_tool_call_reply into tool_call_reply.
    • The loopback check lives in open_local_port, not in the port's constructor, and the constructor's doc claims the opposite. Move the check into the constructor so it holds by construction.
    • A request with no tools must omit tools and parallel_tool_calls instead of sending an empty array.
  • PA.f21 (introduced by Cut 2, low, recorded):

    • The receipt binds identity, response_id, finish reason and usage, not the returned events. That is the SDK receipt's shape, as specified.
    • provider_request_sha256 hashes the prepared request, not the lowered body that is actually sent.
    • Duplicate call ids are unchecked in the SDK and connector ports.
  • PA.f22 (introduced by Cut 2, low, fix): tests are missing for lowering. Each of the following survived a mutation, and each gets a test that fails under it:

    • the tool declarations sent (S2);
    • the stripped model (S3);
    • the foreign-caller and expiry gates in infer (S4);
    • the receipt digest, which must change with response_id (S6).
  • PA.f23 (introduced by the Cut 2 fix batch, low-medium, fix): local_inference.rs:64-65. The response id tolerates absence (#[serde(default)]) but not an explicit null, which decodes as integrity_violation and quarantines the lane. tool_calls was fixed for exactly this case with null_as_default. The fix is the same treatment for id, with a test and a mutation. It rides the next library Hands pass as its own commit.

  • PA.f24 (pre-existing in Cut 2, low, fix): no test gives the port an IPv6 endpoint. Mutation N1 survived: it accepts every IPv6 address as loopback. The fix is a test that [::1] opens and that a non-loopback IPv6 address such as [2001:db8::1] is refused. It rides with PA.f23.

  • PA.f25 (introduced by the Cut 2 fix batch, low, recorded): the_client_never_honors_an_environment_proxy sets and clears the proxy variables with no drop guard. A panic between them would leak the variable into the rest of the test binary and poison client_build_lock. Nothing between them can panic today.

  • PA.f26 (introduced by Cut 3, low, fix): the genesis lane never runs require_ruler. prepare_creation (lib.rs:1347) and WorldState::genesis (lib.rs:1885-1898) admit the creation patch without it. Soul created a world whose genesis patch held a Ruled fact, and it replayed. Production can't reach this today, because mailbox.rs:160-195 builds genesis from labels only. The fix: call require_ruler with the owner as caller in both places, and add a test that fails under their removal. After the fix, every lane that admits a patch shares the one author rule.

  • PA.f27 (introduced by Cut 3, medium, fix): test gaps in the author rule. Each needs a test:

    • the owner at apply time (MS2 survived: a forged owner PatchAdmitted carrying a Ruled fact);
    • a Mint at op index > 0 (MS3 survived);
    • a patch with two Ruled facts where only the second is checked (MS4 survived every test).
  • PA.f28 (introduced by Cut 3, medium, defaulted and recorded): PlayPort::new(WorldMailbox) and submit_patch are pub, so PlayPort is sealed by convention (one construction site in Dungeon), not by type. Soul raised it for the operator. Self defaulted it under the standing go. This is the recorded rule on proportionate forks: a fork that guards only against the project's own code gets a default and a follow-up. The reasoning:

    • Holding a WorldMailbox already means being the consumer that runs the world.
    • L0's sealing ruling concerns forgeries from untrusted input, and no untrusted input reaches PlayPort. CallerId and SystemCapability are private, and inside src only mailbox.rs builds Play.

    Follow-up: if a second consumer shares a process with Dungeon, PlayPort gets a constructor gated by the consumer.

  • PA.f29 (introduced by Cut 3, low, fix): two promised tests are missing.

    • the_evidence_gate_is_unchanged_for_every_author covers Play only, not the four other callers the spec lists.
    • The external consumer-document Mint refusal is absent from external_admission.rs. Its receipt outcome is pub(crate), so the refusal is tested inside the crate through the document decode path (admit_document), not only through the fixture caller.
  • PA.f30 (introduced by Cuts 4-5, low, fix): three loosening mutations survived. Each needs a test:

    • a consumer-authored Retire, GrantAffordance and RevokeAffordance refused by confine_to_ground (M4.3 and M5.2, loosened to confine only elaborators);
    • the last revoke from a Human-controlled subject refused (M5.1 loosened);
    • a direct pin that expected_caller() is None for Retired (M4.2 loosened).
  • PA.f31 (introduced by Cuts 4-5, high, fix): journal.rs:920 and :924 in verify_state_shape judge every past DecisionEvent against the subject's current controller (Some(event.controller_id) == assignment.id()) and its current grants. As a result, retiring any subject that has acted fails verify_append with CorruptJournal, and so does revoking a verb that has been used. Soul's probe: the reeve exercises threaten, then is retired or has threaten revoked. The clauses held only while assignments and grants never changed.

    • Ruling (Self, under the standing go): an event's authority is judged when it commits, by reduce. After that it is judged by replay (verify_history re-runs reduce) and by the digest chain. The shape check must not re-judge history against present state.
    • Hands first lists every caller of verify_state_shape and confirms that each load path also replays or verifies the chain. It then deletes the anachronistic clauses and keeps the time-invariant ones: the subject exists, and the affordance is a catalog entry.
    • If any load path relies on the shape check without replay, Hands stops and reports.
    • Tests: retire a subject that acted; revoke a used verb; both survive reopen and replay.
  • PA.f32 (introduced by Cut 4, high, fix): required_approvers (lib.rs:4388) reads the live assignment, and human_principal() is None for Retired. So the owner can retire the human's subject in Draft, approve, and activate with no human approval: a consent bypass (F3). Retiring the human after activation fails the journal's approval check (journal.rs:880, F4).

    • Ruling (Self, under the standing go): retiring a Human-controlled subject in Draft is refused with a new Mismatch. A human's approval cannot be removed by removing the human.
    • In Active it is allowed, so the player's subject can die. This is flagged in Self's report so the operator sees it.
    • The approval shape check holds only in Draft. In Active, approvals are frozen history, judged by replay (the PA.f31 principle).
    • Tests: the Draft refusal, and an Active retire of the human that survives replay. Mutation: let the Draft retire through.
  • PA.f33 (introduced by Cut 4, medium, fix): channel reach ignores retirement.

    • audience()'s Reach::Subjects arm (lib.rs:3189) returns members without filtering out retired ones.
    • Communicate resolves a retired speaker (patch.rs:4573-4615).
    • CanReach (action.rs:540) passes for a retired target.
    • Fix: audience derivation excludes retired subjects, a retired Communicate speaker is refused, and CanReach fails for a retired target. Channel declarations may still name retired members, because audience derivation is the one owner.
  • PA.f34 (introduced by Cut 4, low, fix): AcquireKnowledge (patch.rs:4498), Forget (:4548) and InstallIncumbent (:4393-4455) accept a retired subject. Fix: refuse all three for a retired subject. Holdings stay transferable from a retired holder, so a body can be looted.

  • PA.f35 (introduced by Cuts 4-5, low, fix): Soul's mutations that survive the Hands suite:

    • MuA: the resolver's Retire drops positions.remove; killed by a Retire-then-Relocate probe.
    • MuB: RevokeAffordance falls out of confinement.
    • MuC: retired is taken from mode().is_none(), which flags mirrors.
    • MuD: the revoke candidate set is not updated, so three revokes pass the resolver.
    • MuE: retired.insert is dropped.

    Soul's probes in scratchpad/soul_probe.rs kill all but MuE. Fix: add the probes as tests, and add one that kills MuE.

  • Fix batch 3 landed (Hands, Sonnet, 2026-09-22): c8b9aad (PA.f26, genesis gated in prepare_creation and in WorldState::genesis), c97fad2 (the PA.f27, PA.f29 and PA.f30 tests), and 6e7c73b (PA.f23, PA.f24). Lib 513 → 525. No test found a production bug. PA.f30a's loosening (confine only elaborators) cannot be killed from the consumer side: a mirror cannot be granted (GrantsOutsideControl) or retired (RetiresAMirror), and a consumer's ground holds only mirrors. So its refusal is structural twice over, and the test documents this. Soul checks this batch together with the PA.f31–f35 batch.

  • Fix batch 4 landed (Hands, Sonnet, 2026-09-22): fd64595 (PA.f31, PA.f32) and e18aa03 (PA.f33 to PA.f35). Lib 525 → 541.

    • verify_state_shape has two production callers, verify_append and verify_history, and both replay. The anachronistic clauses are deleted.
    • Mismatch::RetiresAnApprover refuses a Draft retire of the human, and apply re-decides it. The approval shape check runs only in Draft.
    • audience()'s Reach::Subjects arm filters out retired subjects. CanReach reads audience(), so it is fixed by the same change.
    • Mismatch::RetiredSubjectActed refuses a retired Communicate speaker, AcquireKnowledge, Forget and InstallIncumbent.
    • A retired holder's holdings still transfer (tested).
    • MuA to MuE are killed by named tests. Soul checks fix batches 3 and 4 together.
  • Soul on fix batches 3 and 4 (Opus, 2026-09-22, against e18aa03, Windows):

    • Verdicts: P4.1, P4.2, P4.3, P5.1, P5.2 and P3.2 HOLD. P3.2 now holds across genesis as well: require_ruler runs in prepare_creation, WorldState::genesis, reduce and apply_effect.
    • PA.f31's premise is confirmed. WorldState is decoded only in recover (followed by verify_history) and in commit (on a row already verified).
    • After an Active retire of the human, the Eve surface shows no Speak control, and a stale world.speak is refused with OpportunityMismatch. Nothing panics.
    • Findings PA.f36 to PA.f40 below. All are fix, in batch 5, after Cut 6 lands.
  • PA.f36 (pre-existing in fix batch 4, low, fix): RetiresAnApprover checks only Key::Existing (patch.rs:3838). A genesis patch that declares a human and retires it is admitted by the resolver. Only the Human if Draft arm in apply (lib.rs:3000) stops it, as an Invariant, after open_owner has left an empty world.cc. Soul disabled that arm and the whole suite passed, with the human never approving. Dungeon can't reach this path; the kernel API can. Fix: the resolver tracks declared humans the way it tracks mirrors, plus a test.

  • PA.f37 (pre-existing since Cut 4, low-medium, fix): a retired subject still acts through the clock and commitments.

    • CreateCommitment admits a retired promisor (patch.rs:3581).
    • The tick (clock.rs:101) never checks retirement. In Soul's probe a retired reeve's routine rolled from due 10 to due 20, and its goal and Bind dependencies kept raising pressure.
    • Ruling (Self, under the standing go): "a retired subject cannot act" covers its commitments. CreateCommitment refuses a retired promisor, and derive_motion skips commitments, goals and dependencies whose subject is retired. Nothing is discharged or deleted; they stay as history. This supersedes PA.f4's "acceptable".
  • PA.f38 (pre-existing since Cut 4, low, fix): delegated_authority (lib.rs:3455) reads a retired institution's grants, so a live office-holder keeps a dissolved institution's authority.

    • Ruling (Self, under the standing go, smallest option): a retired institution lends nothing. Retire does not vacate offices; a retired incumbent is inert, and the play authority can vacate its seat.
    • GrantAuthority, OpenForum and SetController on a retired subject stay accepted as inert holdings.
  • PA.f39 (introduced by fix batch 4, low, fix): test gaps. Each surviving mutation needs a test that fails under it:

    • M2: narrowing the AcquireKnowledge retired check to source != Evidenced.
    • M6: letting is_retired also match ExternallyControlled, which would silently drop mirrors from Reach::Subjects audiences.
    • M9: disabling the Draft-only approval shape clause (journal.rs:885), which is defence in depth.
    • Also add Soul's probe S2: CanReach to a placeless target reachable only through a Reach::Subjects channel.
  • PA.f40 (introduced by fix batch 4, low, fix): the forged "mirror acted" test at consumer.rs:1946-1958 claims the shape check refuses it on a controller-id mismatch, but that clause is gone. It now passes only through the speech-claim clause, so a forged mirror event without speech passes verify_state_shape, while verify_history still refuses it. Fix: route the test through verify_history and rename it for what it pins.

  • Fix batch 5 landed (Hands, Sonnet, 2026-09-22): bc1852d, 33fc9c6 and 662a51f. Lib 549 → 560 (559 pass, 1 ignored).

    • PA.f36: the resolver tracks declared humans alongside mirrors, and a test pins the apply arm directly.
    • PA.f37: a retired promisor is refused, and derive_motion skips a retired subject's commitments and dependencies. A second test was added after a mutation showed the dependency loop was unexercised.
    • PA.f38: delegated_authority skips a retired institution's offices.
    • PA.f39: four tests.
    • PA.f40: the forged-mirror test is renamed and now goes through verify_history (made pub(super), with a test-only commits accessor). Its mutations are caught by the digest-chain backstop rather than the effect-equality clause alone, as Hands reported.
    • Soul checks batches 5 and 6 together.
  • PA.f41 (introduced by Cut 6, medium, fix): turn records the caller's copy of the opportunity. select_one (controllers.rs:1278-1289) matches subject, mode, controller, scope and digest, then returns the caller's copy, and the PersonaTurnBinding is built from it (:4264-4276). Soul's forged opportunity (a new world_id, revision 999999, and empty affordance_ids) produced a turn whose receipt-valid binding carried the forged world and revision. Nothing reaches either prompt. Fix: refuse unless the caller's opportunity equals the snapshot's issued opportunity exactly. The binding is then built from the kernel's copy.

  • PA.f42 (introduced by Cut 6, medium, fix): no test inspects the assembled Projector request. MC (the full typed view put into the Projector guidance) survived. Fix: a test over the request that build_projector_invocation assembles, asserting that no subject, controller or world id, scope or state digest, or revision appears.

  • PA.f43 (introduced by Cut 6, low-medium, fix): three paths are untested:

    • turn refusing an operational subject (MA survived);
    • turn refusing a stale scope digest (ME survived; the code refuses it);
    • narrate's success path, whose request must carry only the player's own slice (P6.3).

    Fix: a test for each.

  • PA.f44 (introduced by Cut 6, medium, fix): the marker is minted_at > last_acted_at, so a row with no minted_at is never new. That covers a fact witnessed through a patch, one seeded into knowledge, and every fact the play agent rules into a subject's knowledge. A Persona would never notice "the door bursts open".

    • Ruling (Self, under the standing go; supersedes PA-Q2's defaulted "no new state"): each knowledge row records the revision at which the subject acquired it (acquired_at, written when the row is written, replay-deterministic). The marker is acquired_at > last_acted_at.
    • It rides consumer.v6, because no v6 store exists anywhere yet.
    • A decline pushes no DecisionEvent and does not move the marker. That is accepted: the agent reads the table, not the marker.
  • PA.f45 (pre-existing, low, settled by wording): the owner's world brief goes verbatim into every Projector and Persona prompt, and Cut 7 puts it in the agent's view.

    • Ruling (Self, under the standing go): the brief is the owner's world-public framing, not a secret channel. Invariant 2 in the target names it as the one input shared by every subject. The owner is responsible for keeping secrets out of it.
  • PA.f46 (introduced by Cut 6, low, fix): duplication.

    • PersonaLane::prepare, infer and select repeat ControllerRunner's versions line for line.
    • The 13-line PersonaTurnBinding construction (:4264) copies run_persona's (:3681).
    • The checkpoint integrity check (:895-913) rebuilds the Persona request outside build_persona_invocation.

    Fix: one copy of each, used by both the runner and the lane. The checkpoint check calls the one builder.

  • Fix batch 6 landed (Hands, Sonnet, 2026-09-22): 14d85da (PA.f41, PA.f46), 3b1f412 (PA.f44) and 55f8bc6 (PA.f42, PA.f43). Lib 560 → 570.

    • PersonaLane::select refuses unless the caller's opportunity equals the kernel's exactly, and the binding is built from the kernel's copy.
    • prepare_inference, run_inference, snapshot_for_selection and record_persona_turn have one body each. The checkpoint check calls persona_inference_request.
    • Knowledge.acquired_at (= state.revision + 1) is written by every knowledge writer: AcquireKnowledge, Communicate fan-out, Display fan-out and Witness. The marker is acquired_at > last_acted_at.
    • AcquireKnowledge's no-op refusal now compares confidence and source only.
    • The Cut 0 stimulus's seeded row now reads new: true, because its subject never acted.
  • Carried into Cut 8: a repeated turn with the same command_id is not idempotent, and PersonaLane has no guard. Cut 8 derives a fresh command_id for every Persona turn from (turn_id, round, call_index).

  • PA.f47 (introduced by Cut 3's view mapping, meeting Cut 7, low-medium, fix): the play agent cannot tell its own rulings from evidenced canon. Cut 3 maps FactStanding::Ruled to FactStandingView::Canonical in snapshot(), so that subjects experience a ruling as world truth (P3.4). But that mapping is shared by every reader, including table_view, whose spec prints ruled.

    • Ruling (Self, under the standing go): FactStandingView gains Ruled, and table_view prints it.
    • Every subject-facing rendering (the Projector, which never reads standing, and the typed view) renders Ruled exactly as Canonical, so P3.4 is unchanged.
    • The evidenced-knowledge clause keeps reading FactStanding itself.
  • Soul on fix batches 5 and 6 (Opus, 2026-09-22, against 55f8bc6, Windows).

    • Verdicts:
      • P4.1: FALSIFIED by PA.f48. The kernel side holds: the clock, commitments and delegated_authority all freeze a retired subject.
      • P6.1: HOLDS.
      • P6.2: HOLDS, with the PA.f49 caveat.
      • P3.2: UNPROVEN, because of PA.f50 and PA.f52.
    • What was checked:
      • Knowledge rows are written only by AcquireKnowledge, Communicate, Display and Witness.
      • acquired_at is covered by the digest, and replay reproduces it.
      • A repeated hearing does not re-stamp a row. A confidence change does, and Soul judges that correct.
      • run_narrative issues byte-identical requests at c4334cc and 55f8bc6.
      • verify_history and commits_for_test cannot be reached from outside the crate.
    • PA.f48–PA.f51 are fix, in library batch 7 after Cut 8a lands. PA.f52 is recorded.
  • PA.f48 (introduced by fix batch 5, medium, fix): PA.f38 fixed only the kernel side. The resolver's candidate_effective_authority (patch.rs:2177) still counts a retired institution's delegated authority. graph_overlaps (patch.rs:2849) and Relocate's restricted-route check (patch.rs:3820) read it.

    • Soul's probe: retire the treasury, then grant the reeve levy. The resolver refuses with OverlappingJurisdiction against the dead lend.
    • A restricted route that relies on a dead institution's authority passes the resolver and dies at apply as Invariant (read, not probed).
    • Fix: the resolver skips retired institutions exactly as delegated_authority does. Tests cover both readers. If the two can share one predicate, they share it; the resolver and the kernel must not have two opinions on who is live.
  • PA.f49 (introduced by fix batch 6, low, fix): rows seeded at genesis get acquired_at = 1. Genesis applies at state.revision = 0 and its commit resolves to revision 0, but the stamp is state.revision + 1 (lib.rs:2748). The doc claims the stamp is the resolving commit's revision. Fix: stamp the revision the write's own commit resolves to on every path, genesis included. Then add a test, and a shape check acquired_at <= revision.

  • PA.f50 (introduced by fix batch 6, medium, fix): no test pins the Persona checkpoint request check that PA.f46 rerouted (controllers.rs:914, prepared_matches_request). Replacing it with true fails nothing. Fix: a test that tampers with a persisted Persona checkpoint's request and requires refusal on resume.

  • PA.f51 (introduced by fix batch 5, low, fix):

    • a_forged_mirror_acted_event_is_refused_by_verify_history still passes with the effect-equality check disabled (journal.rs:985). It recomputes the forged commit's digest but leaves the head's last_commit_digest, so "head state does not equal replayed history" rejects it first. Fix: also point the head at the forged digest, so that only the effect check can refuse it.
    • The stale doc comment at lib.rs:1037 still describes the marker as minted_at. Update it.
  • PA.f52 (pre-existing, low, recorded): ControllerRunner::run_narrative (pub) still accepts a scope-equal opportunity with a forged revision. exact_opportunity accepts any revision up to the current one, and in Soul's probe revision 0 committed with the forged revision in its binding. The runner is off the play path, and its production caller (run_cell) passes kernel-derived opportunities. Revisit with the offline machinery.

    • A Persona turn that goes stale during inference is caught at submit only if the scope digest changed. That is the kernel's rule, and Cut 8 submits through it.
  • PA.f53 (introduced by Cut 7, high, fix): table_view omits ids that PLAY_TOOLS take. It prints no commitment keys (needed by discharge_commitment), no pressure rows, channels, dependencies or persona material. Facts are gathered only from knowledge rows (table.rs:758-776), so a fact nobody holds is invisible. In Soul's probe, the canonical flood fact and the horn channel were absent, which means the agent cannot witness an unheld fact.

    • Fix: the table view prints every entity and row a PLAY_TOOLS tool references: all facts with their standing (PA.f47's Ruled included), commitments with keys, pressures, channels, dependencies, and persona material by subject. WorldSnapshot gains whatever the omniscient view needs, readable only through table_view.
    • The test checks composite fields as well as top-level references, using a fixture world that holds one of every kind.
    • Route access, subject kind and controller mode are rendered in plain words, not Rust Debug.
  • PA.f54 (introduced by Cut 7, high, fix): there is no public way to join decoded calls into one patch. WorldPatch fields are pub(crate), and decode_authoring_call returns a one-item patch. So "declare the cellar, then relocate into it" in one round is impossible, and a fact declared alone is unheld and then vanishes from the table (PA.f53).

    • Ruling (Self, under the standing go): 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. Actor calls and table tools end a run.
    • The library adds decode_authoring_calls(&[(name, arguments)]). It returns the patch and a map from each patch site (declaration index, operation index) back to its call index, so a refusal names the model's own call.
  • PA.f55 (introduced by Cut 7, medium, fix): actor_tools emits record_need and finish_without_proposal, which decode_actor_call refuses, and a prefixed name must be stripped before decoding. Fix:

    • The table's actor vocabulary is the subject's granted entries only. The turn-enders belong to the controller lane.
    • actor_tools and decode_actor_call take the same prefix, so a name actor_tools emits decodes as emitted.
    • One decoder.
  • PA.f56 (introduced by Cut 7, medium, fix): describe_refusal is wrong and thin.

    • EmptyEvidence.position is an evidence-list index, but it is printed as "declaration tool call #N".
    • WrongKind drops the expected and actual kinds.
    • CustodyNotConserved prints no site.
    • The RefName branch is unreachable.
    • Operation indexes are not call indexes.
    • ActionRejected never reads the entry's preconditions, so the player sees "precondition #0 failed", not "you are not at the cellar", as PA-Q13 A describes.
    • TargetKindMismatch prints Rust Debug.

    Fix: correct each one. Render preconditions with the labels of the roles bound by the actor's own invocation. Name call indexes through PA.f54's map. Delete the dead branch.

  • PA.f57 (introduced by Cut 7, medium, fix): one refusal text serves two readers: the agent, and the player through the refusal line (invariant 8). The Display fallback prints principal ids and internal store or journal errors.

    • Ruling (Self, under the standing go): two functions in table.rs.
      • describe_refusal is for the agent and keeps full detail.
      • describe_refusal_to_actor returns only what the actor's own attempt reveals: the failed preconditions over roles the actor bound itself. For every other error it returns one fixed line that names nothing.
    • Cut 8b sets the refusal line from the actor form.
    • A test proves that a precondition over a fact the player does not hold reveals neither the fact's statement nor its id.
  • PA.f58 (introduced by Cut 7, low, fix): decode_actor_call silently drops a non-string display and a text on a non-speech entry. Fix: refuse both. Also, the negative pin greps only controllers.rs for table_view. Fix: scan every library source file except table.rs for a table_view( call. The structural guard is PersonaLane's signature, which takes no text, and the pin's doc names that guard.

  • PA.f59 (introduced by Cut 7, low, fix): tests.

    • authoring_tools_admits_every_known_name is mislabelled as the M7.2 check.
    • The shared-decoder test compares only operations, for one tool. Make it Soul's every-tool parity probe, comparing whole patches.
    • patch.rs's test helpers arguments_for and decodes copy the decode. Route them through the one decoder.
    • Add tests that kill X1–X4 and X6.
  • PA.f60 (found by Cut 8a, medium, fix in 7a): Dungeon cannot read an InferenceFault's disposition. requires_recovery() and integrity_was_violated() are pub(super), so Dungeon cannot apply PA.f14's disposition rules to its own play inference, and 8a retries every fault up to the round budget. Fix: a public read-only disposition() returning the existing three-way value, with no constructor widening.

  • Library fix batch 7a landed (Hands, Sonnet, 2026-09-22): 8de082a (PA.f53, PA.f47), 71a5c81 (PA.f54, PA.f55, PA.f58), 1847974 (PA.f56, PA.f57, PA.f60) and 2035fe8 (PA.f59). Lib 582 → 597.

    • WorldSnapshot gains channels and facts, the omniscient fact list. SubjectSnapshot gains dependencies. table_view prints channels, commitments, pressures, persona material, dependencies and every fact with its standing, in plain words.
    • FactStandingView::Ruled is printed by the table. The typed view still renders it as canonical.
    • decode_authoring_calls returns a DecodedBatch with site_calls. decode_actor_call takes the same prefix as actor_tools, which emits granted entries only.
    • describe_refusal is for the agent; describe_refusal_to_actor is the narrow form.
    • InferenceFault::disposition() is public.
    • The shared-decoder test now compares every tool as whole patches.
    • play.rs changed call shape only (an empty prefix; the agent-facing refusal). 8b switches both.
    • Discrepancy: relocate takes a route, not a place, so the batch test references a draft place from declare_route instead.
  • PA.f61 (introduced by the PA-Q7 list, high, fix): two tools on the ruled PLAY_TOOLS list break ruled invariants in substance.

    • communicate never checks the speaker against the caller (patch.rs:4652-4700) and writes Told { by: speaker }. So the agent, as Play, can make any actor "tell" an invented fact with no ExerciseDecision and no quoted span, which breaks invariants 3 and 4.
    • set_persona_material on an existing subject rewrites that actor's character, which the Projector then renders into its input. That contradicts "the agent never writes a Persona's input" and the no-puppets reason behind invariant 2.
    • Ruling (Self, under the standing go; the operator reviewed it, 2026-09-22: "I saw those calls in your traces, I've been watching. No objections"; this reverses Self's own 2026-09-22 suggestion that attitude be updated through set_persona_material):
      • communicate leaves PLAY_TOOLS. A world event with no speaker is witness. Anything a character says comes from dispatching that character and quoting it.
      • set_persona_material stays, for subjects declared in the same patch only: the agent authors new characters but never rewrites an existing one. The play table enforces this by refusing a call whose subject is not a draft reference.
      • An existing character's feelings follow from what it perceives, which is knowledge the agent may give it through witness and acquire_knowledge. They are not edited by the agent.
    • PA-Q7's list becomes 24 tools.
  • PA.f62 (introduced by Cut 8a, high, fix): a new idempotency key discards an open turn. run resumes only when turn_id equals the stored one, but every Eve invocation carries its own key, so an answer or a "continue" silently starts a fresh turn.

    • Ruling (Self, under the standing go): the turn's identity is separate from the request key.
      • One turn per world is open at a time.
      • A world.play request goes to the open turn: it is the answer when the turn is AwaitingPlayer, and a continue when it is Running. If no turn is open, or the stored turn is Closed, the request starts a new turn, whose turn_id is that request's key.
      • Every applied request key is recorded on its turn, and a replayed key is a no-op that returns the turn's current state. That also covers PA.f63.
  • PA.f63 (introduced by Cut 8a, high, fix): replaying a closed turn's key runs the whole turn again, and its first call is refused as a reused command id. Fix: as PA.f62, a recorded key is a no-op.

  • PA.f64 (introduced by Cut 8a, high, fix): Persona prose never reaches the agent. The dispatch result is "{label} acted", and rebuild_conversation never reads persona_turns, so the agent interprets Personas blind. Fix: the dispatch call's result is each dispatched Persona's prose, labelled by subject. It is recorded, and it is rebuilt into the conversation on every round.

  • PA.f65 (introduced by Cut 8a, high, fix): an empty answer wedges the turn. The empty tool result fails prepare on every attempt, and the turn stays Running forever. Fix:

    • The table refuses an empty or whitespace answer before recording it.
    • An inference that fails prepare or infer beyond its retries closes the turn with the fault recorded, instead of leaving it Running.
  • PA.f66 (introduced by Cut 8a, medium, fix): several loop defects.

    • Exhausting the round budget leaves the turn Running with no narration, permanently, which contradicts the spec. Fix: it closes the turn and narrates.
    • A kernel Invariant leaves the turn Running. Fix: it closes the turn with the fault recorded, and the in-memory refusal stands until restart.
    • dispatched is computed before the round on a first run but from the record on resume, so the same round gives different outcomes. Fix: on both paths, dispatched, as of each call, is derived from the record in call order.
    • dispatch_slot uses list position, not the call index. Fix: derive it from (turn_id, round, call_index, subject).
  • PA.f67 (pre-existing in SourceSpan, medium, fix in its owner): SourceSpan::locate is a raw find, so "I can" is accepted as a span of "I cannot go". Fix in ghostlight-persona-projection: a span must start and end on a word boundary (an edge of the source, or next to a non-alphanumeric character). The Interpreter shares it, so its guarantee tightens too.

  • PA.f68 (introduced by Cut 8a, medium, 8b): PlayTurn carries no world id and the store key is fixed at "primary". Two concurrent run calls interleave writes to the one row. A narration that keeps failing leaves the turn unresolved. narrate draws no permit. Fix in 8b:

    • The row is keyed by world id.
    • One running turn per world, under the mutex.
    • A failing narration closes the turn with the fault recorded.
    • narrate draws a permit.
  • PA.f69 (introduced by Cut 8a, medium, fix): tests. Each survivor gets a test that kills it:

    • S1 (a Persona's span checked against the player's prose);
    • S2 (any subject accepted as dispatched; the undispatched test asserts nothing);
    • S4 (the authoring body not recorded before submit);
    • S6 (the opening rebuilt from a fresh table_view);
    • S7 (the previous turn's opening prepended).

    Also:

    • The restart test must also cover a mid-turn resume, and must prove that nothing from the previous turn's record crosses into the new turn's request.
    • the_turn_closes_with_the_players_narration must assert the narration.
  • PA.f70 (info): Dungeon cannot submit the player's act under Play, because PlayPort has no door for ExerciseDecision. This holds by construction.

  • Kernel fix batch 7b landed (Hands, Sonnet, 2026-09-22; resumed after a workstation reboot): 1d50509 (PA.f48), 7d59e41 (PA.f49), 56020d4 (PA.f50, PA.f51) and 275b0f2 (PA.f67).

    • PA.f48: the resolver's candidate_effective_authority skips retired institutions through the retired set it already threads. That is the same rule as delegated_authority.
    • PA.f49: the apply path takes an explicit resolves_to, so genesis stamps 0. verify_state_shape checks acquired_at <= revision.
    • PA.f67: SourceSpan::locate requires word boundaries at both ends, and searches on to a later valid occurrence. No existing test relied on a mid-word span.
    • Every mutation was killed, including a loosening that fixed only graph_overlaps.
    • Lib 597 → 603. persona-projection 13 → 18.
    • From here on, builds run one at a time on the workstation.
  • Library fix batch 7c landed (Hands, Sonnet, 2026-09-22): dc43be5 (PA.f71, PA.f72), 6d68974 (PA.f73, PA.f74) and e383cde (PA.f75).

    • PA.f71: decode_actor_call refuses a name that doesn't carry a non-empty prefix. The bare-kind fallback is gone.
    • PA.f72: precondition_roles keeps the channel role of CanBroadcast and CanReach through a new audience_role. A roleless precondition gets its own plain wording in describe_refusal_to_actor.
    • PA.f73: describe_refusal has dedicated arms for UnresolvedDraft (handle and expected kind) and for DuplicateHandle (both calls). EmptyEvidence maps to its call through the new DecodedBatch::evidence_calls.
    • PA.f74: table_view prints authority grants, offices and forums, and names the commitment kind. Occupant and known-by labels carry ids. Persona values and memories keep their stored order.
    • PA.f75: section-scoped assertions (view_section, subject_row) and an exact command/index pairing. The library test's tool list is cut to 24 tools (Dungeon's PLAY_TOOLS still holds communicate until 8a-fix; PA.f80), and channel printing has its own assertion. Adds the fact-secrecy test that PA.f57 lacked. The "every other error" test covers all 31 KernelError variants; the finding's count of 33 was wrong.
    • Every named mutation was killed and then restored. Hands also found and fixed a scoping bug in its own subject_row helper before committing.
    • Lib 603 → 618.
  • Soul on library fix batch 7a (Opus, 2026-09-22, against 2035fe8, Windows).

    • Verdicts:
      • P7.1: FALSIFIED (low), by PA.f71.
      • P7.2: HOLDS. A run of calls commits as one patch from outside the crate.
      • Invariant 2: HOLDS. Only table.rs reads WorldSnapshot.facts and .channels, both pub(crate). Rendered subject surfaces carry no unheld fact and no ruled.
      • Invariant 8: HOLDS for describe_refusal_to_actor, across every ActionMismatch and the sampled KernelErrors.
      • PA.f47's split: HOLDS.
    • Other checks that held:
      • Commitment keys parse back exactly.
      • The view is byte-deterministic.
      • A one-call batch equals decode_authoring_call for every tool.
    • PA.f71–PA.f75 are fix, in library batch 7c after 7b. PA.f76 is recorded.
  • PA.f71 (introduced by 7a, low, fix): decode_actor_call falls back to the bare name when the prefix does not match (table.rs:237, strip_prefix(..).unwrap_or(kind)). So a name actor_tools never emits still decodes. Fix: with a non-empty prefix, a name that does not carry it is refused.

  • PA.f72 (introduced by 7a, low, fix): precondition_roles (table.rs:336-348) garbles preconditions that bind no role. NoStanding, NoAudience and an out-of-range index all print "a precondition over role a precondition with no role, which you bound yourself". It also drops the channel role of CanBroadcast { via: Channel } and of CanReach's via. Fix: plain wording for roleless preconditions, the channel role read in both refusal forms, and a test for each.

  • PA.f73 (introduced by 7a, low-medium, fix): the agent-facing describe_refusal loses detail on its generic path (table.rs:592-610).

    • UnresolvedDraft drops the handle and the expected kind.
    • A DuplicateHandle split across two calls names neither call unambiguously.
    • EmptyEvidence is never mapped to a call.

    Fix: each names its handle and kind, and its call through the batch map.

  • PA.f74 (introduced by 7a, low-medium, fix): the table prints no authority grants, offices, forums, authority-kind names or grievance kinds. create_commitment's authorized and has_standing checks need them, and so does a new route's restricted.requires. The commitment kind is still Rust Debug (table.rs:1270), and occupants and "known by" are labels only. Fix:

    • Print authority, offices and forums with their ids and kinds.
    • Print the commitment kind in plain words.
    • Print ids beside labels in the occupant and "known by" lists.
    • Keep persona memories and values in their stored order.
  • PA.f75 (introduced by 7a, medium, fix): tests don't guard the claims they name.

    • In table_view_prints_every_id_the_tools_take, the pressure and dependency checks pass trivially: the id is printed on the Subjects line, and contains("0") always matches.
    • The test's list still holds communicate, so channel printing loses its only check once PA.f61 lands. Pin channels directly.
    • PA.f57's required test is not written: a precondition over an unheld fact must reveal neither its statement nor its id.
    • The "every other error" test covers 3 of 33 KernelError variants.
    • Soul's M3 (a batch site mapped to call 0), M4 (the actor form falling back to Display), M5 (pressures dropped), M6 (dependencies dropped) and M8 (a commitment index off by one) survived. Each gets a test that kills it.
  • PA.f76 (introduced by 7a, low, recorded): PA.f58's "text on a silent entry" refusal sits in the shared decode_catalog_call, so the offline operational and grouped lanes are stricter too. A stray text there is now a recorded need, not a captured proposal. Strictness is correct for every lane, and no pre-v17 checkpoint exists to diverge. display: null is refused.

  • Soul on fix batches 7b and 7c (Opus, 2026-09-22, against a6ceaca, code at e383cde, Windows). Lib 618, persona-projection 18, Dungeon check clean, all as reported.

    • Verdicts:
      • PA.f48, PA.f49, PA.f50, PA.f51: HOLD. Each reader and writer has its own killing test, including the recency-marker boundary.
      • PA.f67: HOLDS as written, and the rule itself is wrong (PA.f78).
      • PA.f71, PA.f72: HOLD. An empty prefix passes names through, which is consistent with actor_tools("").
      • PA.f73: HOLDS structurally. evidence_calls and site_calls fill in one decoder loop, but two of its tests pass trivially (PA.f79).
      • PA.f75: HOLDS. KernelError has 31 variants, all covered. M4, M5, M6 and M8 are killed.
    • Triage:
      • PA.f77, PA.f78, PA.f79, PA.f81 and PA.f82 are fix, in library batch 7d.
      • PA.f80 is corrected in the 7c note above; communicate leaves Dungeon's PLAY_TOOLS in 8a-fix, as already planned.
      • Soul's note that describe_refusal's call naming has no live consumer and that turn.refusal holds the agent-facing text is already queued: 8a-fix switches to decode_authoring_calls, and 8b shows the player only describe_refusal_to_actor.
  • Library fix batch 7d landed (Hands, Sonnet, 2026-09-22): 4fea8fd (PA.f78), 9b91940 (PA.f77, PA.f79, PA.f81) and 3c2bdf3 (PA.f82).

    • PA.f78: SourceSpan::locate accepts a span only on UAX #29 word boundaries, via unicode-segmentation 1.x. That adds one lockfile entry with no transitive dependencies. The hand-written helpers are deleted.
      • Refused: "I can" in "I can't" (either apostrophe), "cafe" in a decomposed "café", "run" in "run_fast", and "Stop." in "Stop.Now" (the crate's own result).
      • Accepted: "我不" in "我不能去", " cannot", and "re" in "re-enter".
    • PA.f77: the flaky test uses subject_row. Eight sequential runs of the table suite passed 41 of 41 each time.
    • PA.f79 and PA.f81: exact-kind, third-declaration and exact-output assertions. Each named mutation is killed.
    • PA.f82: the resolver calls the kernel's is_retired. Forcing it false fails 14 tests on both sides. There is now one computation of the exercise revision.
    • Lib 618 → 620. persona-projection 18 → 26. Dungeon bin 63.
  • Play loop fix 8a-fix landed (Hands, Sonnet, 2026-09-22): a5329a9 (library: WorldPatch::sets_persona_of_existing_subject, PA.f61's seam) and 029970a (Dungeon, everything else in one commit; Hands found the A–F split too entangled to make).

    • PA.f61: communicate is off PLAY_TOOLS, which now has 24 tools matching the library list. set_persona_material on an existing subject is refused through the library predicate.
    • Actor prefix: decode_actor_call owns the strip. Hands also found that round_tools emitted actor tool names without the __ separator, so every real (unscripted) actor call would have been undecodable. It is fixed.
    • PA.f54: a run of authoring calls is one patch under one command id. A refusal is described through the run's DecodedBatch. execute_authoring_call is deleted.
    • PA.f62/f63: PlayTurn.applied_keys. A replayed key is a no-op with no inference.
    • PA.f64: dispatch returns the Persona's prose.
    • PA.f65: an empty answer is refused, and an exhausted inference closes the turn with PlayTurn.fault set.
    • PA.f66: a spent budget closes and narrates, and an Invariant closes with the fault. dispatch_command_id is keyed by call slot and subject. Hands claims (c) was already derived from the record and added only a pinning test.
    • PA.f69: tests for S1, S2, S4 and S7. Hands claims S6 was already covered.
    • Untested: the Invariant close path. There is no legitimate way to force one; the code shares its closing path with tested sites.
    • Dungeon bin 63 → 78, lib 620 → 621.
  • Soul on 7d and 8a-fix (Opus, 2026-09-22, against 80e6649, Windows). persona-projection 26, lib 621 and Dungeon bin 78, all as reported.

    • Held:
      • PA.f61's communicate removal.
      • The actor-prefix round trip; no handle collides with a tool name.
      • PA.f54: a run is one patch, and an actor call splits it.
      • Replay within the current row.
      • PA.f64 prose (source_prose, not structured state).
      • PA.f65.
      • PA.f66a, and PA.f66c (it was already correct; the reverse mutation is killed).
      • S1, S2, S7, and the narration assertion.
      • 7d's UAX #29 behaviour (emoji, ZWJ, flags, 1,000, can't).
      • is_retired as the resolver's one liveness predicate.
    • Survived: S6, S4, communicate re-added, list position hashed at the dispatch call site, and the library predicate cut to .take(1).
    • Triage: PA.f83 to PA.f96 are fix, in 8a-fix2. PA.f91 is recorded. PA.f97 is split between 8a-fix2 and 8b.
  • PA.f83 (8a-fix, high, fix): the turn's identity is still the first request key, and a key is forgotten when its turn's row is replaced. After K1 and then K2, a retry of K1 opens a new turn with turn_id K1, and derived_command_id reproduces K1's old command ids. Probe: a new declare_resource was refused as a reused command id.

    • Ruling (Self, under the standing go):
      • turn_id is minted fresh (UUID v4) when a turn opens. It is never a request key, and command ids derive from it, so they cannot collide across turns.
      • The store keeps a ledger of applied request keys that spans turns: a map from key to turn_id, covering the open turn and the most recent 64 closed turns. A key found in the ledger is a no-op that returns that turn's state.
      • Keys older than that window are forgotten, and the doc says so. At worst a retry that old repeats a player act under fresh command ids. The window keeps the rewrite of the row bounded.
  • PA.f84 (8a-fix, high, fix): a late retry of an older key answers whatever question is open now; nothing ties an answer to its question.

    • Ruling (Self, under the standing go):
      • A turn that is AwaitingPlayer exposes a question_id, derived from (turn_id, round, slot).
      • run takes a request of text plus answers: Option<QuestionId>.
      • While a question is open, a request whose answers matches it answers it. A request with no answers, or a mismatched one, is refused as stale, and nothing is recorded or changed.
      • While the turn is Running, empty text continues it. Non-empty text is refused as "the turn is still running", not silently dropped (PA.f97).
      • An answers sent when no question is open is refused.
      • 8b carries answers through the Eve schema.
  • PA.f85 (8a-fix, high, fix): any deterministic failure while building the request, snapshotting or narrating returns early through ?. That leaves an unrecoverable turn in Running (probe: a non-UUID key). Fix: every failure after a turn opens goes through one close-with-fault helper, which closes the turn with the fault and its detail recorded. The three copied close sites collapse into it (PA.f93). Minting turn_id removes the non-UUID trigger.

  • PA.f86 (8a-fix, medium, fix): S6 survives, because the restart tests assert the stored opening_prompt rather than the request that was sent. Fix: change the world between rounds, then assert the resumed request's opening UserText.

  • PA.f87 (8a-fix, medium, fix): the S4 test compares positions in the source text, and it finds record_call, not persist. Fix: a behavioural test in which the port fails or panics after the body is persisted and before submission; a reopened store must hold the body. Delete the source-order test.

  • PA.f88 (8a-fix, medium, fix): nothing ties Dungeon's PLAY_TOOLS to the pinned list of 24 tools. Fix: the list gets one owner. The library doesn't own Dungeon's policy, so Dungeon's PLAY_TOOLS is the owner and a Dungeon test pins its exact contents. The library test's copy stays as the library's own fixture and says so.

  • PA.f89 (pre-existing, low-medium, fix in its owner): SourceSpan::locate uses match_indices, which skips overlapping candidates, so locate("Juno no no", "no no") returns None. Fix: resume the search one char past each candidate's start. Spans made only of whitespace or punctuation are verbatim quotes and stay accepted (recorded).

  • PA.f90 (8a-fix, medium, fix): a failed dispatch still counts as dispatched, so its actor tools are offered with no Persona prose behind them. Fix: a subject counts as dispatched only when its Persona turn produced prose and that prose was recorded.

  • PA.f91 (8a-fix, medium, recorded): a single run can retire Mara, declare a new subject labelled "Mara", and author its persona material.

    • Ruling (Self, under the standing go): recorded, no fix.
      • The play agent may retire and may create. The operator: "if the agent can invent people, it can invent loot".
      • The new subject is a new character, with a new id and no memory or knowledge. The retirement is in the journal, and nothing was rewritten in place.
      • Deciding that two labels are "the same character" is a language judgment, not a kernel rule.
      • What invariant 2 forbids is an existing Persona's input changing under it. That still can't happen.
  • PA.f92 (8a-fix, low, fix): sets_persona_of_existing_subject has no multi-op test, so .take(1) survives, and Dungeon's check is the only thing enforcing invariant 2. Fix: a library test in which the write to the existing subject comes in a later op.

  • PA.f93 (8a-fix, medium, fix): the close-with-fault handling is copied three times, and errors from snapshot and narrate bypass it. Fix: fold it into PA.f85's single helper.

    • Ruling: no substitutable submit trait is added just to force an Invariant. The path shares the helper that the tested sites use, and this is recorded.
  • PA.f94 (pre-existing, medium, fix): inference retries ignore InferenceFault::disposition(). There are 12 immediate retries, even on IntegrityViolation. Fix: only Retryable retries, with bounded exponential backoff. RecoveryRequired and IntegrityViolation close the turn at once with the fault's detail. Remove the stale library-gap doc and its dangling reference.

  • PA.f95 (8a-fix, low, fix):

    • The budget doc is stale.
    • The #[serde(default)] attributes can never apply.
    • close_turn and begin_turn take unused parameters.

    Fix: delete them.

  • PA.f96 (8a-fix, low, fix): the PA.f66d test calls the hash helper, not the call site. Fix: assert the command id actually submitted for a reordered dispatch list.

  • PA.f97 (pre-existing and 8a-fix, low-medium, split):

    • Actor tool names can reach 86 characters, against a common limit of 64. Fix in 8a-fix2: the handle becomes the first 8 hex digits of the subject id, and a collision within one snapshot is a turn fault. That gives at most 8 + 2 + 48 = 58.
    • Dropped player text is covered by PA.f84.
    • turn.refusal holds agent-facing detail; 8b gives the player only the actor form.
    • Concurrent run calls are handled by 8b's per-world mutex.
  • Play loop fix 8a-fix2 landed (Hands, Sonnet, 2026-09-22): 3d6a58e (library: PA.f89, PA.f92, and a note on the PA.f88 fixture) and 74b628d (Dungeon: PA.f83 to PA.f97 in one commit, one state machine).

    • turn_id is minted per turn. KeyLedger spans the open turn and the last 64 closed turns.
    • PlayRequest { text, answers: Option<QuestionId> }, with stale and running refusals typed.
    • One close_with_fault, and RoundInferError is deleted. Retries follow disposition, with backoff.
    • The handle is 8 hex digits. A collision is a turn fault.
    • Dispatched means "prose recorded". Dungeon's PLAY_TOOLS is pinned exactly.
    • Hands reports two limits:
      • The handle-collision test drives find_handle_collision directly, because SubjectId has no public constructor.
      • PA.f96 compares each Persona's inference request_id across reordered dispatch lists. Dispatch is inference only, so no kernel command id exists to compare.
    • Dungeon bin 78 → 98, lib 621 → 622, persona-projection 26 → 30.
  • Soul on 8a-fix2 (Opus, 2026-09-22, against 935c9d0, Windows). Dungeon bin 98, lib 622 and persona-projection 30, all as reported.

    • Held:
      • S6.
      • The ledger persists, reloads, and evicts at exactly the 65th turn.
      • Stale-answer keys are not recorded.
      • A mismatched answer is refused.
      • The 24 tools are pinned.
      • PA.f90's requirement.
      • .take(1).
      • PA.f96: the Persona's act is keyed by its own actor-call slot.
      • Names stay within 58 characters, and no handle can collide with a tool name.
    • The recurring shape: Hands mutated the helper or the in-memory record, not the rule at the layer it protects. Four persist-before-submit deletions, turn_id = hash(key), answers: None accepted, retry disposition, three close-with-fault sites, and the collision check's wiring all survived.
    • Triage: PA.f98 to PA.f111 are fix, in 8a-fix3. PA.f112 goes to 8b.
  • PA.f98 (8a-fix2, high, fix): dispatch by the id table_view prints dispatches nobody, silently. parse_dispatch now compares against the 8-hex handle, and it drops anything unknown.

    • Ruling (Self, under the standing go):
      • dispatch takes subject ids exactly as table_view prints them. The 8-hex handle exists only as the actor tool prefix.
      • Each actor tool's description names its subject's label and full id.
      • An id that names no dispatchable subject is refused in the tool result, by the text given. Nothing is dropped silently.
  • PA.f99 (8a-fix2 gap, medium, fix): no test shows that a body is persisted before submission. Deleting any of the three persists, or moving one after submit_patch, passes. Fix: for each of the authoring, actor and advance-time paths, a test where the store is reopened at the moment of submission (a port or submit hook that snapshots the on-disk store) and holds the body.

  • PA.f100 (8a-fix2 gap, medium, fix): turn_id = hash(key) survives. Fix: a test that the same key used for two turns yields different turn_ids. The ledger must be cleared between them, through the window or a helper.

  • PA.f101 (8a-fix2 gap, medium, fix): a test that answers: None is refused while a question is open.

  • PA.f102 (8a-fix2 gap, medium, fix): tests that RecoveryRequired makes exactly one inference call, that a persistent Retryable fault exhausts the budget and then closes, and that the budget holds. The production delay function gets a unit test for its bound and cap.

  • PA.f103 (8a-fix2 gap, medium, fix):

    • Test each close-with-fault site: snapshot failure, narrate failure after end_turn, and narrate failure at the budget. Soul's probe was end_turn with a Persona script that yields no narration.
    • Document that store or persist errors cannot be recorded in the turn and propagate.
  • PA.f104 (pre-existing, medium, fix): the first pass and a resume disagree. dispatched is read once per round, so [dispatch(Mara), mara__speak] is refused on the first pass and applied on resume. That breaks PA.f66c's ruling.

    • Fix: an actor call is valid if, and only if, its subject was dispatched, with prose recorded, by an earlier call in call order, including an earlier call in the same round. Both paths use that one derivation.
    • Test the same round on the first pass and on resume.
  • PA.f105 (8a-fix2 gap, low-medium, fix): the collision check's wiring is untested, although Hands called that infeasible. SubjectId is serde(transparent), so a test can deserialize a colliding id into a snapshot. Fix:

    • A wiring test through round_tools.
    • The resume path runs the same check.
  • PA.f106 (8a-fix2, low, fix in its owner): locate is quadratic (32k characters takes 2.2 s). Fix: test the span at each word boundary that UAX #29 already yields (starts_with, with an end-boundary lookup), and drop the repeated find. That is linear in the boundaries times the span length.

  • PA.f107 (8a-fix2 gap, low, fix): a multibyte test (Soul's "xéé éé éé") that kills byte stepping.

  • PA.f108 (pre-existing, low, fix): empty text when no turn is open opens a turn with empty prose. Fix: refused. A new turn needs non-empty text.

  • PA.f109 (8a-fix2, low, fix): ClosedTurnKeys.turn_id is written but never read, and a replay returns a bare Ok(()). Fix: a replay returns a typed Replayed { turn_id }, so the ruling's "returns that turn" is true and the field has its reader.

  • PA.f110 (8a-fix2 gap, low, fix): .rev().take(1) survives. Fix: put the existing-subject write in a middle op, not the last one.

  • PA.f111 (8a-fix2, low, fix): stale docs:

    • PLAY_INSTRUCTIONS says each round is a fresh table_view, which contradicts S6;
    • locate's doc reads as history;
    • some docs refer to removed serde(default) attributes.
  • PA.f112 (8b): the key check and the open run under two separate lock acquisitions. 8b's per-world mutex covers both.

  • Play loop fix 8a-fix3 landed (Hands, Sonnet, 2026-09-22): d28912c (library: PA.f106, PA.f107, PA.f110) and 754c7fe (Dungeon: PA.f98 to PA.f109, PA.f111).

    • dispatch takes printed ids and refuses unknown ones by name.
    • Actor tool descriptions carry the subject's label and id, added in play.rs (annotate_actor_tools).
    • Validity of dispatched follows call order.
    • locate walks the word boundaries, about 4x faster (64k: 38.7 → 9.1 ms).
    • A replay returns RunOutcome with the turn id. Empty text opening a turn is refused.
    • Seam for PA.f99: a #[cfg(test)] before_submit_hook on PlayTable that reads the store's own redb handle. A second open of the file is impossible, because the store takes an exclusive owner lock.
    • Mutations hit production call sites this time, including all six persist mutations. Hands found that the budget test tracked the mutated constant, and pinned a literal instead.
    • Untested, reported: the snapshot-failure close site, and the resume path's collision check. Neither has an injection point.
    • Dungeon bin 98 → 113, lib 622, persona-projection 30 → 32.
  • Soul on 8a-fix3 (Opus, 2026-09-22, against 3b156c0, Windows). Dungeon bin 113, lib 622 and persona-projection 32, all as reported.

    • Held: PA.f99 at all three doors (the hook reads committed state, since the observation flips at each door), PA.f100, PA.f101, PA.f102, PA.f103's narrate site, PA.f104, PA.f106, PA.f107, PA.f108, PA.f110, PA.f111, PA.f61, PA.f54, S1, S2, and the dispatch parser's own strictness (brackets, padding, case, duplicates, the player's own id, and a subject with no live opportunity).
    • Triage: PA.f113 to PA.f124 are fix, in 8a-fix4.
  • PA.f113 (8a-fix3, high, fix): nothing ties dispatch to what table_view prints. The id spelling has four copies, because the library's id_text is pub(super). Changing what table_view prints passes all 735 tests while every dispatch an agent could make is refused.

    • Ruling (Self, under the standing go): the library owns the id spelling. table.rs exposes the one printer and its parser publicly, table_view prints through it, and Dungeon's full_id_text and the test copy are deleted. The dispatch test reads the id out of a real table_view (bracketed_id_after, which the file already has).
  • PA.f114 (8a-fix3, medium, fix): parse_dispatch drops non-string entries, a malformed body and a missing subjects key in silence, which is what PLAY_INSTRUCTIONS promises it will not do. Fix: each is refused, quoting what was given.

  • PA.f115 (pre-existing, medium, fix): deleting span_is_exact's actor.subject filter passes everything, so one Persona could speak another's prose verbatim. That is invariant 4. Fix: a test with two dispatched Personas.

  • PA.f116 (8a-fix3, medium, fix): PA.f109's assertion passes trivially, because the ledger holds one archived entry. Fix: a third closed turn, so the matched entry is not the front one.

  • PA.f117 (8a-fix3, low-medium, fix): KeyLedger::contains lost its production caller and survives only through the eviction test. Fix: delete it and test eviction through turn_id_for. Also narrow #[allow(dead_code)] on mod play in main.rs so it stops hiding the whole module; 8b wires the module and removes it.

  • PA.f118 (8a-fix3, low-medium, fix): the resume path's collision check duplicates round_tools' check verbatim, which is two owners for one rule, and deleting it changes nothing. Fix: one helper called at both sites, reached by a test through PA.f119's trigger.

  • PA.f119 (8a-fix3, low, fix): the in-code claim that a snapshot failure cannot be forced is false. play_world discards WorldMailbox's JoinHandle as _owner; keeping it and calling .abort() makes snapshot() fail deterministically. Fix: keep the handle in the fixture, test both snapshot-failure sites, and delete the claim.

  • PA.f120 (8a-fix3, low, fix in its owner): locate is O(boundaries x quote), not linear: 528k against a 264k quote takes 1.73 s in debug. The new guard uses a single-word source, which has two boundaries, so it guards nothing.

    • Fix:
      • Compare the first byte before starts_with.
      • Keep the boundaries in one sorted Vec and binary-search the end, instead of allocating a HashSet per call.
      • The guard test uses a many-boundary source with a long quote.
      • The doc states the real bound and drops the stale reference to the "repeated-find shape below".
    • The bound is accepted: a quote is a span of the prose that was just produced.
  • PA.f121 (pre-existing, low, fix): two locate rules have no test. Reversing the boundary walk returns the last candidate rather than the leftmost, and removing the empty-quote guard locates 0..0.

  • PA.f122 (8a-fix3, low, fix): skip_retry_delay_in_test is a cfg switch, so production's retry_delay path is never the tested one. Fix: the delay becomes an ordinary field with a production default that tests set to zero, so both run the same code. before_submit_hook stays #[cfg(test)], with a comment saying why.

  • PA.f123 (pre-existing, low, fix): execute_dispatch is the only call kind that does not persist at its own site, so a crash re-infers Persona prose, and an actor call already checked against the old prose would be checked against new prose on resume. Fix: persist the recorded prose as soon as a Persona turn produces it. PersonaLane::turn writes nothing, so PA.f99's three submit doors stay the right set.

  • PA.f124 (8a-fix3, very low, fix): execute_dispatch's summaries print {subject:?}, a fifth id spelling. Fix: print through the library's printer.

  • Play loop fix 8a-fix4 landed (Hands, Sonnet, 2026-09-22): 340d583 and 7bede2f (PA.f113, PA.f114), 5af2a18 (PA.f115 to PA.f117), d042dc7 (PA.f118, PA.f119), a395210 (PA.f122), 91e80d3 (PA.f123), 5b9671b (PA.f124) and 9a2cb2e (PA.f120, PA.f121).

    • The library owns the id printer and parser. Dungeon's copies are gone, and the dispatch test reads its id out of a real table_view.
    • A malformed dispatch body, a missing key and a non-string entry are each refused by name.
    • A Persona cannot speak another Persona's quote.
    • The ledger test uses three closed turns.
    • One collision helper, called at both sites. Both snapshot-failure sites are tested by aborting the mailbox owner.
    • The retry delay is an ordinary field, so tests run the production path.
    • execute_dispatch persists its own recorded Persona turn.
    • locate walks boundaries with a first-byte check and a binary search: 528k against a 264k quote went from 0.31 s to 0.13 s in a debug build. Hands could not reproduce Soul's 1.73 s and says so.
    • main.rs's blanket #[allow(dead_code)] is gone. Three orphaned test helpers were deleted. PlayError::NoPlayerOpportunity has no producer until 8b.
    • Reported discrepancies:
      • The resume path's collision check has only a source-presence test, because execute_round takes no caller-supplied snapshot.
      • Hands' first PA.f123 test passed with the fix deleted; it fired the hook at the dispatch site instead.
    • Dungeon bin 113 → 122, lib 622, persona-projection 32 → 35. The retry-budget test now takes about 36 s of real time.
  • Soul on 8a-fix4 (Opus, 2026-09-22, against 647917d, Windows). Dungeon bin 122, lib 622 and persona-projection 35, all as reported.

    • Held: PA.f113 (id_text_matches is the printer's literal inverse, and the dispatch test reads a real render), PA.f114 through execute_dispatch, PA.f115 in all three directions, PA.f118's two snapshot sites, PA.f122, PA.f124, and PA.f120/f121 against an independent oracle over 13 sources x 29 quotes. No earlier ruling regressed.
    • The 36 s retry test was my misreading: that figure was the mutant's time in Hands' report. The test sets the base to zero and runs in 0.40 s, and the whole binary in 6 s. The shape is already the one I was going to ask for.
    • The pass added about 1,000 lines, of which 61 are production code.
    • Verdict: not ready for 8b. PA.f125 blocks.
    • Triage: PA.f125, PA.f126, PA.f128 to PA.f133 are fix, in 8a-fix5. PA.f127 is folded into 8b's round entry.
  • PA.f125 (8a-fix4, high, fix, blocker): PA.f123's persist made a worse window. execute_dispatch persists at play.rs:1520, but the Dispatch call record is written afterwards by execute_round. A crash in between leaves persona_turns durable with no dispatch record. On resume the dispatch re-runs, the "already acted" guard fires, and the prose is lost, which breaks PA.f64. dispatched_subjects then offers that Persona's actor tools while span_is_exact checks against prose the agent never saw, so every speak for it is refused for the rest of the turn. Fix: the Dispatch body is recorded before execute_dispatch runs, or the partial result is recorded inside it, and then persisted.

  • PA.f126 (8a-fix4, medium, fix): PA.f123's test doesn't guard it. Deleting both the persist and the hook fire leaves the suite green, because execute_actor_call's own persist writes persona_turns anyway. Fix: assert that the resumed tool result still carries the prose.

  • PA.f127 (pre-existing, medium, fold into 8b): the resume path's collision rule has no behavioural guard. collision_refusal(..).filter(|_| false) keeps the suite green, because the source-presence test guards a string. Ruling (Self, under the standing go): 8b gives round entry one round_snapshot() owner that both infer_round and execute_round call. That collapses the two sites and deletes the grep test.

  • PA.f128 (8a-fix4, low, fix): the ledger test rules out front() but not back(): at the moment K2 is retried it sits at the back. Only a second test catches that. Fix: close a further turn, so the matched entry is neither end.

  • PA.f129 (pre-existing, low, fix): handle_for still carries its own paren strip, so PA.f113's "no copies remain" is not true. Fix: derive the handle from id_text.

  • PA.f130 (8a-fix4, low, fix): PlayError::NoPlayerOpportunity has no producer and is kept alive by a narrow allow. Fix: delete the variant, or raise it where it belongs.

  • PA.f131 (8a-fix4, trivial, fix): the allow on WorldFixture::owner is unnecessary.

  • PA.f132 (8a-fix4, low, fix): locate's "real bound" prose overstates the first-byte check, which is a constant factor. The guard test's quote shares no first byte with the source, so starts_with never runs. Fix: correct the prose, and use a shared-prefix quote in the guard. Soul measured 0.18 s for the guard shape and 0.50 s for a shared-prefix shape, with no blow-up at prose sizes.

  • PA.f133 (8a-fix4, low, recorded then folded): "only a source-presence test is possible" describes a choice, not a constraint. PA.f127's round_snapshot() owner is the answer.

  • Play loop fix 8a-fix5 landed (Hands, Sonnet, 2026-09-22): 43987dc.

    • PA.f125: the Dispatch body is recorded and persisted in execute_round before execute_dispatch runs, which is the order every other door already uses. A new PlayTurn::dispatch_owner(subject) names the earliest call whose body claims a subject, so a resumed call knows its own work from another call's, and a same-call duplicate mention is still refused.
    • PA.f126: the replacement test resumes a hand-built crashed turn and asserts the tool result still carries the prose. All three mutations, including the honest revert, fail it.
    • PA.f128 to PA.f132 are done. PlayError::NoPlayerOpportunity is deleted; Hands found no site that should raise it and would not change begin_turn's behaviour unasked.
    • PA.f132: the guard test now uses a shared-prefix quote, which reaches starts_with, at about 0.44 s.
    • Dungeon bin 122, persona-projection 35. Net +291/-98.
  • Cut 8b landed (Hands, Sonnet, 2026-09-22): 1b20a0f (play.rs: the round_snapshot() owner, the actor-form refusal line, concurrent dispatch, run_lock) and a99fdfe (runtime.rs, eve.rs: AppState, GHOSTLIGHT_PLAY_MODEL, world.play routing, ghostlight.world_play.v0).

    • PA.f127 and PA.f133 are closed: one round_snapshot() runs the collision check once, reached behaviourally through a #[cfg(test)] force_round_snapshot seam, and the source-presence test is deleted.
    • turn.refusal is describe_refusal_to_actor against the real affordance snapshot. Its test asserts no id or label leaks (invariant 8).
    • Dispatch runs concurrently under controller_permits, and close_turn's narration draws a permit too.
    • Hands added claimed_this_call dedup, because concurrency broke the old persona_turns-only check.
    • PA.f13: the permit barrier stays, and now guards a boundary that can really be busy. Readiness's "active" arm is reachable again.
    • Two discrepancies, both accepted (Self, under the standing go):
      • The per-world lock lives in PlayTable (run_lock), not in AppState. This daemon runs one world per process, and a second lock over the same decision would be a second owner.
      • STORE_KEY stays "primary", because each PlayTable owns its own store file under that world's service root, so the row is already per world. PA.f68's intent holds; Soul checks the path.
    • Dungeon bin 122 → 128. PlayPort::new still has one production site.
  • Soul on 8a-fix5 and Cut 8b (Opus, 2026-09-22, against e35454c, Windows). Dungeon bin 128 and lib 622, as reported.

    • Held: PA.f125 and PA.f126 (including a crash mid-dispatch with several subjects, and a same-call duplicate), round_snapshot as one owner, the refusal line (describe_refusal_to_actor takes no snapshot, no invocation and no fact, so it cannot structurally leak), no deadlock and no permit starvation (no permit is ever held while waiting for a lock; the order is always run_lock then store), two concurrent world.play calls with one key, and no credential on the play path. No earlier ruling regressed.
    • Soul's own probe proved a two-subject concurrent dispatch works and keeps JSON order. No test in the repo dispatches more than one subject; 8b-fix adds one.
    • Both accepted discrepancies are correct, but rest on structure rather than tests: one open_play call site, CultCache's single-owner lock, and one runtime root per process.
    • Verdict: not ready for Cut 9 as specified. PA.f134 blocks, and PA.f135, PA.f137 and PA.f138 shape the cut.
    • Triage: PA.f136 to PA.f147 are fix, in 8b-fix. PA.f134 and PA.f135 change Cut 9's spec, below.
  • PA.f134 (Cut 8b, critical, changes Cut 9): ask_player wedges the world. Answering needs QuestionId { turn_id, round, slot }, but turn_id is minted inside begin_turn and emitted nowhere: RunOutcome carries it only on Replayed, and the route returns accepted and drops the outcome. So no client can build answers, every later request is StaleAnswer, and no new turn can open. Recovery today means deleting the store file.

    • Ruling (Self, under the standing go):
      • The turn row is the player's surface. Cut 9's card projects the turn's state, the open question with its id and text, the narration, and the refusal line.
      • PA-Q9's bindings: ["text"] becomes ["text", "answers"], and the card binds answers from the question id it is showing.
      • RunOutcome names the open question on every path, so the route and any other consumer can see it without reading the store.
      • Cut 9's verification includes answering a question through the surface the card actually renders.
  • PA.f135 (Cut 8b, high, sequencing for Cut 9): nothing player-visible exists yet. Narration, question and refusal live only in the play store, and PersonaLane::narrate commits nothing.

    • Ruling: Cut 9 does not delete world.speak and the story card until its own verification shows a turn closing with its narration visible through the new card.
  • PA.f136 (8a-fix5, medium, fix): dispatch is the only door that does not resubmit its recorded body. parse_dispatch re-runs on every pass, and the recorded body only decides whether to record. If the world changes so a subject no longer resolves (a retirement), a resumed call runs a shorter list, and that Persona's prose and tools vanish for the rest of the turn while its turn stays in persona_turns. Fix: the resume reads the recorded body, as the other three doors do.

  • PA.f137 (Cut 8b, medium, fix): the seam test calls table.round_snapshot() directly, so execute_round and infer_round calling fetch_round_snapshot instead both survive. The deleted source-presence test was the only pin. Fix: pin both call sites again, by behaviour if reachable, otherwise by the source test.

  • PA.f138 (Cut 8b, medium, fix): the routing test asserts only accepted, so a spawned task that never calls table.run passes. Fix: observe the table, for example the turn row afterwards.

  • PA.f139 (8a-fix5, low-medium, fix): dispatch_owner's "earliest call wins" is untested; .max() survives. Fix: two calls in one round naming one subject, resumed.

  • PA.f140 (Cut 8b, low, fix): service/play-turn-v1.cc is missing from the admitted state layout that proves each file is a direct, single-linked regular file. Fix: add it and its lock.

  • PA.f141 (Cut 8b, low, fix as doc): controller_permits now has only one consumer, the play table, because Cut 1 deleted Dungeon's own drivers. Persona dispatch is the controller work now, so readiness's "active" is accurate. Fix: say so where the field and the readiness arm are defined.

  • PA.f142 (Cut 8b, low, fix): every world.play spawns a task that may park on run_lock for a whole turn before being refused, with no bound and no signal. Fix: the route takes the lock without waiting; a busy turn is refused at once with TurnStillRunning.

  • PA.f143 (Cut 8b, low, fix): concurrent dispatch ordering is deterministic by reading, but reversing the zip survives. Fix: a test with several subjects that pins both the summary order and persona_turns.

  • PA.f144 (pre-existing, low, fix): turn.refusal is never cleared, so a refusal in round 1 is still shown after a later act succeeds. Fix: the line belongs to the most recent player act: cleared when a player act commits, and when a turn opens.

  • PA.f145 and PA.f146 (Cut 8b, low, fix): play.rs's header still says nothing is reachable from runtime.rs, and "advertised" overstates the Eve change: world.play is in the validation table only, with no descriptor or button until Cut 9. Fix: correct both.

  • PA.f147 (Cut 8b, low, fix): the route takes idempotency_key…unwrap_or_default(). A caller that skipped validation would get key "", recorded in applied_keys, making every later keyless play a silent no-op. Fix: refuse a missing key.

  • Fix batch 8b-fix landed (Hands, Sonnet, 2026-09-22): 22eb918.

    • PA.f134's half: RunOutcome::Ran and Replayed carry the turn id and the open question, and so do EmptyAnswer and StaleAnswer. The new PlayTable::current_turn_view() is the door Cut 9's card reads, since the route answers before run resolves. It is dead code in the non-test binary until Cut 9, as expected.
    • PA.f136: dispatch resumes from its recorded body. PA.f144: a committed player act clears the refusal line. PA.f142: the route takes the lock without waiting.
    • PA.f137, PA.f138, PA.f139, PA.f140, PA.f143 and the doc fixes are done. The layout test for the play store is Linux-gated, so it does not run on this workstation.
    • PA.f147 is closed, not fixed. Hands implemented the guard, then mutated it and found it unreachable: execute_world parses idempotency_key.unwrap_or("") as a CommandId before any operation arm, so a missing key is already refused. The local guard was reverted as dead, and the regression test now pins the real one. This is what the brief asks for: the mutation, not the diff, decides.
    • Dungeon bin 128 → 137.
  • Soul on 8b-fix and Cut 9 (Opus, 2026-09-22, against 50cde3f, Windows). Dungeon bin 139 and lib 622, as reported.

    • Held: PA.f147's closure (verified independently, mutation and all), PA.f144's two writers, the row's one write door and its unconditional increment, the revision surviving a restart (2 → 2 → 3), PA.f142's try_lock, PA.f138, the play card's own subtree, and the deletion's completeness.
    • Verdict: not ready for the playtest. Soul drove the vendored browser lowering over the real surface document and found the loop stops at "create a world".
    • Triage: PA.f148 to PA.f160 are Cut 10, below.
  • PA.f148 (Cut 9, blocker): eve.rs:78-83 folds the play row's revision into the surface document's version, and runtime.rs:1187 reads that number back as sourceVersion and derives expected_revision = source_version - 1 for the kernel's compare-and-swap. One play commit and every later world command is denied for ever ("expected revision 3, current revision 2"). Four things claim to be the surface version and Cut 9 moved one of them. It escaped because the journey test counts sourceVersion itself instead of reading the served document: the same test-input defect again, on the exact value the cut changed.

  • PA.f149 (pre-existing, blocker): no bound command can round-trip from the real client. Soul drove @gamecult/eve-browser-lowering (pin 672c0c1) over what authenticated_surface emits and got {"bindings":{}} for world.play and for world.create.

    • local_draft emits {scope, key, type}, which EveStateBindingDescriptor does not have, so the binding resolves to the name "world.play.answers" while captureBindings asks for "answers".
    • Even on a match, the client sends payload.bindings = {…}, and Dungeon's payloads are flat deny_unknown_fields structs. Nothing unwraps it.
  • PA.f150 (Cut 9, blocker): the fold does not wake the SSE path. publish_projection sends the unfolded world version and is called only at startup and from the eve_command Ok arm, so a play-row-only change (a question, a narration, a refusal) notifies nobody. The browser polls at 0 ms. The DM asks; the client never hears.

  • PA.f151 (Cut 9, high, invariant 8): hidden: true is not a prop the renderer supports, so the question id renders as a visible, editable text box labelled "Question id" holding a UUID. It leaks an id to the player, and once input works a player could forge one.

  • PA.f152 (pre-existing, medium): world.summary still renders the phase, revision, minute and a count of subjects to any authenticated account. Cut 9 removed the event count from this same card as a leak; this is the same reasoning left half-applied.

  • PA.f153 (Cut 8b, high): the spawned task discards run's result and swallows every PlayError into a log, after the route has already answered accepted. The errors PA.f134 taught to carry the open question have no caller. EmptyOpening, StaleAnswer, NoQuestionOpen, TurnStillRunning and Poisoned all look like success to the player.

  • PA.f154 (8b-fix, medium): an answer arriving while run still holds the lock between breaking its loop and returning gets TurnStillRunning, and PA.f153 then discards it. try_lock is right; the silent discard is what makes it dangerous.

  • PA.f155 to PA.f158 (Cut 9, low): no test pins the revision surviving a restart (the claim is true, proven by probe); M9.1 is no longer a live mutation, since the parameter it needed is gone; stale doc references to the deleted speech path in runtime.rs:170, :1145, play.rs:2918 and the target doc; PlayTurnView::turn_id and state have no production reader.

  • PA.f159 (Cut 9, medium, plausible): the play card and its controls are emitted for any authenticated account, unlike the owner-gated world.advance_time and world.seed, and begin_turn resolves the player subject from the snapshot rather than the principal. Two accounts would drive one player.

  • PA.f160 (Cut 9, low): the additive fold is monotone only while a play view exists; losing it drops the version by the whole accumulated revision. Subsumed by PA.f148's fix.

  • Soul on Cut 10 (Opus, 2026-09-23, against 0c09324, Windows).

    • Held: the submodule pin is unmoved; the bridge imports the vendored lowering's own dist and re-spells nothing; the round trip posts through api_router; the binding-shape and envelope fixes are both killed by their mutations; PA.f148 holds for world commands; PA.f151 holds in a live surface dump (no question id, no answers control); PA.f152 and PA.f159 hold against a second account ("only the world's owner may play"); admission is genuinely synchronous.
    • Verdict: not ready for the playtest. PA.f161 and PA.f162 block.
    • Triage: PA.f161 to PA.f168 are Cut 11.
  • PA.f161 (Cut 10, high, blocker): the fold moved out of the document and into its consumer. runtime.rs:1233 compares the client's echoed sourceVersion against authenticated_wake_version, which is surface_version + play.revision. Both the vendored lowering and web/src/main.ts echo document.version alone, and any row that has ever held a question has revision >= 1, so every answer is refused as stale, permanently. authenticated_wake_version's own doc says it is never a value a client feeds back. PA.f84's safety survives (it over-refuses), its usability does not.

    • Ruling (Self, under the standing go): the play row records the world's surface_version at the moment its question opened. An answer is stale when the client's sourceVersion is older than that, and that is the only comparison. The question's identity stays in the row and is resolved server-side, so PA.f84 keeps its guarantee: an answer cannot land on a question the player never saw. playRevision either gains this reader or goes (PA.f165).
  • PA.f162 (Cut 10, high, blocker): the round-trip test shells out to node and needs jsdom and ajv, which nothing in Ghostlight declares or installs. On a clean checkout it fails. Idunn's Linux gate runs cargo test --locked in a pinned container that installs no vendored npm packages and may carry no node, so the release gate fails before it builds. Hands' 145 was measured against node_modules left in the main tree by its own debugging.

    • Ruling (Self, under the standing go):
      • The bridge's captured intents are committed as a fixture, stamped with the submodule revision that produced them. The Rust test replays that fixture through api_router, so the gate needs no node.
      • A second test regenerates the fixture with the bridge and fails if it differs from what the current pin produces. It runs only when the bridge is available, and it says plainly when it is skipped; a silently skipped job is a scar this pipeline already carries.
      • The gamecult-ops runbook gets that dev job. The fixture without the regeneration check would be a copy of the client's behaviour pretending to be the client.
  • PA.f163 (Cut 10, medium, fix): PA.f150's test is vacuous. Deleting the spawned task's publish_projection leaves the whole suite green, because this same cut made admission synchronous, so dispatch_world's own Ok arm already published. The test asserts only that recv() returned. The fix is still needed: the question, narration and refusal all land after the response. Fix: assert the wake that follows the turn's own commit, not the one the response already caused.

  • PA.f164 (Cut 10, medium, fix): a world.create form filled as labelled is refused with "missing field targets". lens_weights carries an authored value, which the lowering captures; targets and jurisdictions carry only placeholder, which it never captures. Fix: seed "[]" and "{}" as authored values, and check every other control for the same gap.

  • PA.f165 (Cut 10, low, fix): playRevision has no reader outside tests. It becomes PA.f161's reader or it goes.

  • PA.f166, PA.f167, PA.f168 (Cut 10, low, fix): play.rs:681 still describes the fold Cut 10 undid; local_draft's doc calls accessMode inert when the lowering disables a control without it; unwrap_bindings discards flat siblings instead of refusing them.

  • Also for Cut 11: several new doc comments are scar tours — narrating the previous wrong machine before stating the rule (eve.rs:215, eve.rs:689, runtime.rs:1416). AGENTS.md forbids that, and in PA.f161's case the narration is where the false claim lived.

  • Unverified leg, for the playtest: seeding is still gated on GHOSTLIGHT_SEED_VAULT_ROOT. The round trip proves the payload reaches the vault lookup, never that a seed succeeds. Nobody has run that end to end.

  • Judgement on deserialize_json_capture: kept, narrowly. It is the smallest server-side accommodation for a lowering with no JSON control, but it loosens the payload for every caller, including CultMesh, to buy something only the browser needed. It must not become the reason structured controls never arrive.

  • Cut 11 landed (Hands, Sonnet, 2026-09-23): 4155823 in Ghostlight, and 42a477d in gamecult-ops (the runbook's dev job).

    • PA.f161: OpenQuestion carries surface_version, set from eve::surface_version when the question opens and cleared when it is answered. The route compares only against that. ScriptedPort and its helpers widened to pub(crate) for tests, as authorised.
    • PA.f162: crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json holds the real intents, stamped with the vendor revision 672c0c1e9fc8…. The gate test replays it with no node. The regeneration test diffs live against committed (ignoring the key and the timestamp), checks the stamp against git -C vendor/eve rev-parse HEAD, and prints SKIPPED with its reason when the bridge is absent.
    • Hands proved the clean-checkout claim: it renamed every node_modules in the tree, stripped node from PATH, and ran the compiled binary. The gate test passes; the regeneration test skips aloud.
    • PA.f163 to PA.f168 are done. playRevision is deleted: no production or client reader existed. The scar-tour comments are cut.
    • Mutations, all killed: both directions of the staleness comparison, the spawned publish, the authored values, the sibling refusal, and two mutations of the fixture itself.
    • Discrepancy, reported: PA.f167's accessMode fix has no test. The bridge sets values through jsdom and dispatches events directly, so it bypasses the disabled attribute a real browser would render. Hands said so rather than faking the coverage.
    • Dungeon bin 145 → 151. Net: one fixture, six tests, one field on a never-deployed row, one dead wire field removed.
  • Soul on Cut 11 (Opus, 2026-09-23, against d8b8e86, Windows).

    • Held: PA.f161's accept path end to end through the route (the test takes its version from the route's own response); PA.f163; PA.f164, with every other advertised binding checked; PA.f162's drift check, killed two ways; PA.f165 verified independently; PA.f168's six call sites; and the test-visibility widening, which production cannot reach. 151 and 622 green.
    • Verdict: not ready. PA.f169 blocks the release, PA.f172 blocks Raven's own machine, and PA.f170 is a design fork for Self.
    • Triage: PA.f169, PA.f171 to PA.f175 are Cut 12. PA.f170 needs facts about the lowering first.
  • PA.f169 (Cut 11, high, blocker): client_intents (runtime.rs:2472) has three callers and only one is behind eve_client_bridge_is_available(). With node off the PATH the suite is 149 passed, 2 failed, and the same without the submodule. The gamecult-ops runbook claims the bridge proof needs no separate gate step in containers that "may carry no node at all", which is false as written. Fix: the same skip guard on both, and correct the runbook.

  • PA.f170 (Cut 11, medium-high, fork): PA.f84's guarantee is not restored. Asking a question commits nothing, so two questions in one turn carry the same surface_version, and Cut 10 took the question id off the wire. Soul's probe composed an answer for Q1 and it resolved Q2: accepted, silently. Before Cut 11 the guarantee held vacuously, because everything was refused; now it is a wrong accept. One tab answering each question once never sees it; a reload, a second tab or a retried POST does.

    • The only counter that discriminates is the play row's revision, which Cut 11 deleted from the wire, and the vendored lowering echoes exactly one number: document.version. So the fix depends on what the lowering can carry. A probe answers that before Self rules.
  • PA.f170's probe and ruling (Imagination, Sonnet, then Self, under the standing go, 2026-09-23). The probe drove the real lowering at the pinned revision and established:

    • A binding with no matching node anywhere is not captured: findAuthoredBindingValue walks the live tree.
    • A node whose renderer ignores props.value can carry an authored value that is captured and never appears in the DOM. No hidden trick is needed, and the lowering supports none.
    • Simpler still: a button's own props.action object is spread verbatim into the payload, so any field the server puts there rides back with no binding machinery at all.
    • routeHint is hardcoded to sourceVersion and transport; nothing else passes through.
    • web/src/main.ts is ours and only hand-builds one intent (the auth completion), so changing the shipped client is possible but is a different kind of change from shaping the served document.
    • Whatever rides back is still blocked by PlayPayload's deny_unknown_fields and by unwrap_bindings refusing a mixed envelope. The wire allows it; the Rust schema is a second gate.
    • It is DOM-invisible, not network-invisible: the value is in the served JSON, like sourceVersion. A player cannot type into it, because the intent is read from the in-memory tree.
    • Ruling (Self):
      • Each question mints an opaque random token and the row keeps it. It is not the QuestionId and carries no turn id, round, slot, subject or any other world handle: an answer's handle, and nothing else. Invariant 8 holds, because a player reading it in devtools learns nothing about the world, and forging one only answers their own open question.
      • The play button carries it in props.action, the channel that needs no binding machinery.
      • PlayPayload gains the token. An answer whose token is absent or does not match the open question's is refused as stale.
      • question_surface_version and the version comparison are deleted (PA.f161, PA.f175). The token is the one owner of "is this answer for this question", and a second, weaker test of the same thing is the split authority this pass keeps finding.
      • PA.f168's rule becomes: the bindings envelope merges with the command's own action fields, and an unknown sibling is still refused. The action spread and the envelope arrive together, so a blanket refusal would break every command.
  • PA.f171 (Cut 11, medium, fix): the gate has no drift protection. Removing PA.f164's authored value leaves the fixture test green, because the recording carries the value in its own payload. The live check is sharp but needs node, so nothing in CI ever runs it. Fix: the live check gets a scheduled runner; a fixture whose liveness is never checked anywhere is the thing the fixture was supposed to prevent.

  • PA.f172 (Cut 11, medium, blocker for the playtest): question_surface_version was added with no #[serde(default)] while STORE_SCHEMA stayed …play_turn_store.v1, so a row from an earlier session matches by type, key and schema and then fails to decode. The runtime catches that, logs a warning, and sets play = None: a world with no play card, and world.play denied as unavailable, with the cause only in the log. "Never deployed" was true of Yggdrasil, not of the workstation the playtest runs on. Fix: the schema version moves when the row's shape moves, an unreadable row is named as legacy rather than corrupt, and a play table that fails to open is reported where the operator can see it.

  • PA.f173 (Cut 11, low): close_with_fault leaves question and question_surface_version set, though the doc says every path clears them. It is unobservable, because open_question_id returns None unless the state is AwaitingPlayer. Fix: clear them, or correct the doc to say what actually guards it.

  • PA.f174 (Cut 11, low, fix): the accessMode rule is right and untestable here only because the bridge assigns values directly. Fix: the bridge asserts the element is not disabled before typing, which closes it cheaply.

  • PA.f175 (Cut 11, low-medium, fix): execute_round takes one snapshot and reuses it, so a round that authors and then asks records a version from before its own commits. On its own it only over-accepts, but it widens PA.f170's window.

  • The seed leg is untested territory, not "one env var away". The gate test codifies the refusal as its expected result, and seed_once goes on to open the vault and sweep against real inference, none of it exercised. GHOSTLIGHT_SEED_VAULT_ROOT appears nowhere in deployment/ and nowhere in gamecult-ops, so no runbook tells the operator it exists or what to point it at. That is a runbook item whatever the seed attempt does. exercised. It should be attempted alone, first; a failure there says nothing about the answer path.

  • Cut 12 landed (Hands, Sonnet, 2026-09-23): 7052644 and 241b33e in Ghostlight, 5e341f8 in gamecult-ops.

    • PA.f169: all three bridge tests are guarded and print SKIPPED. Hands verified it the way Idunn would, by rebuilding PATH without node: 156 passed, 0 failed. Removing one guard reproduced Soul's two failures exactly.
    • PA.f172: STORE_SCHEMA is …play_turn_store.v2, and a v1 row is read leniently, upgraded in place and persisted, rather than refused. Hands chose recovery over a politer refusal, because the point was the playtest's own world state. The strict canonical check stays for current rows. A failed play table is now reported as playStatus in readiness, not only in a log.
    • PA.f173, PA.f174 and PA.f175 are done. The bridge now refuses to type into a disabled control, which closes PA.f167's gap.
    • PA.f171: the runbook gains a live bridge check with its own schedule and owner, and the false "no separate gate step" claim is corrected.
    • Every rule was mutated at its call site and each mutation killed.
    • Dungeon bin 151 → 156. Ghostlight +459/-14, gamecult-ops +90/-13.
    • Hands flagged the overlap itself: 5b1f55c's ruling deletes question_surface_version, so the v2 schema and two of this cut's fixes are scoped to be superseded by Cut 13. That is the intended order, and Cut 13's brief carries it.
  • Cut 13 landed (Hands, Sonnet, 2026-09-23): 2f2a8b6.

    • Each question mints a uuid::Uuid::new_v4() token, kept on the row and carried in the play button's props.action, the same DOM-invisible channel command already rides. An answer whose token does not match the open question's is refused.
    • question_surface_version, its comparison, and the extra snapshot execute_round fetched only to compute it are deleted. QuestionId and OpenQuestion are kept: QuestionId is still play.rs's own admission key, and neither existed only for the comparison.
    • unwrap_bindings merges the envelope with the command's own action fields and refuses a name collision; an unknown sibling is refused one door down, by the payload's deny_unknown_fields.
    • The store is v3, reading v1 and v2 leniently through one upgrade_legacy_row, minting a token when a recovered row's question is still open.
    • Soul's own regression, soul_an_answer_composed_for_q1_lands_on_q2…, is now a passing test that reads its token out of the served surface. Dungeon bin 156 → 163.
    • The fixture is unchanged: its world.play click has no open question, so its action never carried a token.
    • Reported: production code grew by about 150 lines, in a cut meant to be neutral or negative. Hands attributes it to carrying two legacy schemas rather than one, plus this file's doc-comment convention.
    • Hands reported no mutations. Soul checks every rule's call site itself, as it did after Cut 10.
  • Soul on Cuts 12 and 13 (Opus, 2026-09-23, against a978a82, Windows).

    • Verdict: ready for the playtest with one preflight, and the seed leg cannot be judged at all. Cut 13's token rule is real, load-bearing and better tested than Hands claimed: every mutation killed exactly the tests that should die, and Soul's own PA.f170 attack is refused through the route.
    • Held: the token binds an answer to its question; tokens are distinct per question; every question-opening path mints; cleared on answer and on fault; a legacy upgrade mints for a recovered open question; the token is opaque, appears exactly once in the served document, and is absent from the rendered DOM and every attribute through the real pinned lowering; the merge rule; the tests read the token from the served surface; the strict canonical check still covers current rows; the no-node run is 163 passed, 4 skipped.
    • Growth judged: the claimed ~150 lines is 93 outside the test module, of which 29 are code. Two legacy schemas and a uuid mint for a real fix. It earns its keep. The doc comments re-litigate the deleted comparison, which is style, not liability.
    • Triage: PA.f176 to PA.f182 are Cut 14.
  • PA.f176 (Cut 12, high for the gate, not the daemon): eve_client_bridge_is_available() checks node and a committed dist/ file, which is always present, and never checks the bridge's actual runtime dependencies. On a clean checkout with node on PATH: 159 passed, 4 failed on jsdom, and still 4 failed on ajv after running the runbook's own setup line. That is PA.f169's shape returning from the dependency side: a prerequisite that fails the gate instead of skipping it. Fix: the check proves the dependencies it needs, and skips otherwise; the runbook's setup line installs both packages.

  • PA.f177 (Cut 12, moderate, preflight blocker): PA.f172 fixed half the population. applied_keys and fault were added to PlayTurn without #[serde(default)] while the schema stayed v1, so an older v1 row fails to decode even through upgrade_legacy_row: "missing field applied_keys". The play table then does not open, the card vanishes, and only playStatus says why.

    • Ruling (Self, under the standing go): the legacy reader is genuinely lenient — every field added since v1 defaults when a legacy row is read. A row that still cannot be read is retired to a sidecar file and play starts fresh, reported where the operator sees it. A daemon must never answer an unreadable row by quietly having no play surface.
  • PA.f178 (Cut 13, low-moderate, fix): with no question open, payload.answer_token is never read, so a stale tab's second click opens a new turn with text written for a question that is gone, and spends an inference budget doing it. Fix: an answer token arriving with no open question is refused ("that question is closed"), and never opens a turn.

  • PA.f179 (Cut 13, low, fix): open_question_of and the gate both fabricate an empty token through unwrap_or_default(), so a missing token degrades to "the empty token is valid". Fix: compare the Options directly, so absence refuses.

  • PA.f180 (Cut 13, low, fix): unwrap_bindings flattens the action channel and the bindings channel into one map, so the server cannot tell them apart, and a token sent as a captured binding is accepted today. Invariant 8 then rests entirely on eve.rs never emitting the token as a control. Fix: keep the channels distinguishable at the ingress, and refuse a token that arrives as a binding.

  • PA.f181 (Cut 13, low, recorded): QuestionId's match is exercised by one test that forges it, because the token refuses everything upstream. It still covers the window between the runtime's read and admit. Kept, with that narrowness stated where it lives.

  • PA.f182 (Cut 13, trivial, fix): the runbook lists three node-driving tests and warns that an unguarded fourth would break the gate. Cut 13 added a fourth, correctly guarded, one commit after the list was written. Add it.

  • The seed leg, plainly: the only coverage asserts that the click reaches the env-var read and is refused. Nothing past that line is tested anywhere: not the vault open, not the sweep, not a model call, not a committed patch. If the operator sets the variable and clicks Seed, everything after that read is first contact, and a failure there will look like a regression in this pass when it is untested ground. Plan the playtest so a failed seed does not abort the play-loop legs. Also: that refusal names a server environment variable to the player; it becomes a player-safe message with the detail logged (invariant 8).

  • Cut 14 landed (Hands, Sonnet, 2026-09-23): 58324f8 and 0808c6a in Ghostlight, 86ba7bc in gamecult-ops.

    • PA.f177: every field added since v1 defaults on the legacy path, and a row that still cannot be decoded is written verbatim to a timestamped sidecar beside the store, cleared through the same compare-and-swap swap_in uses, and warned about with its path, in the same stream as playStatus. Play then starts fresh.
    • PA.f178: a spent token arriving with no open question is refused and never opens a turn. PA.f179: answer_token_admits compares the Options, so absence refuses. PA.f180: ACTION_ONLY_PAYLOAD_FIELDS keeps the channels apart, and a token sent as a binding is refused.
    • The seed refusal no longer names the environment variable.
    • PA.f176: the availability check resolves the bridge's real dependencies. With both node_modules removed: 170 passed, 0 failed, 4 skipped. Hands could not produce a PATH with no node at all in this sandbox, because shims live outside the vendored directories; it said so rather than claiming the run.
    • Every rule was mutated at its call site and every mutation killed, including restoring the pre-PA.f176 check and deleting the node_modules to reproduce the bug live (166 passed, 4 failed). Nothing was reported as unmutatable.
    • Commits are grouped by file, because the items interleave inside play.rs and runtime.rs; each message enumerates what it carries.
    • Dungeon bin 163 → 170.
  • PA.f183 (pre-existing, low, recorded): seed_once's runner.sweep(1) renders a ControllerError to the player through to_string(), and Inference, ProviderContract and WorkPersistence could carry internal detail. The type is shared by many routes, so its player-facing Display is its own cut, not this one's. Recorded by Hands rather than fixed.

  • Soul on Cut 14 (Opus, 2026-09-23, against 3d63749, Windows). The last Soul pass of the pipeline.

    • Counts reproduce exactly: 170 with the bridge, 170 with 4 skipped without its dependencies, 622 in the library. Soul produced the no-node run Hands could not, by running the prebuilt test binary under env -i with PATH limited to C:\Windows\System32: all four gated tests skip, nothing fails.
    • Held: PA.f176's dependency resolution, PA.f178's guard (read from one consistent snapshot), PA.f179's gate itself, PA.f180 (scoped to the right channel, and every payload read goes through it), retirement preserving the payload verbatim, the kernel as the only writer, one play authority, and the runbook.
    • Invariants: the kernel as only writer, no transcript crossing turns, Personas never seeing structured state, an actor acting only through its own affordance, verbatim speech and one play authority all hold. Retirement holds on the legacy path and is absent on the current-schema path. Invariant 8 holds on the play surface and is broken on the seed route.
    • Triage: PA.f184 to PA.f192 are Cut 15, the last fix cut.
  • PA.f184 (Cut 14, medium, fix): the retirement fallback is wired only into the v1 and v2 arms. The current-schema arm and the catch-all still bail!, so a damaged or future-schema row kills the play card permanently with no sidecar. The realistic trigger is the strict canonical check: any future change to PlayTurn's fields without moving STORE_SCHEMA makes every existing store unopenable. That is the disease PA.f177 named, cured on the branches nobody's store is on. Fix: every arm that cannot read a row retires it.

  • PA.f185 (Cut 14, medium, fix): open treats all of upgrade_legacy_row's error exits as "did not decode", including an inner-schema disagreement, so a fully readable turn is destroyed and the sidecar's own reason says it did not decode. The payload is preserved verbatim, so nothing is lost, but the headline is false. Fix: retire only on a genuine decode failure, and let upgrade_legacy_row distinguish its exits.

  • PA.f186 (Cut 14, medium, fix): retirement inverts the signal PA.f172 added. playStatus is derived from state.play.is_some(), so a successful retirement reports "ok" while the player's turn is gone, and the only trace is one warn. Before Cut 14 the same row reported "unavailable". Fix: readiness reports a retirement, with the sidecar's path, for the life of the process. A quiet data loss must not read as health.

  • PA.f187 (pre-existing, medium-high, fix — this is PA.f183 measured): the seed route renders an internal error into the player's refusal through to_string(). Soul measured it rather than guessing: a refused connection reaches the player as "the local inference endpoint refused the connection: error sending request for url (http://127.0.0.1:11434/v1/chat/completions)". Cut 14 removed the variable's name and left its value exposed one line below, in the failure a fresh operator is most likely to hit. Fix: a player-facing refusal on that route names a category, and the detail is logged. The vault path does not leak; PlayError::Controller cannot be reached through admit, so the play path is clean.

  • PA.f188 (Cut 14, low-medium, fix): PA.f179 is guarded at the gate and nowhere else. Soul's mutation at the construction site (play.rs:397, Some(...unwrap_or_default())) survives the whole suite, re-introducing exactly the degradation the finding removed. Fix: guard the construction sites too.

  • PA.f189 (Cut 14, low, fix as doc): a sidecar that cannot be written leaves open failing, which is the right ordering — evidence before destruction — with a precondition its doc does not name. An orphan sidecar can accumulate if retirement's own swap fails. Fix: say so.

  • PA.f190 (pre-existing, low-medium, fix): the availability check proves the node dependency graph and says nothing about the git requirement the same test then asserts. Soul hit it by accident with a copied vendor/eve: the test panicked instead of skipping. That is the third instance of PA.f169's shape, for a third reason. Fix: the check covers every precondition the gated tests assert.

  • PA.f191 (pre-existing, high for playability, fix): web/src/main.ts sets pollMs: 0 and subscribes to the SSE stream only if the first surface fetch was already authenticated. A player who signs in through the in-page gate rather than the redirect gets a tab that never refreshes, so every answer after the first is refused as stale. Cut 14 changed how that feels: PA.f178 turned a silent wrong-turn into a visible dead end. Fix: subscribe from the current authenticated state after any sign-in, and keep a poll fallback.

  • PA.f192 (Cut 14, low, fix): the sidecar is written as .cc into the store directory while not being a CultCache store.

  • Also for Cut 15, from the walk-through: a turn interrupted mid-flight leaves the player at a blank card whose only exit is pressing Play with an empty box, and nothing says so. The card names it.

  • For the playtest briefing, not defects: the card is blank while a turn runs, and a character's speech reaches the player folded into the closing narration. Both follow from invariant 8 and from narration at close; they are how it plays, and the operator should know before sitting down.

  • Cut 15 landed (Hands, Sonnet, 2026-09-23): f295a3a (play.rs), d366316 (runtime.rs), ce382a8 (web/src/main.ts) and 1dea8f5 (eve.rs).

    • PA.f184: every arm retires, including the current schema and an unrecognised future one. PA.f185: upgrade_legacy_row's exits are distinguished, so a readable row with a disagreeing inner schema is no longer destroyed. PA.f188: both construction sites are guarded, and Soul's own mutation now fails. PA.f189 and PA.f192 are done.
    • PA.f186: playStatus reads "a play-turn row was retired to " instead of "ok", from a new PlayTable::retired_row_sidecar().
    • PA.f187: describe_seed_failure names a category, and the detail is logged.
    • PA.f191: the web client subscribes from the current authenticated state and keeps a poll fallback. There is no headless harness for main.ts, so Hands verified it by tracing the vendored host's own control flow, and says so.
    • The card now names the empty-submit continue while a turn is Running.
    • Every rule was mutated, including both construction sites, and every mutation killed its test.
    • Reported gap: the git-checkout precondition is unit-tested by parameterising the helper, not at its call site, because proving the wiring would mean corrupting the real submodule. Hands recorded it as not yet reached rather than claiming coverage.
    • Reported asymmetry: the current-schema arm retires on any read failure, including a non-canonical one, while the legacy path narrows to decode failures only. That is what the brief asked for; the payload is preserved verbatim either way.
    • Reported measurement: the gated tests skip through an early return, so the total stays 178 whether or not the bridge is available. The count cannot distinguish them; only the skip lines do.
    • Dungeon bin 170 → 178. Net about +980 lines, mostly tests and doc comments.
  • Soul on Cut 15 (Opus, 2026-09-23, against 30c710e, Windows). The closing pass.

    • Held: retirement's availability half on every arm; the legacy arm's narrowing, including that a CAS failure is not retirement; nothing dies without a forensic copy first — the sidecar is written before the destructive swap and a failed write leaves the row untouched; PA.f186's ok and unavailable cases; PA.f187 fully, since describe_seed_failure matches on variant, so no fault shape can reach the player, and no other route learned the habit; PA.f188 at both construction sites (there is no third); PA.f191's shape, where the 5 s poll is what refreshes the surface that flips the subscription.
    • Every invariant holds, including invariant 8 on the new hint.
    • The invariant Cut 15 bent is not on the list and is written nowhere: a readable turn is not destroyed. PA.f185 states it for the legacy arm; PA.f184 breaks it on the current arm, in the same commit.
    • Counts: 178 with the bridge's dependencies absent, and 178 with vendor/eve a plain copy. Library 622.
  • PA.f193-A (Cut 15, medium, fix): the current-schema arm routes every Err into retirement, including read_current_schema_row's inner-schema tag check. Soul rebuilt PA.f185's own probe with a v3 envelope: the turn decodes perfectly and is destroyed anyway, fifty lines from the test that asserts the opposite for the identical condition on the legacy arm.

  • PA.f193-B (Cut 15, medium, fix): the same arm retires a row that decodes but is not canonical — the trigger PA.f184's own comment calls realistic. Before Cut 15 such a row left the play card dead and the row intact and recoverable by fixing the code; after it, the row is eaten on the first restart. The cut traded preservation for availability on its own headline case without naming the trade.

  • PA.f193-C (Cut 15, medium, fix): git -C vendor/eve rev-parse HEAD does not fail for a copied directory, because git walks up and answers with the parent repository. Soul measured it: a copy reports available, and the fixture test then compares the stamped revision against Ghostlight's own HEAD and panics — the exact shape PA.f190 claims to have closed. The unit test passes only because its fixture sits outside any repository.

  • PA.f193-D (Cut 15, low-medium, fix): the new hint renders while any turn is Running, including a healthy one, and admit refuses an empty continue in that window with "only an empty continue is accepted" — told to a player who just submitted one because the card said to.

  • PA.f193-E (Cut 15, low, fix): playStatus reports a retirement only for the life of the process that performed it. After any restart the store opens clean and the only trace is a log line and an unreferenced file.

  • PA.f193-F (trivial): PA.f188 names two different findings across Cut 14 and Cut 15. Renumber the later one.

  • Soul's owed list, ruled into Cut 16 (Self, under the standing go):

    1. Split read_current_schema_row's exits as upgrade_legacy_row's are split. Only a genuine decode failure is retirement; a non-canonical row and an inner-tag mismatch both decode, so they leave the row intact and fail the open. Write the rule down: a readable turn is never destroyed.
    2. Check the checkout with git -C vendor/eve rev-parse --show-toplevel against vendor/eve's own path, or vendor/eve/.git. Build the fixture inside a git init root, or the test certifies nothing.
    3. Make a skip visible without --nocapture. The workstation and the Linux release currently report the same number for different amounts of verification.
    4. The hint gates on the run lock, not on the state alone; and the TurnStillRunning text stops contradicting itself.
    5. The sidecar becomes readable: JSON, with the payload verbatim in base64 beside it. A .msgpack nobody can open is preservation in name only.
    6. playStatus derives the retirement from the sidecars on disk, so it survives the restart that follows it.
    7. Renumber the duplicate PA.f188.
  • Target gap, for the reconciliation: notes/ghostlight-dungeon.md describes a much larger machine. Measured against the kernel's seven commands: no d20 resolution pair, no roll receipt or outcome bands; no away-time scheduler or strategic tick (AdvanceStrategicTick: zero hits); no gestalt and no institution agency (zero hits each); a concrete local-Markdown VaultEvidenceSource rather than the generic VaultProvider contract, and no CanonCandidate; news without the tick it would hang from; and hosting details naming a model family and a credential store the Body does not use. The authority and invariant section is what the Body honours; the implementation section is a roadmap in a spec's grammar and Self reconciles it as such.

  • Cut 16 landed (Hands, Sonnet, 2026-09-23): 63964b0, d082cd2 and db4f574.

    • PA.f193-A and PA.f193-B: read_current_schema_row splits into CurrentSchemaRowError::{Decode, Other}, mirroring upgrade_legacy_row. Only Decode retires; a non-canonical row or an inner-schema mismatch fails the open loudly and leaves the row untouched.
    • PA.f193-C: the check compares git rev-parse --show-toplevel against vendor/eve's own canonical path. The new test builds a real git init root with a copied vendor/eve inside it, proves the naive check false-positives there, then proves the fix refuses it.
    • Skips are visible: the four bridge tests are #[ignore] with a documented --ignored opt-in, and their unavailable branch now panics instead of returning quietly. Cost: they no longer run automatically on a capable workstation.
    • PA.f193-D: PlayTurnView gained run_in_progress, a non-blocking peek at the run lock, and the hint gates on Running && !run_in_progress. Lock contention became its own TurnBusy with accurate text, so TurnStillRunning is true only where it applies.
    • The sidecar is JSON with the original bytes in payload_base64 (.json, not .msgpack), round-trip tested to the exact bytes. retired_row_sidecar scans the directory fresh, so a retirement survives the restart after it.
    • PA.f188's duplicate is renumbered to PA.f193-D, with a note at each old site.
    • Every mutation killed. Dungeon bin: 179 passed, 4 ignored, and 183 with --include-ignored.
    • No discrepancies, and no fork: the Body matched the spec at every named site.
  • Soul on Cut 16 (Opus, 2026-09-23, against f5cba20, Windows). The pipeline's final pass.

    • The bar is met once one runbook edit lands. Nothing in the Body stops a player. The one thing that currently misleads is outside it: the runbook tells an operator a check ran when it did not.
    • Held: PA.f193-A and PA.f193-B on both split arms, with no third path where a row that decodes is retired; PA.f193-C's --show-toplevel comparison, sound against case, separators, symlinks and a git worktree; skips visible as 179 passed; 4 ignored, with Idunn's gate unaffected by the ignore itself; --include-ignored failing loudly with remediation text; the sidecar decoded byte-exact outside the process, twice; playStatus surviving a restart and ignoring other stores' files; the lock peek being neither racy nor deadlocking, so a healthy turn never shows the hint; invariant 8 intact.
  • PA.f194-A (PA.f184's arm, medium, owed): the catch-all arm retires on the schema string alone, with no decode attempted. Soul built a canonical, perfectly readable v4 row holding an open question and its token: open succeeds, the sidecar is written, the turn is gone, and the sidecar's reason claims the row "could not be read" when nothing read it. The trigger is an Idunn rollback across a schema move. The bytes are preserved and Soul decoded them by hand, so it is recoverable but not automatically.

    • The rule is written at the two call sites that already obey it. It belongs on retire_unreadable_row itself, as a precondition: the caller has attempted a decode and it failed.
  • PA.f194-B (Cut 16, high, operator-facing, owed first): the runbook's weekly drift check is #[ignore]d, so run verbatim it reports 0 passed; 0 failed; 1 ignored, ok, exit 0. It is the only thing that ever checks the fixture is live. Making a silent skip visible in cargo's summary moved the same silent green up into the runbook, where it is worse: the old form at least printed a line naming itself. The runbook's description of that behaviour is now false too.

  • PA.f194-C (Cut 16, medium, owed): the positive half of the checkout test asserts unconditionally whenever git is on PATH, so an uninitialised or copied vendor/eve now fails Idunn's own gate, which was green before. Whether that fires depends on how Yggdrasil materialises the submodule, which Soul could not check from here.

  • PA.f194-D (Cut 16, low, owed): with the hint correctly suppressed during a real round, the card now renders zero rows for the whole round. Strictly better than telling the player to do something that would be refused, but the only in-flight signal is gone. The surviving hint's first clause is also false in the one case it appears.

  • PA.f194-E (Cut 16, trivial, owed): PlayTurnStore::retired_sidecar has no production consumer left, and retire_unreadable_row's doc still advertises it as readiness's path.

  • Soul's judgement, for the postmortem:

    • What Soul caught that the tests never would: the tests were always about the wrong subject. Three of the five final findings are not about a Rust function at all, but about what a human is told and by what — a runbook sentence describing a machine that no longer exists, a pasted command printing ok for nothing, a reason field asserting a read that never happened. No assertion in the repo has those as its subject.
    • The shape that kept recurring: a true statement about a narrow mechanism standing in for a false statement about the whole path. dist/index.js exists, therefore the bridge works. rev-parse exited zero, therefore this is a checkout. The check failed, therefore the row is unreadable. The schema id is unknown, therefore the row cannot be read. Cargo printed ok, therefore the fixture is live. Each local fact is true; each inference is not; and each fix was scoped to the reported instance, leaving the identical inference standing one call site over.
    • What a future brief should demand: when a cut fixes a proxy-for-truth defect, it is not done until every other site drawing a conclusion from the same class of evidence is enumerated and ruled on in writing, including sites outside the repo. Name the inference, not the line. And any change to how a test reports — an ignore, a filter, a skip, a guard — is followed by running every command in the owning runbook verbatim and reading what it prints, because that is the one surface a test suite structurally cannot check.
  • Cut 17 landed (Hands, Sonnet, 2026-09-23): abb84b4 and 07aae19 in Ghostlight, 9528ef8 in gamecult-ops.

    • PA.f194-B: both documented commands carry --ignored, and Hands ran the drift check verbatim from a fresh checkout: 1 passed, 0 ignored, 48.32 s, where it had reported ok on nothing. The paragraph describing the old SKIPPED behaviour is rewritten.
    • PA.f194-C, settled from source, not guessed: Idunn's materialize_tree_raw/materialize_gitlink_raw (F:\Projects\Idunn\src\drivers.rs) write every tree and gitlink byte-for-byte from the object store and refuse to write .git metadata at all. So vendor/eve in the gate container is unconditionally a copy, never a checkout, and the bridge tests can never run there. Hands took the skip branch: the positive assertion checks vendor/eve/.git as independent ground truth and skips with a named reason when it is absent. Verified by forcing that false, which reproduced Idunn's exact condition.
    • PA.f194-A: the catch-all arm decodes before retiring, so only a genuine decode failure reaches retire_unreadable_row, and the precondition is now stated on that function, where the violating caller can see it.
    • PA.f194-D: a world.play.resolving row gated on run_in_progress, so the card always says something, and the surviving hint says "did not finish" rather than claiming a turn is in flight.
    • PA.f194-E: PlayTurnStore::retired_sidecar and its plumbing are deleted; readiness uses the disk scan.
    • Every mutation killed. Dungeon bin 182 passed, 4 ignored, and 186 with --include-ignored where the bridge is built.
  • Soul on Cut 17 (Opus, 2026-09-23, against fe4eb0e, Windows). The bar is met.

    • Soul ran the runbook's drift check verbatim: 1 passed; 0 failed; 0 ignored, 46.99 s, with the fixture's stamp matching git -C vendor/eve rev-parse HEAD. The last thing standing between a stale fixture and a silent green is closed.
    • It read Idunn's drivers.rs itself and agrees: git ls-tree carries no .git, and the parser refuses a path component named .git anyway ("Git tree contains forbidden .git metadata"). A gitlink is cloned to a separate cache root, verified, materialised through the same raw path and deleted. So vendor/eve in a frozen source is a plain directory on both paths, and the gate stays green.
    • The skip cannot hide a regression: the negative case and PA.f193-C's own copy-inside-a-repo case both run unconditionally, and --include-ignored printed zero skip lines on a real checkout.
    • PA.f194-A: Soul swept every write in play.rs. The four compare_and_swap_batch sites are the first write, the normal commit, the legacy upgrade (decoded first) and retirement itself, and all four retirement call sites now sit behind a decode attempt. No remaining path destroys a row without reading it.
    • PA.f194-D: the two rows are structurally exclusive and exhaustive, so the card cannot be blank for a turn in flight, and both are constant strings. Noted, not in scope: the hint is table-wide rather than per-account, which is the shape the old one already had.
    • Counts: 182 passed, 4 ignored; 186 with --include-ignored.
  • PA.f195-A (Cut 17, low, fixed): the runbook still claimed, present tense, that the suite is green "with all four tests skipped" when the dependencies are missing, contradicting the paragraph forty lines above it. An ordinary run is green because the tests are #[ignore]d and cargo never enters them; the availability check governs the deliberate --ignored run. Fixed in gamecult-ops 5d48e43.

  • PA.f195-B (Cut 17, trivial, owed): 07aae19's message says the tuple plumbing went with the field; play.rs:504 still destructures a _retired_sidecar every arm builds and nothing reads. A claim slightly ahead of its diff.

  • PA.f195-C (Cut 17, low, recorded): the PA.f194-C skip prints its reason with eprintln! on a passing test, so cargo discards it without --nocapture, and a run where the positive half did not execute reports the same 182 passed. That is the invisible-skip shape again, at half-test granularity where #[ignore] cannot express it. Soul confirmed it is not firing on a real checkout.

  • PA.f196 (pre-existing, critical, fixed 2026-09-23, 0bf36c7): found by the operator on the first press of a button. Sign-in answered "invalid Eve command result: data must not have additional properties". gamecult.eve.command_result.v1 is additionalProperties: false over exactly schema, receipt, transientProjection, pluginPayload and draftDirective, with the per-command detail inside the receipt. Dungeon returned a flat object — state, message, sourceVersion, providerId, surfaceId, operationId, idempotencyKey and updatedAtUtc at the top level — so the browser rejected every result whole. Not just sign-in: every command, always, since the surface existed.

    • How the pipeline missed it. Cut 10 drove the request path with the real vendored client and proved a command reaches the daemon. Nothing ever validated the response, because 94 assertions read the fields off our own shape — result["state"] — which is the test-local-shape defect this pass kept finding, one layer further out. The client bridge could have caught it the day it was written, by validating what came back.
    • Fix: the contract shape, with the detail in the receipt. The new test reads both schemas off the pinned vendor/eve checkout and checks a real result against them, rather than restating the allowed keys in an assertion that can only agree with its author. Dungeon bin 182 → 183.
    • The lesson, for the Eureka brief: a round trip is not proven until both directions are checked against the other side's own contract. Driving the real client proves the request; only validating the response proves the answer.
  • PA.f197 (pre-existing, critical, fixed 2026-09-23, 94ab279): the next wall, found by the operator on the retry. Sign-in answered "unknown field owner, expected one of schema, boundaryId, ...". Dungeon decoded Heimdall's published heimdall.command_boundary.v1 with deny_unknown_fields over the seven fields it reads; Heimdall has published owner, lifecycleAuthority, healthPublication, forbiddenWriters and compatibility since June (Heimdall 758892d), and the strict reader arrived in August (3411c1c). Every Heimdall receipt also carries schema, which each strict receipt struct denied, so begin, completion and logout would each have failed next. This sign-in path has never run against real Heimdall.

    • Same shape as PA.f196, pointing the other way: a consumer restating a foreign owner's document and refusing the real one. Fixtures were built from Dungeon's own structs, so they could only agree.
    • Fix: Heimdall-owned documents are read tolerantly; only the signed PrivateEnvelope, whose shape both sides fix, stays strict. The new test decodes the shapes Heimdall emits; making the boundary strict again reproduces the operator's error. Dungeon bin 183 → 184.
    • Not fixed, and not a code defect: Heimdall's private route is rudp://127.0.0.1:4101 on Yggdrasil and Dungeon correctly refuses a non-loopback route, so a Dungeon on Raven cannot reach Heimdall at all. The Raven playtest topology cannot sign in without moving one of them.
  • PA.f77 (introduced by 7c, medium, fix): table_view_prints_only_a_subjects_own_granted_affordances (table.rs:2886) fails about one run in four. Its unscoped view.find("{label} [{id}]") first matches the occupant list PA.f74 added under Places, and then reads whichever subject row sorts first. When it passes, it checks the wrong subject. A flaky test blocks Idunn's release gate. Fix: use subject_row, and sweep the test module for any other unscoped find over the view.

  • PA.f78 (introduced by 7b, medium, fix in its owner): PA.f67's hand-written word boundary (is_alphanumeric at each edge) is the wrong rule.

    • It accepts "I can" out of "I can't" (straight or curly apostrophe), a span ending between e and a combining accent, and "run" out of run_fast.
    • It refuses any CJK quote that doesn't end at punctuation, because every ideograph counts as alphanumeric and the prose has no spaces.
    • Ruling (Self, under the standing go): a span starts and ends on a Unicode word boundary as defined by UAX #29, via the unicode-segmentation crate. That is established tooling for exactly this question, replacing a hand rule that failed in three scripts. It is a new dependency: its owner is ghostlight-persona-projection's SourceSpan::locate, and its consumers are the Persona capture and gap paths, the Interpreter and Dungeon's span_is_exact.
    • Under UAX #29, "re" out of "re-enter" is still accepted, because a hyphen is a word boundary. That is intended.
    • Tests cover every one of Soul's probe cases.
  • PA.f79 (introduced by 7c, low, fix): two refusal-text tests pass trivially.

    • The expected-kind test asserts contains("place"), but its handle is missing_place.
    • The duplicate-handle test declares only the two colliding items, so a filter that matches every declaration survives.

    Fix: assert the rendered kind apart from the handle, and add an unrelated third declaration.

  • PA.f80 (introduced by 7c, low, corrected): a report and the cut map said PLAY_TOOLS held 24 tools. Dungeon's play.rs:72 still lists communicate. The note is corrected; 8a-fix drops it.

  • PA.f81 (pre-existing, low, fix): nothing pins the actor-facing refusal (table.rs:556-590) to bare role names. Appending a Debug dump of the failed precondition survived every test. It cannot reach a snapshot, but it can leak the entry's own schema. Fix: exact-output assertions for one role-bearing and one roleless case.

  • PA.f82 (introduced by 7b, low, fix): two values each have two computations.

    • The resolver's retired set (patch.rs:3297) repeats matches!(…Retired) instead of calling is_retired (lib.rs:3283).
    • The exercise revision is computed in action.rs:243 and again in resolution_revision.

    Fix: one predicate, and one source for the revision.

  • Carried into 8b (invariant 8): the actor refusal uses bare role names and no snapshot, so it cannot leak a label. That is safer than the "you are not at the cellar" example: a label resolved against the whole world could name a place the player never perceived. If 8b adds labels, they resolve only against what the subject perceives. play.rs switches the refusal line to the actor form.

  • PA.f8 (pre-existing, info): PreparedInference::prepare's doc says consumers may implement ports outside the crate; probe B shows they cannot read the request. Cut 2 removes the need for one; the comment is corrected in Cut 2.

Cut 10. The client round trip

Soul's pass on Cut 9 drove the vendored browser lowering over the real surface and found that nothing a player types reaches the daemon, that the card never wakes, and that one play commit denies every later world command. The loop is complete and the client cannot use it. This cut is the seam between them.

  • Repo/branch: from Cut 9. Written by Self under the standing go, 2026-09-22, from PA.f148 to PA.f160.
  • The versions have one owner each (PA.f148, PA.f160).
    • The surface document's version stays the world's own surface_version, because runtime.rs derives the kernel's compare-and-swap from it. Nothing else may be folded into it.
    • The play row's revision travels as its own field beside it, and the client watches both.
    • The SSE event carries both, and the journey test reads sourceVersion from the served document rather than counting.
  • The client round trip (PA.f149).
    • Eve owns the DSL. Dungeon emits what the vendored lowering consumes, and accepts the envelope it sends; the pin does not move for this.
    • Look for a working consumer in the Eve repo or the vendor's own tests and follow it, rather than inventing a shape.
    • The proof is one test that builds the intent with the vendored lowering and posts it through api_router: create, seed, activate, play, answer. Hand-written payloads prove nothing about a client.
  • The card wakes (PA.f150). A committed play row publishes a projection. The spawned turn notifies after each commit, on the same path eve_command uses.
  • The question id never reaches the player (PA.f151). The renderer has no hidden, so the id may not ride in a visible control.
    • Preferred: the lowering can carry an authored, non-editable binding value. Use it.
    • If it cannot, the server resolves the open question itself, and stale answers are refused by the sourceVersion the client already sends: an answer whose source version predates the question is stale. PA.f84 keeps its guarantee either way — an answer cannot land on a question the player never saw.
  • Play failures reach the player (PA.f153, PA.f154). world.play admits the request synchronously — a replayed key, a stale answer, empty text, a busy turn — and answers denied with the reason. Only the turn's execution is spawned. What the spawned turn cannot report, the turn view carries.
  • Ownership (PA.f152, PA.f159). The play card, its controls and the world.play descriptor are owner-gated like world.advance_time and world.seed, and the turn's player subject follows the principal. world.summary stops rendering the subject census; the phase and the clock stay.
  • Cleanup (PA.f155 to PA.f158). Pin the revision across a restart; retire or re-aim the_play_surface_shows_only_the_projection, whose mutation is no longer live; delete the stale doc references; give PlayTurnView::turn_id and state a reader or delete them.
  • Verification: the client round trip above, the CAS test that one play commit does not deny the next world command, an SSE test that a play-row change wakes the client, a test that no id appears in anything the player is served, and a denied-with-reason test for each admission refusal.
  • Landed (Hands, Sonnet, 2026-09-23): d9f0159 (the bridge) and 43b6af6 (eve.rs, play.rs, runtime.rs).
    • The binding shape was found, not invented: the vendor's own host-isolation.test.mjs builds a local-draft descriptor under a surface id, and eve::local_draft now copies it field for field. Proven with the bridge first: the old shape gave {"bindings":{}}, the new one carries the value.
    • tools/eve_client_bridge.mjs (110 lines) renders a surface with jsdom, types into controls by node id, clicks, and captures the intents the real lowering builds. The new world_create_seed_activate_and_play_round_trip_through_the_real_client drives create, approve, seed, activate and play through api_router.
    • world.create had no control for brief, a required field its own bindings advertised. No client could ever have created a world. Found by the bridge, fixed here.
    • JSON-typed fields only ever arrive as text, because the lowering has no JSON control; deserialize_json_capture accepts either. Hands flags this as reaching past the envelope into payload shape.
    • Coverage gap, reported: the stale-sourceVersion refusal and the round trip's answer leg have no test at the runtime.rs layer, because reaching AwaitingPlayer needs an inference backend and the scripted port is private to play.rs's test module. The mechanism is pinned one layer down instead. Hands would not widen that visibility unasked.
    • Dungeon bin 139 → 145. Net +1055/-204 plus the bridge.
    • Hands reported no mutations for this cut. Soul checks each rule's call site itself.