249 KiB
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 noRulingfield.Authorityisoperator,self-under-standing-go, ordefaulted. 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
Landedfield names commits, and closed when every promise (P<cut>.<n>) in its Verdicts table has a verdict (HOLDS | FALSIFIED | UNPROVEN). The root agent fillsLandedand 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, nevergit 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 oncrates/ghostlight,cargo check --offline, scratch target removed; repo untouched) tried, from outside the library:- build an
InferenceRequest: E0451, fieldspurposeandproviderare private (controllers.rs:115-118); there is no public constructor (tool_requestispub(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 atcontrollers.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 deriveDeserializewithpub(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.
- build an
-
Catalog size (measured from the checked-in fixture).
sidecar/claude-sdk/test/schemas.jsonis regenerated frompatch_tools()bysdk_inference.rs:1874-1913. The 39 patch tools total 54,810 characters (parameters plus descriptions parsed frompatch.rs:5600-6128). Droppingdeclare_affordance(8,055 characters of parameters on its own),record_gapandsubmitleaves 36 tools and 46,428 characters, about 13,300 tokens at 3.5 characters per token. The largest arecreate_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 replacedadmitwithmint; the ruled starting list is under PA-Q7. A grantedspeaktool is 160 characters. Script: scratchpadcatalog_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 lentToolResultOracle(sdk_inference.rs:417-498); it holds onegatemutex for a whole query (:423), andToolResultOracle::answeris synchronous (controllers.rs:251-260). - Routing is a model-name prefix with two slots, connector and SDK
(
sdk_inference.rs:715-773);open_inferencerefuses at open anyControllerModelslane no slot claims (controllers.rs:2336-2376). - The SDK sidecar is a private MessagePack child-process pipe
(
SidecarFrame,sdk_inference.rs:36-70) that borrowscodex_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-processInferencePorttrait. 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 wholeknowledge, each row renderedsaid | seen_doing | knownwith the speaker's label, certainty and text (visible_stimulus/projector_knowledge/perceived_from,controllers.rs:4604-4655). Rows carryminted_at(lib.rs:1044-1047) but nothing marks what arrived since the subject last acted. The kernel records every exercise as aDecisionEvent { revision, scope, .. }inWorldState.events(lib.rs:774-793,:873, pushed at:4881);last_opportunity_atis fictional minutes, not a revision (lib.rs:853). - Projector then Persona runs as two checkpointed stages of the narrative
lane (
run_narrativecontrollers.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 aHuman-mode opportunity; onlyrun_narrative's lane gate (:2776-2780) restricts it. - An exercise commits only if
exact_opportunityfinds the scope's current digest (lib.rs:4244-4270) and the caller equals the assignment'sexpected_caller(lib.rs:738-752, checked at:1613and:1631and re-decided at:4866and:4897). ANarrativePersona's expected caller isController(controller_id), whichControllerPort::submit_controllermints from the kernel-issued opportunity (mailbox.rs:316-332); a forgedcontroller_idfailsControllerMismatch. A human's expected caller is itsPrincipal. - Retry identity is caller plus body (
lib.rs:1466-1476); anExerciseDecisionbody carries its opportunity (revision and digest), so a retry rebuilt from a later snapshot isCommandIdConflict, notAlreadyApplied. 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_shapedemandscontroller_assignments.len() == subjects.len()(journal.rs:488-495); opportunities are minted for every assignment whosemode()isSome(lib.rs:4115-4156).- No operation grants or revokes an affordance: grants are written only by a
subject's declaration (
ComponentOpvariantspatch.rs:1014-1188; catalog "Draft-only",lib.rs:868). The genesis human subject is declared withkernel_speak_grant()only, at the commons (mailbox.rs:115-145), andspeakhas one precondition,CanBroadcast { Colocated }(patch.rs:2283-2297).Relocateneeds 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 assweep(1)inside the owner's request (runtime.rs:1786-1826), one session at a time.
- Rust drives every connector round: an inert port returns one round's calls
and the lane's evaluator re-derives the conversation from
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 aCodexProviderRequest(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→ assistanttool_calls,ToolResult→ tool message; response text →InferenceEvent::Text,tool_calls→InferenceEvent::ToolCall, inert). Probe B: an external port cannot readPreparedInference.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, besidesdk_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. Addsreqwestto the library (already in the workspace lock for Dungeon). About 250 lines with tests. - B: a port in Dungeon. Needs a public accessor on
PreparedInferenceand 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.
- A: a third port in the library,
- 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, inminted_atorder, 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 greatestDecisionEvent.revisionwhose scope is that subject (one pass overstate.events, no new state, no schema change), andprojector_knowledgeadds"new": minted_at > last_acted_atto each row (a subject that never acted sees every minted row as new). Nothing about who hears what changes; the kernel'sknowledgestays the sole perception owner. - Cost: the Projector's stimulus bytes change for every lane that renders
one, including the offline narrative lane. Stored
NarrativeCheckpoint::Projectorrows 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
ControllerRunneris the checkpoint store, the Interpreter stage and the runner's five-model open. - Options:
- A: an unpersisted lane.
pub struct PersonaLaneincontrollers.rs, built from aControllerPort, anArc<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-boundPersonaTurn), andpub 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
ProjectorandPersonacheckpoints and end at a new terminal stage (Spoken { turn }). Resumable, but it needs a new stage, its integrity arm, its progression rule, and acontroller_workrow bump, and it gives the turn two durable owners (the work store and Dungeon's turn record).
- A: an unpersisted lane.
- 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_controllerfrom an external crate. A Persona's act commits as that Persona's ownExerciseDecision, 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 throughsubmit_principalwith the player's ownVerifiedPrincipalEvidence, 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
CommandBodyis persisted before it is submitted, and a resumed call resubmits those bytes under the same command id, so the kernel answersAlreadyApplied(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 }inmailbox.rswithsnapshot(),submit_patch(command_id, WorldPatch)andadvance_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_capabilityand itsapply_effecttwin admit(Play, AdmitPatch)and(Play, AdvanceTime)and nothing else;require_patch_authorreturnsOk(None)(unconfined) forPlay;require_answeradmits(Active, None)with declarations forPlayand for no other caller;Playis refused in Draft (WrongPhase);require_clock_calleradmitsPlay;Play, and no other caller, may declare aRuledfact andMintquantity (PA-Q15, ruled; shape under PA-Q15 "Design").Playcarries no evidence receipts, so itsAdmitis refused by the existing gate like anyone's without a receipt; it usesMintinstead.
- History (superseded by PA-Q15): this default first refused any
Playpatch carrying evidence (PlayCarriesEvidence), so thatPlaycould 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
PlayPortinAppState, pinned by a negative grep (Cut 3, Cut 8). - Option not taken: binding
PlayPortto the owner's verified evidence, likeSeedPort. It would end a long play session at the owner's session expiry and buys nothing the journal'sSystem(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()andmode()returnNone, soderive_opportunitiesmints nothing for it (lib.rs:4125) with no new filter, andcontroller_assignmentsstays the sole answer to who may call (lib.rs:836-838).expected_callerbecomesOption<CallerId>and returnsNoneforRetired; both call sites compareSome(caller). The shape check'slen ==clause is unchanged. - Operation:
ComponentOp::Retire { subject: Ref<SubjectId> }resolving toResolvedOp::Retire { subject }. Applied: the assignment becomesRetired, the subject'spositionsentry andaffordance_grantsentry 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 anExternallyControlledmirror is refused (another world owns it); a confined author (elaborator, consumer) is refused byconfine_to_ground(OutsideJurisdiction) whatever the subject's place. Owner andPlaymay retire. - What readers see: no opportunity, so no dispatch (Dungeon dispatches from
opportunities) and no act (
exact_opportunity→OpportunityMismatch); no position, soaudience(lib.rs:3053) never reaches it and the Projector'spresentomits it;SubjectSnapshotgainspub retired: boolso 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_effectapplies the resolved op like any other, so replay reproduces it. - A body that matters afterwards is the agent's
declare_resourcein the same patch, as the target says.
- State:
- Schema:
ControllerAssignment,ComponentOp,SystemCapabilityall change shape, soSTATE_SCHEMA/COMMIT_SCHEMAbumpconsumer.v5 → v6(lib.rs:172-173, pinned atconsumer.rsas 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_affordanceis 36 tools, ~13,300 tokens; a 22-tool core ~9,000 tokens. Dungeon addsdispatch,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,retireandgrant_affordance/revoke_affordancejoin 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 ofPATCH_TOOLSnames (Dungeon policy, pinned ⊆PATCH_TOOLSby test, the lensleads_withprecedent), starting from the 22-tool core plusretireandgrant_affordance. - Revised by PA-Q15 (the starting list Cut 8 pins):
admitleaves the list, becausePlayholds no receipts and everyAdmitit wrote would be refused;minttakes its place.declare_factstays, and itsstandingnow offersruled.witnessandacquire_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):
communicateleaves the list, which becomes 24 tools.set_persona_materialis 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 theapp_session.rspattern (OwnedRedbMessagePackBackingStore, one row). The row,PlayTurn:turn_id(theworld.playidempotency 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 exactCommandBodyor 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,roundsand the recorded results, the connector posture; nothing crosses from one turn to the next except world state. When a turn closes, the next turn'sopening_promptdepends 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
Runningturn resumes on the player's nextworld.play(the player's evidence rides that request; an empty text means "continue"), and anAwaitingPlayerturn waits for the answer. Nothing runs a player act without a live principal.
- The agent's conversation is re-derived from
- 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 latestnarration(the Projector's prose for the player; Q11) as atextcard; the agent'squestionwhen one is open; the refusal line (Q13) when present; the turn state; acontrol.input.textareabound totextand oneworld.playbutton (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 keepsworld.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
speakonly (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, holdingPlay, declares a route from the commons and relocates the subject as its own ruling. - Design (ruled A):
ComponentOp::GrantAffordance { subject, affordance }andRevokeAffordancewriteaffordance_grantsfor an existing subject and an existing catalog entry. The owner (including the seed lane, which is the owner in Draft) andPlaymay 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::narratecall 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 fromrender_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_viewprints 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 ofPATCH_TOOLS, refusing unknown names.pub fn decode_authoring_call(name, arguments) -> Result<WorldPatch, String>: the existingapply_tool_calldecode, one call to a one-item patch, and the one decoderapply_tool_callitself then calls.pub fn actor_tools(prefix, &WorldSnapshot, SubjectId) -> Vec<CodexToolDefinition>andpub fn decode_actor_call(&WorldSnapshot, SubjectId, kind, arguments) -> Result<(DecisionOpportunity, DecisionInvocation), String>: the existingcatalog_tools/decode_catalog_call, plus an optionaldisplay.pub fn describe_refusal(&WorldSnapshot, &KernelError) -> String.- Two inference seams:
pub fn InferenceRequest::play(..), which wrapstool_requestunder a newInferencePurpose::Play, andpub 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)topuband 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
Ruledfacts, 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 theActionRejectedmismatch 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
Ruledfact 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.
- A: the surface shows the kernel's refusal of the player's own attempt,
rendered by
- 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
Ruledfact, "the cellar door is barred from inside", andwitnesses 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 (refusalon 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_idhashes ancestry,elaboration.rs:1545-1551). The gate's seeding is the owner pressing Seed, onesweep(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
Canonicalwith an evidence receipt, orClaimedby a subject) and could mint no quantity (Admitrequires 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
Ruledby 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." OnlyPlaygains 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
PlayCarriesEvidencerefusal ("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.
FactStandingisCanonical { evidence }orClaimed { by }(patch.rs:298-303), withFactStandingRefas its proposal twin (:308-311).ComponentOp::AdmitandResolvedOp::Admitcarry a requiredevidence: EvidenceRef(patch.rs:1055-1060,:1721-1726). The resolver refuses a canonical fact or anAdmitwhose receipt is not in the same patch'sevidencelist (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, inreduce, and is re-decided inapply_effect:require_patch_authorandconfine_to_ground(lib.rs:1655-1685,:4786-4802). - The quantity ledger has one owner:
check_ledger(patch.rs:1877-1891) creditsadmitted, andapply_operationsrecomputes 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 rendershow(said | seen_doing | known, fromKnowledgeSource), certainty and text, never standing (controllers.rs:4616-4638). Only the typed views (Interpreter, operational) print it (controllers.rs:4675-4695).
- The evidence gate is structural in the types, not a caller check.
- Design (for Cut 3): two additive variants plus one author rule.
Admitis not reshaped.- Facts:
FactStanding::RuledandFactStandingRef::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 mapsFactStandingRef::Ruled → FactStanding::Ruled(patch.rs:4965-4972), andFactCandidategainsRuled(:2111,:3184,:3340). Canonical stays the only standing that evidenced knowledge may name, so aRuledfact can be knownWitnessedbut neverEvidenced. - Quantity: a new
ComponentOp::Mint { holder, resource, qty }andResolvedOp::Mint. It resolves likeAdmitwithout the receipt clause: holder and resource must resolve andqtymust be nonzero. It credits the ledger asadmitted(the ledger has no "minted" term to add; the equationbefore + admitted + gained - consumed - spent = afteris unchanged), andoperation_groundtreats it likeAdmit(lib.rs:4604-4606). - One author rule:
require_ruler(caller, &ResolvedPatch) -> Result<(), Vec<Mismatch>>collects everyRuledfact and everyMintop and refuses them withMismatch::RuledWithoutAuthority { site }unlesscaller == System(Play). It is called inreduce'sAdmitPatcharm afterresolve_patch, besideconfine_to_ground, and re-decided inapply_effect'sPatchAdmittedarm as anInvarianterror, like confinement. - Why this shape keeps the gate for everyone else:
CanonicalandAdmitkeep 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 includingPlay. WhatPlaygains is two new spellings that onlyrequire_rulercan admit. Neither existing check becomes "evidence or else". - Subjects: the snapshot maps
FactStanding::RuledtoFactStandingView::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 itknownwith certainty.
- Facts:
- Option not taken: turning
Admit.evidenceinto an enum orOption(Evidence(EvidenceRef) | Ruled). That changes the wire shape of a consumer'sAdmit(consumer.rs:547-552,ghostlight.consumer_patch.v0) and theadmittool 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-mvpat the map's commit. No commit; output in the session scratchpad. - First:
git statusclean. - Captures:
- test census with
-- --listfor-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 renderedvisible_stimulusfor a subject with oneToldrow, oneSeenrow 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()andsha256(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
targetsize.
- test census with
Cut 1. Delete the autonomous drivers and owner controls from Dungeon
-
Repo/branch: Ghostlight
codex/ghostlight-dungeon-mvpfrom the map's commit. One commit; builds alone. Library untouched. -
Deletes first (
crates/ghostlight-dungeon/src/runtime.rsunless 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),ControllerHttpResultandcontroller_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), thecoverparameter ofauthenticated_surface(:242), the owner's controller buttons and descriptor (:483-560, ~78),"world.controller.act"inoperation_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 ignoredlive_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.rstests naming cover are updated, not deleted.
- the tick driver:
-
Keeps:
controller_permitsandconfigured_controller_concurrency(:1889-1898): Cut 8 draws Persona dispatch from this pool. Between Cut 1 and Cut 8 onlyruntime_readiness(:1712-1716) reads it; that interval is stated here so no one reads it as a leftover.ControllerRunnerstays for seeding andcustody_probe.world.speak, the story card andworld.advance_timestay until Cut 9. -
Docs in the same commit:
docs/architecture/ghostlight-dungeon-mvp.md:477-484drops the cover, tick-interval and elaboration-ceiling variables;docs/architecture/ghostlight-world-ontology.md:676-677and the handoff line 311 lose the elaboration pool.gamecult-opssets 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
AdvanceTimeloop, cover cells, the Active elaboration sweep, owner-requested controller turns. - Derived state:
CoverSummaryis 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 withworld.controller.actgone. - 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 forworld\.controller\.act: M1.1's required test. The paren insubmit_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"tooperation_schema:removed_legacy_operation_is_denied_before_dispatch(:3052) must be extended in this cut to nameworld.controller.act, and must then fail.
- builds:
-
Promises:
- P1.1 No Dungeon code path submits
AdvanceTimeexcept the owner'sworld.advance_timecommand. - P1.2 No Dungeon code path runs
run_cell,run_narrative,run_operationalorElaborationRunner::sweep. - P1.3
world.controller.actis denied before dispatch. - P1.4 The library diff is empty.
- P1.1 No Dungeon code path submits
-
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_committested the deletedcontroller_submission).controller_quarantinedwent too, because its only writers were deleted. Its readiness arm and seed-gate filter went with it. The negative grep'sworld\.controller\.actterm 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_clockloop passed 46/46 (PA.f11)P1.2 HOLDS By grep and inspection; unguarded: a seed-spawned sweeppassed 46/46 (PA.f11)P1.3 HOLDS M1.1 caught at runtime.rs:2226P1.4 HOLDS Library diff empty The negative grep's
world\.controller\.actterm 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 }.preparecallsPreparedInference::prepare(the one builder).inferrefuses a foreign caller id and an expired invocation exactly as the SDK port does (sdk_inference.rs:680-690), lowers the request to onePOST /v1/chat/completions(model = name with the prefix stripped,messagesfrom instructions and input,toolswithstrict: 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:sha256overrmp_serde::to_vec_namedofLocalInferenceReceipt { 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_inferencerefuses aLocalBindingwhose endpoint is not loopback (ControllerOpenError::LocalEndpointNotLoopback). No header other than content type is ever sent.
-
Per-file changes:
sdk_inference.rs:715-773:RoutedInferencePortgains alocal: Option<(String, Arc<dyn InferencePort>)>slot;routechecks 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) becomespubso callers pass&models.each()plus the play model (Cut 8).lib.rs:116:pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};andmod local_inference;.runtime.rs:440-478: readGHOSTLIGHT_LOCAL_ENDPOINTandGHOSTLIGHT_LOCAL_MODEL_PREFIX; no other Dungeon change in this cut.
-
Authority map:
- Owner:
RoutedInferencePortstays the one router; the model name still carries the transport. - Forbidden writers: no port reads a credential; no lane chooses a port.
- Owner:
-
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 on127.0.0.1:0(tokio listener in the test; no new dev-dependency):a_scripted_backend_drives_an_elaboration_round_through_the_existing_evaluatorpins invariant 7 (two rounds, calls inert,evaluate_elaboration_loopre-derives the conversation, the second request carries the first round's tool results);the_request_lowers_every_input_itempins 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
ToolCallevents only for the first call: the elaboration-round test fails on the second call. M2.3 map a 4xx toretryable: the fault test fails.
- builds:
-
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.rsis 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.28was 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_backtest andan_unroutable_model_fails_at_opencaught it.
- Lines: +985 / −34.
-
Verdicts (Soul, 2026-09-22, against
182f803, Windows):Promise Verdict Note P2.1 HOLDS M2.2 killed P2.2 FALSIFIED PA.f16: an HTTP_PROXYvariable sent the whole request to a proxy, plusproxy-authorizationfrom the proxy URL. PA.f17: a 307 re-POSTed the body to another portP2.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_iddropped 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) and4ac7b82(PA.f18–f20, PA.f22), by Hands on Sonnet, 2026-09-22.- The client is built with
.no_proxy()andredirect::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_requireddisposition. - An absent response
idand a null or absenttool_callsare tolerated. A tool-callidstays 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
toolsandparallel_tool_calls. - Every fix was killed by its own mutation.
local_inference::went 7 → 21 tests; lib 488 → 502.
- The client is built with
-
Re-verification (Soul, Sonnet, 2026-09-22, against
4ac7b82): P2.2 HOLDS..no_proxy()disables environment proxy resolution as a whole, andPolicy::none()refuses every 3xx. ASocketAddrcannot 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 statusclean. -
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 ofRuledfacts andMint, and for the clock. Minted only byPlayPort." lib.rs:1703-1720and: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)withdeclaresisOkexactly whencaller == System(Play).Playin Draft isWrongPhase. 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_calleradmitsPlay.mailbox.rs:pub struct PlayPortwithnew(WorldMailbox),pub async fn snapshot,pub async fn submit_patch(&self, CommandId, WorldPatch)andpub async fn advance_time(&self, CommandId, TickMinutes), both throughsubmit_stamped(:267) underCallerId::System(Play).lib.rs:94: exportPlayPort.- Ruled facts.
patch.rs:298-303:FactStanding::Ruled, doc "Stated by the play table. The kernel does not evaluate it; onlyPlaymay write it."patch.rs:308-311:FactStandingRef::Ruled.patch.rs:2111:FactCandidate::Ruled, set at:3184and:3340. The evidenced-knowledge clause at:4288-4296is untouched: it compares againstCanonical, so a ruled fact still cannot be knownEvidenced.patch.rs:4965-4972lowersRuled → Ruled.patch.rs:3046-3052is untouched (anif letonCanonical). lib.rs:2044-2060(apply's fact check): theRuledarm isfalse, which is not a stranger.journal.rs:580-590(verify_state_shape): aRuledstanding is live.lib.rs:4500-4515(confine_to_ground, facts):Ruledunder any confined author isOutsideJurisdiction. This is redundant withrequire_rulerand exists only so the match stays total.lib.rs:3660-3663:FactStanding::Ruled => FactStandingView::Canonical.lib.rs:2638-2646is untouched.- Mint.
patch.rs:1014-1188:ComponentOp::Mint { holder: Ref<SubjectId>, resource: Ref<EntityId>, qty: Quantity }, doc "Creates quantity with no receipt. OnlyPlaymay write it; every other author creates quantity throughAdmitand its evidence."patch.rs:1689-1780:ResolvedOp::Mint { holder, resource, qty }. Resolution besideAdmit(:3913-3960): the same holder and resource resolution, theZeroQuantityandQuantityOverflowchecks, the samecandidate_setandadmitteddelta, and no receipt clause. Lowering beside:5138-5148.lib.rs:2124-2150: Mint creditsadmitted.lib.rs:2178: Mint's referent is its resource.lib.rs:2373-2390: apply Mint like Admit without theevidence.containscheck.lib.rs:4604-4606:operation_groundtreats Mint like Admit.patch.rs:6922:"mint". - The author rule.
lib.rs, besideconfine_to_ground(:4411):fn require_ruler(caller: &CallerId, resolved: &ResolvedPatch) -> Result<(), Vec<Mismatch>>. It returnsOkwhen the caller isSystem(Play). Otherwise it returns oneMismatch::RuledWithoutAuthority { site }perRuledfact (Site::Declaration(handle)) and perMint(Site::Operation(index)), andOkif there are none. Called inreduce'sAdmitPatcharm (:1655-1685) right afterresolve_patchand before confinement, mapped toPatchRejected. Re-decided inapply_effect'sPatchAdmittedarm (:4786-4802), mapped toKernelError::Invariant("admitted patch rules or mints without the play authority").patch.rs:1285:Mismatch::RuledWithoutAuthority { site: Site }. - Catalog.
patch.rs:5799region:PATCH_TOOLSgainsmint(Operate { variant: "mint" }, fieldsholder,resource,qty, the same field kinds asadmitwithoutevidence). Its description says only the play table may mint.patch.rs:6270-6282: theFactStandingRefcomposite gains("ruled", vec![]), anddeclare_fact's description says only the play table may rule. The catalog test (patch.rs:6837) goes to(7, 31, 40).assert_exhaustiveand 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 thisv17if they land before av17store exists anywhere; otherwise each bumps again. - Schema.
lib.rs:172-173:consumer.v5 → consumer.v6.consumer.rs:2237and the length pin are updated as in L1.CONSUMER_PATCH_SCHEMAstaysghostlight.consumer_patch.v0. A consumer can now decodeMintandruled, but admission refuses both for it, so nothing a consumer could have sent before changes meaning.
- The capability.
-
Authority map:
- Owner: the kernel's admission functions.
PlayPortis the only minter ofSystem(Play).require_ruleris the only admitter of aRuledfact or aMint. - Inputs: caller, phase, answer, the resolved patch.
- Outputs: patches and clock advances attributed
System(Play),Ruledfacts and minted quantity. Every one is journaled with the caller, which is the provenance. - Derived state:
FactStandingView::Canonicalfor a ruled fact is display only. It decides nothing, and the kernel readsFactStandingitself. - Forbidden writers: the owner, the seed lane (which is the owner in Draft),
elaborators and consumers may not write
RuledorMint. They keepCanonical { evidence }andAdmit { evidence }with the receipt check exactly as it is. Every caller exceptPlaystill meetsAnswerRequiredfor an unanswered Active declaration. - Shared paths:
reduceandapply_effect(and replay through it) call the samerequire_ruler. The resolver stays actor-blind. - Deletion line: none. PA-Q5's
PlayCarriesEvidencewas never built.
- Owner: the kernel's admission functions.
-
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(eachUnauthorized);the_play_authority_rules_a_fact_and_a_witness_knows_it_as_canon: aRuledfact, witnessed at a place, appears in the subject's snapshot asFactStandingView::Canonical, and in its Projector stimulus asknownwith no author;the_play_authority_mints_and_the_ledger_balances:before + qty = after, andcheck_ledgerpasses 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 withRuledWithoutAuthority, carrying the fact's declaration site;no_one_else_may_mint, the same four callers, refused withRuledWithoutAuthorityat the op's index;the_evidence_gate_is_unchanged_for_every_author: for the same four callers and forPlay, aCanonicalfact orAdmitwhose receipt is not inevidenceis stillFactWithoutEvidence/AdmitWithoutEvidence, and with the receipt inevidenceis 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 forgedPatchAdmittedunder an elaborator caller, carrying aRuledfact that the resolver would accept, is refused at apply. This follows theapply_effect_re_decides_elaborator_authoritypattern (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.rsgainsplay_port_is_the_only_play_minter(compile-pass use ofPlayPort) and a consumer document carryingMint, refused. - negative:
rg -n 'SystemCapability::Play' crates/ghostlight/srcoutside#[cfg(test)]lists only the admission sites (require_system_capability, its twin,require_answer,require_patch_author,require_clock_caller,require_ruler) andPlayPort'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 anySystem(_)instead ofPlay. Killed by the elaborator test. - M3.2
require_ruler:caller == System(Play)→matches!(caller, CallerId::System(_)). Killed byno_one_else_may_rule_a_fact(elaborator and consumer rows). - M3.3
require_ruler: collectRuledfacts only, notMint. Killed byno_one_else_may_mint. - M3.4
require_ruler: admitPrincipal(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 therequire_rulerre-decision. Killed byapply_effect_re_decides_the_ruler. - M3.6 Admit resolution (
patch.rs:3938):evidenced = trueforPlay. Killed bythe_evidence_gate_is_unchanged_for_every_author(Playrow). This is the spelling that would turn Admit into "evidence or else". - M3.7 Mint apply: credit
consumedinstead ofadmitted. Killed by the ledger test. - M3.8 snapshot (
lib.rs:3650-3668): filter out knowledge rows whose fact isRuled, 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.
- M3.1
- builds:
-
Promises:
- P3.1
Playdeclares in Active unanswered and unconfined; no other caller does. - P3.2
Playalone writesRuledfacts andMintquantity. The owner, seed, elaborators and consumers are refused both atreduceand atapply_effect. - P3.3 The evidence gate on
Canonicalfacts andAdmitis byte-for-byte the same predicate at the same sites for every author,Playincluded. - 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
Playcannot act in Draft, approve, activate, exercise or decline.
- P3.1
-
Landed:
4c4973b(Hands, Sonnet, 2026-09-22). It was amended and force-pushed over its own first push9fbdde8, 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 redundantRuledarm masked a dropped apply-timerequire_rulerfor aRuledfact. The test forges aMinteffect instead. - M3.9:
require_ownerindependently refusesPlayactivation, so the pipeline could not observe the mutation. A unit assertion onrequire_system_capabilitywas added.
- M3.5:
- Not done as specified:
- The Projector-stimulus half of P3.4 is untested; only the snapshot's
Canonicalview is asserted. - The external consumer-document
Mintrefusal was not written, because the receipt's outcome ispub(crate). The consumer row ofno_one_else_may_mintcovers the refusal internally.
- The Projector-stimulus half of P3.4 is untested; only the snapshot's
-
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 itP3.5 HOLDS Overflow at u64::MAXrefused, zero refused, u128 world totals, replay equalP3.6 HOLDS MS5 killed Soul's mutations:
- MS1 (Draft pass), MS5 and MS6 (exempt elaborators) were killed.
- MS2 (exempt
Principalat apply) and MS3 (first op only) survived Hands' suite; Soul's probes kill them. - MS4 (first ruled fact only) survived every test.
require_ruleris load-bearing for confined authors too. Under MS6 an elaborator'sMintpasses, because confinement has noMintveto. -
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-mvpfrom Cut 3. One commit (shares Cut 3'sv6). Written for PA-Q6's default. -
Per-file changes:
lib.rs:700-760:ControllerAssignment::Retired;id,mode,consumer,human_principalreturnNone;expected_caller -> Option<CallerId>,NoneforRetiredandExternallyControlledkeepsSome(System(Consumer)); call sites:1613,:1631,:4866,:4897compareSome(&caller).patch.rs:1014:ComponentOp::Retire { subject: Ref<SubjectId> };patch.rs:1689:ResolvedOp::Retire { subject: SubjectId }; resolution besideRelocate(:3613): subject resolves, is not retired (NoOperationEffect), is not externally controlled (Mismatch::RetiresAMirror); lowering at:5093region.lib.rs:2115apply_operations: setRetired, removepositionsandaffordance_grantsentries.lib.rs:4411confine_to_ground: everyRetireunder any confined author isOutsideJurisdiction.lib.rs:966-1015:SubjectSnapshot.retired: bool(pub), set insnapshot.patch.rs:5600:PATCH_TOOLSgainsretire(Operate { variant: "retire" }, one fieldsubject); counts atpatch.rscatalog test go to(7, 32, 41)(Cut 3 addedmint); 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_assignmentsstays the sole answer to who may call;Retiredis 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.
- Owner:
-
Verification:
- tests:
a_retired_subject_holds_no_opportunity_across_a_replay(invariant 6: retire, reopen the journal, assert no opportunity andretired);a_retired_subject_cannot_act(its last opportunity isOpportunityMismatch);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 forRetired: the opportunity test fails. M4.3 drop the confinement clause: the elaborator test fails.
- tests:
-
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
Playretire.
-
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_shapeneeded no new arm. The loosening of M4.2 (expected_callerstillSomeforRetired) survived:mode()gates first. The loosening of M4.3 (confine only elaborators) survived: no consumer-authoredRetiretest. 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 Communicatespeaker, through aReach::Subjectschannel (PA.f33). No opportunity, cannot act, and replay: these holdP4.2 FALSIFIED in practice Retiring any subject with history fails verify_append(PA.f31).Forgetcan 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> }andRevokeAffordance; 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_opportunitiesrequires one,lib.rs:4142-4146); apply writesaffordance_grants;confine_to_groundrefuses both for every confined author.PATCH_TOOLSgainsgrant_affordance,revoke_affordance((7, 34, 43)), fixture regenerated. Samev6/v17as 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 isScopeChanged);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-2840andrun_projector:3530-3580becomes 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 insnapshotfrom one pass overstate.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 butNarrativePersona);pub async fn narrate(&self, command_id, &DecisionOpportunity) -> Result<String, ControllerError>(refuses any mode butHuman). Requests areprojector_request/persona_request(:5934,:5949) under the given command id. No work-store access; the type holds noControllerWorkStore.lib.rs:80-87: exportPersonaLane.
-
Authority map:
- Owner: the library builds every Persona and Projector input from the
subject's own slice;
PersonaLanehas no parameter through which a caller can pass text into either prompt. - Forbidden writers: the caller (the play agent) of any Persona input.
- Owner: the library builds every Persona and Projector input from the
subject's own slice;
-
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 arenew, 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 thePersonaLaneimpl block = 0. - mutations: M6.1 pass
typed_viewinto the Persona prompt: the no-structured-state test fails. M6.2 computenewwith>=: the marker test fails on the subject's own spoken row. M6.3 takelast_acted_atfromlast_opportunity_at(minutes): the marker test fails.
- tests:
-
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_invocationandbuild_persona_invocation, which the narrative lane andPersonaLaneshare. turnandnarratealso refuse a retired subject.- The stimulus equals Cut 0's capture plus one
"new"per row. A row with nominted_atis 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_outexcludes the speaker from its own telling. The>boundary is pinned by a constructed case instead.
- The Projector and Persona builders are extracted into
-
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_atare 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 (
turnacceptsOperationalAgent), MC (the typed view in the Projector guidance) and ME (select_freshinstead ofselect_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_structureandid_text(elaboration.rs:2164-2280) move totable.rs; the seed prompt calls them there. -
Adds:
crates/ghostlight/src/table.rswith the functions named in PA-Q12's design.table_viewextends 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, becausemint,transfer,witnessandacquire_knowledgeall 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_callisapply_tool_call's decode arm (elaboration.rs:1339-1420) called for one call, so there is one decoder:apply_tool_callcalls it.decode_actor_callisdecode_catalog_call(controllers.rs:6279-6360) plus a verbatim optionaldisplay, and looks the subject's current opportunity up in the snapshot.describe_refusalrendersActionRejectedmismatches through the entry's preconditions, andPatchRejectedmismatches by site with the offending tool call's index, so the agent can repair it; every otherKernelErrorrenders by itsDisplay. The agent holdsPlay, so it never meetsRuledWithoutAuthorityorAdmitWithoutEvidencefor its ownmint. It does meetAdmitWithoutEvidenceif it callsadmit, which is whyadmitis 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)overtool_requestwith parallel calls on;InferenceOutput::events()andreceipt_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,DeclarationorDecisionInvocationby hand.
-
Verification:
- tests:
authoring_tools_refuse_an_unknown_name;every_authoring_tool_decodes_its_own_example(reusesfield_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_callthe snapshot's first opportunity instead of the subject's: its test fails. M7.2 letauthoring_toolsskip unknown names: its test fails. M7.3 print resources by label only, as the moved renderer does:table_view_prints_every_id_the_tools_takefails on amintexample's resource id. (That test takes one generated example per listed tool and asserts that every referenced id appears in the view.)
- tests:
-
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.rsis 1132 lines.render_world_structureandid_textmoved there byte for byte, and the seed prompt is unchanged.table_viewis a separate renderer, so the seed bytes never move when it changes.apply_tool_callnow callsdecode_authoring_call.decode_catalog_call,render_jurisdictionandrender_kindare widened topub(super)for reuse.- Lib 570 → 579. M7.1–M7.3 were killed.
- Not as specified:
table_viewprints aRuledfact ascanonical, becausesnapshot()mapsRuledtoFactStandingView::Canonicalfor 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_toolsemits toolsdecode_actor_callrefuses (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 textissued 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
AppStateandworld.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):PlayTurnStoreoverservice/play-turn-v1.cc, theapp_session.rs:56-80pattern; one row,PlayTurnas 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 }withasync 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;textanddisplaymustSourceSpan::locatein that actor's prose (the player's texts, or the Persona's turn), else refused; persist the body; submit (submit_principalfor the player,ControllerPort::submit_controllerfor a Persona); onActionRejectedfor the player, setrefusalfromdescribe_refusal;dispatch { subjects }→ for each subject with aNarrativePersonaopportunity,PersonaLane::turnconcurrently underpermits; 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::narratefor the player writesnarration, and the turn isClosed.
- an authoring tool →
- 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-103AppState.play: Option<Arc<PlayTable>>plus aMutexguarding one running turn;open_controller(:440-479) readsGHOSTLIGHT_PLAY_MODELand passes it toopen_inference's model list;execute_world(:1439) routesworld.playto the table in a spawned task and returnsaccepted;eve.rs:622-636adds"world.play" => "ghostlight.world_play.v0". -
Authority map:
- Owner:
PlayTableowns 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
PlayTableconstructsPlayPort; 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.
- Owner:
-
Verification (scripted
InferencePortin 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_refusedanda_raw_op_is_journaled_as_play_and_never_as_an_actor(invariant 3: the commit's caller isSystem(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'smintand adeclare_factwithruledstanding commit with callerSystem(Play);admitis not inPLAY_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_prompton 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:
Retryableis retried inside the round budget.RecoveryRequiredorIntegrityViolationfrom inference becomes that tool call's recorded result for the agent, for example "Mara could not act: ", and the turn continues.- A kernel
Invariantfrom 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_quarantinestays unread in Dungeon. -
PA.f13, the permit barrier: Persona dispatch draws from
controller_permitsagain (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
turngets a freshcommand_id, derived from(turn_id, round, call_index).PersonaLaneis 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 theAppStatewiring,GHOSTLIGHT_PLAY_MODEL,world.playrouting 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.rsis 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 onghostlight-persona-projection(forSourceSpan).- M8.1–M8.3 were killed. The other rules have no mutations yet; Soul covers them.
commit_authoring_runis the seam that 8b switches todecode_authoring_calls(PA.f54). Until then it commits each call as its own patch.runtakes aturn_id, theworld.playidempotency key.ControllerPortis built on demand for a Persona's act.- Owed to 8b:
a_refused_player_act_sets_the_refusal_lineis 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
Arcand 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 communicatePA.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_materialon an existing subjectPA.f61 table_viewnever reaches a PersonaHOLDS 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.rssemantics fixes (8a-fix) come after library batch 7a, and 8b follows.SourceSpanboundaries (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), theworld.speakarm (:1520-1538),eve.rs:436-455(story card) and:457-501(speak control),"world.speak"inoperation_schema(:632),current_operator_view's log read for the surface (runtime.rs:1630-1640;operator_logstays in the library),speak_throughand tests that drive speech through Eve (the HTTP journey test is rewritten toworld.play). - Adds: in
eve.rsActive arm, the play card as PA-Q9 describes, read from the store's row and the snapshot;surface_versionalso 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; negativerg -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 bindsanswersfrom the id it is showing. Without this,ask_playerwedges the world: nothing else can tell a client theQuestionIdto 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
RunOutcomeand a small read on the table carry the same, so nothing but the card reads the store. - PA.f135, sequencing. The deletion of
world.speakand 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
acceptedresponse. Aworld.playthat never reaches the table must fail it.
- PA.f134. The card binds
- Fork raised by Hands and ruled (Self, under the standing go,
2026-09-22): the play row's counter. The cut asked
surface_versionto 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 setsturn.refusalafter that call's record already exists, socalls.len(),rounds.len(),question.is_some()andnarration.is_some()are all unchanged on exactly the write invariant 8 cares most about.PlayTurnStoreStatecarries a persistedrevision: u64, incremented once per committed write inPlayTurnStore::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.
PlayTurnViewcarries it, soeve.rsreads it throughcurrent_turn_view()and nothing outsidePlayTabletouches the row.surface_versionmoves 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.factsispub(crate)so that onlytable_viewrenders it, andtable_viewis 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) andf1732c0(the deletion).eve::authenticated_surfacetakes aPlayTurnView, read inruntime.rs'scurrent_play_viewthroughPlayTable::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-onlysurface_versionstill serves mesh, readiness and broadcast.- M9.1 (render the operator log into the card) and the revision
mutation (increment only when
callsgrows) were both killed. - Deleted:
SpeakPayload, theworld.speakarm, the story card, the speak control,"world.speak"fromoperation_schema, andcurrent_operator_viewentirely.authenticated_surfacelost itsoperator_logparameter, andworld.summaryno 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.speakoperation id is gone, and theoperator_log()call that produces the durable textthe_play_surface_shows_only_the_projectionproves the card cannot reach.operator_logstays 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.answersis a boundcontrol.input.textover a"json"draft carrying theQuestionId, following howlens_weightsandjurisdictionsencode 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:
322bdf6andf1732c0; 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.yamlsays every backend shares "one standardized CultNet connector API"; the Body has one in-process trait and a private sidecar pipe. Resolved at5ff1d54(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() = 1answering 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::openrequires 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
Ruledfacts andMint; Cut 3 builds them. -
PA.f9 (introduced by PA-Q15, low, recorded):
PATCH_TOOLShas one owner and is every authoring lane's catalog, so the elaborator's and the seed's prompts now listmintand aruledfact standing, which admission refuses them. The descriptions say "only the play table". A lane that reaches for either earns aRuledWithoutAuthorityrefusal 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_clockloop besidemaintain_mesh_projection, and asweepspawned fromseed_once, and both passed 46/46. Fix: a source-scan test, on the precedent ofsoul_no_credential_name_appears_in_the_runtimes_own_source. It banssubmit_clock(,.elaborator(,run_narrative(,run_operational(,run_cell(andElaborationRunnerfrom Dungeon's non-test source. Cut 8's Persona dispatch goes throughPersonaLane, which the ban does not name. -
PA.f12 (introduced by Cut 1, low, fix): the dropped
no_proposal_projection_carries_the_canonical_world_commitwas the only test ofsubmit_receipt'sapplied/already_appliedkinds 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) drainscontroller_permitsas 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_quarantinehas no reader, and the seed lane has always turned every error into a payload string. The library still refusesCustodyUncertainitself. 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, 195is 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. WithHTTP_PROXYset, the whole request went to the proxy, and a proxy URL carrying userinfo addedproxy-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": nullfails to decode and becomesIntegrityViolation, which quarantines the lane. A missingiddoes 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-113copiescall_id_is_validandtool_name_is_validfromsdk_inference.rs:579-590; hoist one copy.- Fold
two_tool_call_replyintotool_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
toolsandparallel_tool_callsinstead 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_sha256hashes the prepared request, not the lowered body that is actually sent.- Duplicate call ids are unchecked in the SDK and connector ports.
- The receipt binds identity,
-
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 responseidtolerates absence (#[serde(default)]) but not an explicitnull, which decodes asintegrity_violationand quarantines the lane.tool_callswas fixed for exactly this case withnull_as_default. The fix is the same treatment forid, 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_proxysets 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 poisonclient_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) andWorldState::genesis(lib.rs:1885-1898) admit the creation patch without it. Soul created a world whose genesis patch held aRuledfact, and it replayed. Production can't reach this today, becausemailbox.rs:160-195builds genesis from labels only. The fix: callrequire_rulerwith 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
PatchAdmittedcarrying aRuledfact); - a
Mintat op index > 0 (MS3 survived); - a patch with two
Ruledfacts where only the second is checked (MS4 survived every test).
- the owner at apply time (MS2 survived: a forged owner
-
PA.f28 (introduced by Cut 3, medium, defaulted and recorded):
PlayPort::new(WorldMailbox)andsubmit_patcharepub, soPlayPortis 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
WorldMailboxalready means being the consumer that runs the world. - L0's sealing ruling concerns forgeries from untrusted input, and no
untrusted input reaches
PlayPort.CallerIdandSystemCapabilityare private, and insidesrconlymailbox.rsbuildsPlay.
Follow-up: if a second consumer shares a process with Dungeon,
PlayPortgets a constructor gated by the consumer. - Holding a
-
PA.f29 (introduced by Cut 3, low, fix): two promised tests are missing.
the_evidence_gate_is_unchanged_for_every_authorcoversPlayonly, not the four other callers the spec lists.- The external consumer-document
Mintrefusal is absent fromexternal_admission.rs. Its receipt outcome ispub(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,GrantAffordanceandRevokeAffordancerefused byconfine_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()isNoneforRetired(M4.2 loosened).
- a consumer-authored
-
PA.f31 (introduced by Cuts 4-5, high, fix):
journal.rs:920and:924inverify_state_shapejudge every pastDecisionEventagainst the subject's current controller (Some(event.controller_id) == assignment.id()) and its current grants. As a result, retiring any subject that has acted failsverify_appendwithCorruptJournal, and so does revoking a verb that has been used. Soul's probe: the reeve exercisesthreaten, then is retired or hasthreatenrevoked. 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_historyre-runsreduce) and by the digest chain. The shape check must not re-judge history against present state. - Hands first lists every caller of
verify_state_shapeand 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.
- Ruling (Self, under the standing go): an event's authority is judged
when it commits, by
-
PA.f32 (introduced by Cut 4, high, fix):
required_approvers(lib.rs:4388) reads the live assignment, andhuman_principal()isNoneforRetired. 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 newMismatch. 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.
- Ruling (Self, under the standing go): retiring a
-
PA.f33 (introduced by Cut 4, medium, fix): channel reach ignores retirement.
audience()'sReach::Subjectsarm (lib.rs:3189) returns members without filtering out retired ones.Communicateresolves a retired speaker (patch.rs:4573-4615).CanReach(action.rs:540) passes for a retired target.- Fix: audience derivation excludes retired subjects, a retired
Communicatespeaker is refused, andCanReachfails 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) andInstallIncumbent(: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:
RevokeAffordancefalls out of confinement. - MuC:
retiredis taken frommode().is_none(), which flags mirrors. - MuD: the revoke candidate set is not updated, so three revokes pass the resolver.
- MuE:
retired.insertis dropped.
Soul's probes in
scratchpad/soul_probe.rskill all but MuE. Fix: add the probes as tests, and add one that kills MuE. - MuA: the resolver's Retire drops
-
Fix batch 3 landed (Hands, Sonnet, 2026-09-22):
c8b9aad(PA.f26, genesis gated inprepare_creationand inWorldState::genesis),c97fad2(the PA.f27, PA.f29 and PA.f30 tests), and6e7c73b(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) ande18aa03(PA.f33 to PA.f35). Lib 525 → 541.verify_state_shapehas two production callers,verify_appendandverify_history, and both replay. The anachronistic clauses are deleted.Mismatch::RetiresAnApproverrefuses a Draft retire of the human, and apply re-decides it. The approval shape check runs only in Draft.audience()'sReach::Subjectsarm filters out retired subjects.CanReachreadsaudience(), so it is fixed by the same change.Mismatch::RetiredSubjectActedrefuses a retiredCommunicatespeaker,AcquireKnowledge,ForgetandInstallIncumbent.- 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_rulerruns inprepare_creation,WorldState::genesis,reduceandapply_effect. - PA.f31's premise is confirmed.
WorldStateis decoded only inrecover(followed byverify_history) and incommit(on a row already verified). - After an Active retire of the human, the Eve surface shows no Speak
control, and a stale
world.speakis refused withOpportunityMismatch. Nothing panics. - Findings PA.f36 to PA.f40 below. All are fix, in batch 5, after Cut 6 lands.
- Verdicts: P4.1, P4.2, P4.3, P5.1, P5.2 and P3.2 HOLD. P3.2 now holds
across genesis as well:
-
PA.f36 (pre-existing in fix batch 4, low, fix):
RetiresAnApproverchecks onlyKey::Existing(patch.rs:3838). A genesis patch that declares a human and retires it is admitted by the resolver. Only theHuman if Draftarm inapply(lib.rs:3000) stops it, as anInvariant, afteropen_ownerhas left an emptyworld.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 tracksmirrors, plus a test. -
PA.f37 (pre-existing since Cut 4, low-medium, fix): a retired subject still acts through the clock and commitments.
CreateCommitmentadmits a retired promisor (patch.rs:3581).- The tick (
clock.rs:101) never checks retirement. In Soul's probe a retired reeve's routine rolled fromdue 10todue 20, and its goal andBinddependencies kept raising pressure. - Ruling (Self, under the standing go): "a retired subject cannot act"
covers its commitments.
CreateCommitmentrefuses a retired promisor, andderive_motionskips 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,OpenForumandSetControlleron 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
AcquireKnowledgeretired check tosource != Evidenced. - M6: letting
is_retiredalso matchExternallyControlled, which would silently drop mirrors fromReach::Subjectsaudiences. - M9: disabling the Draft-only approval shape clause
(
journal.rs:885), which is defence in depth. - Also add Soul's probe S2:
CanReachto a placeless target reachable only through aReach::Subjectschannel.
- M2: narrowing the
-
PA.f40 (introduced by fix batch 4, low, fix): the forged "mirror acted" test at
consumer.rs:1946-1958claims 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 passesverify_state_shape, whileverify_historystill refuses it. Fix: route the test throughverify_historyand rename it for what it pins. -
Fix batch 5 landed (Hands, Sonnet, 2026-09-22):
bc1852d,33fc9c6and662a51f. 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_motionskips a retired subject's commitments and dependencies. A second test was added after a mutation showed the dependency loop was unexercised. - PA.f38:
delegated_authorityskips a retired institution's offices. - PA.f39: four tests.
- PA.f40: the forged-mirror test is renamed and now goes through
verify_history(madepub(super), with a test-onlycommitsaccessor). 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.f36: the resolver tracks declared humans alongside
-
PA.f41 (introduced by Cut 6, medium, fix):
turnrecords 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 thePersonaTurnBindingis built from it (:4264-4276). Soul's forged opportunity (a newworld_id,revision999999, and emptyaffordance_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_invocationassembles, 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:
turnrefusing an operational subject (MA survived);turnrefusing 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 nominted_atis 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 isacquired_at > last_acted_at. - It rides
consumer.v6, because no v6 store exists anywhere yet. - A decline pushes no
DecisionEventand does not move the marker. That is accepted: the agent reads the table, not the marker.
- 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 (
-
PA.f45 (pre-existing, low, settled by wording): the owner's world
briefgoes 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,inferandselectrepeatControllerRunner's versions line for line.- The 13-line
PersonaTurnBindingconstruction (:4264) copiesrun_persona's (:3681). - The checkpoint integrity check (
:895-913) rebuilds the Persona request outsidebuild_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) and55f8bc6(PA.f42, PA.f43). Lib 560 → 570.PersonaLane::selectrefuses 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_selectionandrecord_persona_turnhave one body each. The checkpoint check callspersona_inference_request.Knowledge.acquired_at(=state.revision + 1) is written by every knowledge writer:AcquireKnowledge,Communicatefan-out,Displayfan-out andWitness. The marker isacquired_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
turnwith the samecommand_idis not idempotent, andPersonaLanehas no guard. Cut 8 derives a freshcommand_idfor 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::RuledtoFactStandingView::Canonicalinsnapshot(), so that subjects experience a ruling as world truth (P3.4). But that mapping is shared by every reader, includingtable_view, whose spec printsruled.- Ruling (Self, under the standing go):
FactStandingViewgainsRuled, andtable_viewprints it. - Every subject-facing rendering (the Projector, which never reads
standing, and the typed view) renders
Ruledexactly asCanonical, so P3.4 is unchanged. - The evidenced-knowledge clause keeps reading
FactStandingitself.
- Ruling (Self, under the standing go):
-
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_authorityall 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.
- P4.1: FALSIFIED by PA.f48. The kernel side holds: the clock,
commitments and
- What was checked:
- Knowledge rows are written only by
AcquireKnowledge,Communicate,DisplayandWitness. acquired_atis 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_narrativeissues byte-identical requests atc4334ccand55f8bc6.verify_historyandcommits_for_testcannot be reached from outside the crate.
- Knowledge rows are written only by
- PA.f48–PA.f51 are fix, in library batch 7 after Cut 8a lands. PA.f52 is recorded.
- Verdicts:
-
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) andRelocate's restricted-route check (patch.rs:3820) read it.- Soul's probe: retire the treasury, then grant the reeve levy. The
resolver refuses with
OverlappingJurisdictionagainst 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_authoritydoes. 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.
- Soul's probe: retire the treasury, then grant the reeve levy. The
resolver refuses with
-
PA.f49 (introduced by fix batch 6, low, fix): rows seeded at genesis get
acquired_at = 1. Genesis applies atstate.revision = 0and its commit resolves to revision 0, but the stamp isstate.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 checkacquired_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 withtruefails 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_historystill passes with the effect-equality check disabled (journal.rs:985). It recomputes the forged commit's digest but leaves the head'slast_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:1037still describes the marker asminted_at. Update it.
-
PA.f52 (pre-existing, low, recorded):
ControllerRunner::run_narrative(pub) still accepts a scope-equal opportunity with a forged revision.exact_opportunityaccepts 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_viewomits ids thatPLAY_TOOLStake. It prints no commitment keys (needed bydischarge_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 canonicalfloodfact and thehornchannel were absent, which means the agent cannotwitnessan unheld fact.- Fix: the table view prints every entity and row a
PLAY_TOOLStool references: all facts with their standing (PA.f47'sRuledincluded), commitments with keys, pressures, channels, dependencies, and persona material by subject.WorldSnapshotgains whatever the omniscient view needs, readable only throughtable_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.
- Fix: the table view prints every entity and row a
-
PA.f54 (introduced by Cut 7, high, fix): there is no public way to join decoded calls into one patch.
WorldPatchfields arepub(crate), anddecode_authoring_callreturns 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_toolsemitsrecord_needandfinish_without_proposal, whichdecode_actor_callrefuses, 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_toolsanddecode_actor_calltake the same prefix, so a nameactor_toolsemits decodes as emitted.- One decoder.
-
PA.f56 (introduced by Cut 7, medium, fix):
describe_refusalis wrong and thin.EmptyEvidence.positionis an evidence-list index, but it is printed as "declaration tool call #N".WrongKinddrops the expected and actual kinds.CustodyNotConservedprints no site.- The
RefNamebranch is unreachable. - Operation indexes are not call indexes.
ActionRejectednever reads the entry's preconditions, so the player sees "precondition #0 failed", not "you are not at the cellar", as PA-Q13 A describes.TargetKindMismatchprints RustDebug.
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
Displayfallback prints principal ids and internal store or journal errors.- Ruling (Self, under the standing go): two functions in
table.rs.describe_refusalis for the agent and keeps full detail.describe_refusal_to_actorreturns 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.
- Ruling (Self, under the standing go): two functions in
-
PA.f58 (introduced by Cut 7, low, fix):
decode_actor_callsilently drops a non-stringdisplayand atexton a non-speech entry. Fix: refuse both. Also, the negative pin greps onlycontrollers.rsfortable_view. Fix: scan every library source file excepttable.rsfor atable_view(call. The structural guard isPersonaLane'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_nameis 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 helpersarguments_foranddecodescopy 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()andintegrity_was_violated()arepub(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-onlydisposition()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) and2035fe8(PA.f59). Lib 582 → 597.WorldSnapshotgainschannelsandfacts, the omniscient fact list.SubjectSnapshotgainsdependencies.table_viewprints channels, commitments, pressures, persona material, dependencies and every fact with its standing, in plain words.FactStandingView::Ruledis printed by the table. The typed view still renders it ascanonical.decode_authoring_callsreturns aDecodedBatchwithsite_calls.decode_actor_calltakes the same prefix asactor_tools, which emits granted entries only.describe_refusalis for the agent;describe_refusal_to_actoris the narrow form.InferenceFault::disposition()is public.- The shared-decoder test now compares every tool as whole patches.
play.rschanged call shape only (an empty prefix; the agent-facing refusal). 8b switches both.- Discrepancy:
relocatetakes a route, not a place, so the batch test references a draft place fromdeclare_routeinstead.
-
PA.f61 (introduced by the PA-Q7 list, high, fix): two tools on the ruled
PLAY_TOOLSlist break ruled invariants in substance.communicatenever checks the speaker against the caller (patch.rs:4652-4700) and writesTold { by: speaker }. So the agent, as Play, can make any actor "tell" an invented fact with noExerciseDecisionand no quoted span, which breaks invariants 3 and 4.set_persona_materialon 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):communicateleavesPLAY_TOOLS. A world event with no speaker iswitness. Anything a character says comes from dispatching that character and quoting it.set_persona_materialstays, 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 whosesubjectis not a draft reference.- An existing character's feelings follow from what it perceives, which
is knowledge the agent may give it through
witnessandacquire_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.
runresumes only whenturn_idequals 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.playrequest goes to the open turn: it is the answer when the turn isAwaitingPlayer, and a continue when it isRunning. If no turn is open, or the stored turn isClosed, the request starts a new turn, whoseturn_idis 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.
- Ruling (Self, under the standing go): the turn's identity is
separate from the request key.
-
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", andrebuild_conversationnever readspersona_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
prepareon every attempt, and the turn staysRunningforever. Fix:- The table refuses an empty or whitespace answer before recording it.
- An inference that fails
prepareorinferbeyond its retries closes the turn with the fault recorded, instead of leaving itRunning.
-
PA.f66 (introduced by Cut 8a, medium, fix): several loop defects.
- Exhausting the round budget leaves the turn
Runningwith no narration, permanently, which contradicts the spec. Fix: it closes the turn and narrates. - A kernel
Invariantleaves the turnRunning. Fix: it closes the turn with the fault recorded, and the in-memory refusal stands until restart. dispatchedis 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_slotuses list position, not the call index. Fix: derive it from(turn_id, round, call_index, subject).
- Exhausting the round budget leaves the turn
-
PA.f67 (pre-existing in
SourceSpan, medium, fix in its owner):SourceSpan::locateis a rawfind, so "I can" is accepted as a span of "I cannot go". Fix inghostlight-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):
PlayTurncarries no world id and the store key is fixed at"primary". Two concurrentruncalls interleave writes to the one row. A narration that keeps failing leaves the turn unresolved.narratedraws 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.
narratedraws 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_narrationmust assert the narration.
-
PA.f70 (info): Dungeon cannot submit the player's act under Play, because
PlayPorthas no door forExerciseDecision. 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) and275b0f2(PA.f67).- PA.f48: the resolver's
candidate_effective_authorityskips retired institutions through the retired set it already threads. That is the same rule asdelegated_authority. - PA.f49: the apply path takes an explicit
resolves_to, so genesis stamps 0.verify_state_shapechecksacquired_at <= revision. - PA.f67:
SourceSpan::locaterequires 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.
- PA.f48: the resolver's
-
Library fix batch 7c landed (Hands, Sonnet, 2026-09-22):
dc43be5(PA.f71, PA.f72),6d68974(PA.f73, PA.f74) ande383cde(PA.f75).- PA.f71:
decode_actor_callrefuses a name that doesn't carry a non-empty prefix. The bare-kind fallback is gone. - PA.f72:
precondition_roleskeeps the channel role ofCanBroadcastandCanReachthrough a newaudience_role. A roleless precondition gets its own plain wording indescribe_refusal_to_actor. - PA.f73:
describe_refusalhas dedicated arms forUnresolvedDraft(handle and expected kind) and forDuplicateHandle(both calls).EmptyEvidencemaps to its call through the newDecodedBatch::evidence_calls. - PA.f74:
table_viewprints 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 exactcommand/indexpairing. The library test's tool list is cut to 24 tools (Dungeon'sPLAY_TOOLSstill holdscommunicateuntil 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 31KernelErrorvariants; 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_rowhelper before committing. - Lib 603 → 618.
- PA.f71:
-
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.rsreadsWorldSnapshot.factsand.channels, bothpub(crate). Rendered subject surfaces carry no unheld fact and noruled. - Invariant 8: HOLDS for
describe_refusal_to_actor, across everyActionMismatchand the sampledKernelErrors. - 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_callfor every tool.
- PA.f71–PA.f75 are fix, in library batch 7c after 7b. PA.f76 is recorded.
- Verdicts:
-
PA.f71 (introduced by 7a, low, fix):
decode_actor_callfalls back to the bare name when the prefix does not match (table.rs:237,strip_prefix(..).unwrap_or(kind)). So a nameactor_toolsnever 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,NoAudienceand 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 ofCanBroadcast { via: Channel }and ofCanReach'svia. 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_refusalloses detail on its generic path (table.rs:592-610).UnresolvedDraftdrops the handle and the expected kind.- A
DuplicateHandlesplit across two calls names neither call unambiguously. EmptyEvidenceis 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'sauthorizedandhas_standingchecks need them, and so does a new route'srestricted.requires. The commitment kind is still RustDebug(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, andcontains("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
KernelErrorvariants. - 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.
- In
-
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 straytextthere is now a recorded need, not a captured proposal. Strictness is correct for every lane, and no pre-v17 checkpoint exists to diverge.display: nullis refused. -
Soul on fix batches 7b and 7c (Opus, 2026-09-22, against
a6ceaca, code ate383cde, 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_callsandsite_callsfill in one decoder loop, but two of its tests pass trivially (PA.f79). - PA.f75: HOLDS.
KernelErrorhas 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;
communicateleaves Dungeon'sPLAY_TOOLSin 8a-fix, as already planned. - Soul's note that
describe_refusal's call naming has no live consumer and thatturn.refusalholds the agent-facing text is already queued: 8a-fix switches todecode_authoring_calls, and 8b shows the player onlydescribe_refusal_to_actor.
- Verdicts:
-
Library fix batch 7d landed (Hands, Sonnet, 2026-09-22):
4fea8fd(PA.f78),9b91940(PA.f77, PA.f79, PA.f81) and3c2bdf3(PA.f82).- PA.f78:
SourceSpan::locateaccepts a span only on UAX #29 word boundaries, viaunicode-segmentation1.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.
- PA.f78:
-
Play loop fix 8a-fix landed (Hands, Sonnet, 2026-09-22):
a5329a9(library:WorldPatch::sets_persona_of_existing_subject, PA.f61's seam) and029970a(Dungeon, everything else in one commit; Hands found the A–F split too entangled to make).- PA.f61:
communicateis offPLAY_TOOLS, which now has 24 tools matching the library list.set_persona_materialon an existing subject is refused through the library predicate. - Actor prefix:
decode_actor_callowns the strip. Hands also found thatround_toolsemitted 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_callis 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.faultset. - PA.f66: a spent budget closes and narrates, and an
Invariantcloses with the fault.dispatch_command_idis 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
Invariantclose 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.
- PA.f61:
-
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
communicateremoval. - 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_retiredas the resolver's one liveness predicate.
- PA.f61's
- Survived: S6, S4,
communicatere-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.
- Held:
-
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_idK1, andderived_command_idreproduces K1's old command ids. Probe: a newdeclare_resourcewas refused as a reused command id.- Ruling (Self, under the standing go):
turn_idis 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.
- Ruling (Self, under the standing go):
-
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
AwaitingPlayerexposes aquestion_id, derived from(turn_id, round, slot). runtakes a request oftextplusanswers: Option<QuestionId>.- While a question is open, a request whose
answersmatches it answers it. A request with noanswers, 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
answerssent when no question is open is refused. - 8b carries
answersthrough the Eve schema.
- A turn that is
- Ruling (Self, under the standing go):
-
PA.f85 (8a-fix, high, fix): any deterministic failure while building the request, snapshotting or narrating returns early through
?. That leaves an unrecoverable turn inRunning(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). Mintingturn_idremoves the non-UUID trigger. -
PA.f86 (8a-fix, medium, fix): S6 survives, because the restart tests assert the stored
opening_promptrather than the request that was sent. Fix: change the world between rounds, then assert the resumed request's openingUserText. -
PA.f87 (8a-fix, medium, fix): the S4 test compares positions in the source text, and it finds
record_call, notpersist. 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_TOOLSto the pinned list of 24 tools. Fix: the list gets one owner. The library doesn't own Dungeon's policy, so Dungeon'sPLAY_TOOLSis 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::locateusesmatch_indices, which skips overlapping candidates, solocate("Juno no no", "no no")returnsNone. 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.
- Ruling (Self, under the standing go): recorded, no fix.
-
PA.f92 (8a-fix, low, fix):
sets_persona_of_existing_subjecthas 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.
- Ruling: no substitutable submit trait is added just to force an
-
PA.f94 (pre-existing, medium, fix): inference retries ignore
InferenceFault::disposition(). There are 12 immediate retries, even onIntegrityViolation. Fix: onlyRetryableretries, with bounded exponential backoff.RecoveryRequiredandIntegrityViolationclose 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_turnandbegin_turntake 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.refusalholds agent-facing detail; 8b gives the player only the actor form.- Concurrent
runcalls 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) and74b628d(Dungeon: PA.f83 to PA.f97 in one commit, one state machine).turn_idis minted per turn.KeyLedgerspans the open turn and the last 64 closed turns.PlayRequest { text, answers: Option<QuestionId> }, with stale and running refusals typed.- One
close_with_fault, andRoundInferErroris 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_TOOLSis pinned exactly. - Hands reports two limits:
- The handle-collision test drives
find_handle_collisiondirectly, becauseSubjectIdhas no public constructor. - PA.f96 compares each Persona's inference
request_idacross reordered dispatch lists. Dispatch is inference only, so no kernel command id exists to compare.
- The handle-collision test drives
- 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: Noneaccepted, 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.
- Held:
-
PA.f98 (8a-fix2, high, fix):
dispatchby the idtable_viewprints dispatches nobody, silently.parse_dispatchnow compares against the 8-hex handle, and it drops anything unknown.- Ruling (Self, under the standing go):
dispatchtakes subject ids exactly astable_viewprints 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.
- Ruling (Self, under the standing go):
-
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 differentturn_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: Noneis refused while a question is open. -
PA.f102 (8a-fix2 gap, medium, fix): tests that
RecoveryRequiredmakes exactly one inference call, that a persistentRetryablefault 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 wasend_turnwith a Persona script that yields no narration. - Document that store or persist errors cannot be recorded in the turn and propagate.
- Test each close-with-fault site: snapshot failure, narrate failure
after
-
PA.f104 (pre-existing, medium, fix): the first pass and a resume disagree.
dispatchedis 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.
SubjectIdisserde(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.
- A wiring test through
-
PA.f106 (8a-fix2, low, fix in its owner):
locateis 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 repeatedfind. 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_idis written but never read, and a replay returns a bareOk(()). Fix: a replay returns a typedReplayed { 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_INSTRUCTIONSsays each round is a freshtable_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) and754c7fe(Dungeon: PA.f98 to PA.f109, PA.f111).dispatchtakes 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
dispatchedfollows call order. locatewalks the word boundaries, about 4x faster (64k: 38.7 → 9.1 ms).- A replay returns
RunOutcomewith the turn id. Empty text opening a turn is refused. - Seam for PA.f99: a
#[cfg(test)]before_submit_hookonPlayTablethat 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
dispatchto whattable_viewprints. The id spelling has four copies, because the library'sid_textispub(super). Changing whattable_viewprints 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.rsexposes the one printer and its parser publicly,table_viewprints through it, and Dungeon'sfull_id_textand the test copy are deleted. The dispatch test reads the id out of a realtable_view(bracketed_id_after, which the file already has).
- Ruling (Self, under the standing go): the library owns the id
spelling.
-
PA.f114 (8a-fix3, medium, fix):
parse_dispatchdrops non-string entries, a malformed body and a missingsubjectskey in silence, which is whatPLAY_INSTRUCTIONSpromises it will not do. Fix: each is refused, quoting what was given. -
PA.f115 (pre-existing, medium, fix): deleting
span_is_exact'sactor.subjectfilter 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::containslost its production caller and survives only through the eviction test. Fix: delete it and test eviction throughturn_id_for. Also narrow#[allow(dead_code)]onmod playinmain.rsso 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_worlddiscardsWorldMailbox'sJoinHandleas_owner; keeping it and calling.abort()makessnapshot()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):
locateis 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
Vecand binary-search the end, instead of allocating aHashSetper 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".
- Compare the first byte before
- The bound is accepted: a quote is a span of the prose that was just produced.
- Fix:
-
PA.f121 (pre-existing, low, fix): two
locaterules have no test. Reversing the boundary walk returns the last candidate rather than the leftmost, and removing the empty-quote guard locates0..0. -
PA.f122 (8a-fix3, low, fix):
skip_retry_delay_in_testis acfgswitch, so production'sretry_delaypath 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_hookstays#[cfg(test)], with a comment saying why. -
PA.f123 (pre-existing, low, fix):
execute_dispatchis 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::turnwrites 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):
340d583and7bede2f(PA.f113, PA.f114),5af2a18(PA.f115 to PA.f117),d042dc7(PA.f118, PA.f119),a395210(PA.f122),91e80d3(PA.f123),5b9671b(PA.f124) and9a2cb2e(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_dispatchpersists its own recorded Persona turn.locatewalks 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::NoPlayerOpportunityhas no producer until 8b.- Reported discrepancies:
- The resume path's collision check has only a source-presence test,
because
execute_roundtakes no caller-supplied snapshot. - Hands' first PA.f123 test passed with the fix deleted; it fired the hook at the dispatch site instead.
- The resume path's collision check has only a source-presence test,
because
- Dungeon bin 113 → 122, lib 622, persona-projection 32 → 35. The retry-budget test now takes about 36 s of real time.
- The library owns the id printer and parser. Dungeon's copies are gone,
and the dispatch test reads its id out of a real
-
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_matchesis the printer's literal inverse, and the dispatch test reads a real render), PA.f114 throughexecute_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.
- Held: PA.f113 (
-
PA.f125 (8a-fix4, high, fix, blocker): PA.f123's persist made a worse window.
execute_dispatchpersists atplay.rs:1520, but theDispatchcall record is written afterwards byexecute_round. A crash in between leavespersona_turnsdurable 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_subjectsthen offers that Persona's actor tools whilespan_is_exactchecks against prose the agent never saw, so every speak for it is refused for the rest of the turn. Fix: theDispatchbody is recorded beforeexecute_dispatchruns, 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 writespersona_turnsanyway. 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 oneround_snapshot()owner that bothinfer_roundandexecute_roundcall. That collapses the two sites and deletes the grep test. -
PA.f128 (8a-fix4, low, fix): the ledger test rules out
front()but notback(): 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_forstill carries its own paren strip, so PA.f113's "no copies remain" is not true. Fix: derive the handle fromid_text. -
PA.f130 (8a-fix4, low, fix):
PlayError::NoPlayerOpportunityhas 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::owneris 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, sostarts_withnever 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
Dispatchbody is recorded and persisted inexecute_roundbeforeexecute_dispatchruns, which is the order every other door already uses. A newPlayTurn::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::NoPlayerOpportunityis deleted; Hands found no site that should raise it and would not changebegin_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.
- PA.f125: the
-
Cut 8b landed (Hands, Sonnet, 2026-09-22):
1b20a0f(play.rs: theround_snapshot()owner, the actor-form refusal line, concurrent dispatch,run_lock) anda99fdfe(runtime.rs,eve.rs:AppState,GHOSTLIGHT_PLAY_MODEL,world.playrouting,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_snapshotseam, and the source-presence test is deleted. turn.refusalisdescribe_refusal_to_actoragainst the real affordance snapshot. Its test asserts no id or label leaks (invariant 8).- Dispatch runs concurrently under
controller_permits, andclose_turn's narration draws a permit too. - Hands added
claimed_this_calldedup, because concurrency broke the oldpersona_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 inAppState. This daemon runs one world per process, and a second lock over the same decision would be a second owner. STORE_KEYstays"primary", because eachPlayTableowns 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.
- The per-world lock lives in
- Dungeon bin 122 → 128.
PlayPort::newstill has one production site.
- PA.f127 and PA.f133 are closed: one
-
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_snapshotas one owner, the refusal line (describe_refusal_to_actortakes 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 concurrentworld.playcalls 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_playcall 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.
- Held: PA.f125 and PA.f126 (including a crash mid-dispatch with several
subjects, and a same-call duplicate),
-
PA.f134 (Cut 8b, critical, changes Cut 9):
ask_playerwedges the world. Answering needsQuestionId { turn_id, round, slot }, butturn_idis minted insidebegin_turnand emitted nowhere:RunOutcomecarries it only onReplayed, and the route returnsacceptedand drops the outcome. So no client can buildanswers, every later request isStaleAnswer, 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 bindsanswersfrom the question id it is showing. RunOutcomenames 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.
- Ruling (Self, under the standing go):
-
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::narratecommits nothing.- Ruling: Cut 9 does not delete
world.speakand the story card until its own verification shows a turn closing with its narration visible through the new card.
- Ruling: Cut 9 does not delete
-
PA.f136 (8a-fix5, medium, fix): dispatch is the only door that does not resubmit its recorded body.
parse_dispatchre-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 inpersona_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, soexecute_roundandinfer_roundcallingfetch_round_snapshotinstead 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 callstable.runpasses. 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.ccis 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_permitsnow 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.playspawns a task that may park onrun_lockfor 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 withTurnStillRunning. -
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.refusalis 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 fromruntime.rs, and "advertised" overstates the Eve change:world.playis 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 inapplied_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::RanandReplayedcarry the turn id and the open question, and so doEmptyAnswerandStaleAnswer. The newPlayTable::current_turn_view()is the door Cut 9's card reads, since the route answers beforerunresolves. 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_worldparsesidempotency_key.unwrap_or("")as aCommandIdbefore 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.
- PA.f134's half:
-
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'stry_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.
- 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 (
-
PA.f148 (Cut 9, blocker):
eve.rs:78-83folds the play row's revision into the surface document'sversion, andruntime.rs:1187reads that number back assourceVersionand derivesexpected_revision = source_version - 1for 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 countssourceVersionitself 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(pin672c0c1) over whatauthenticated_surfaceemits and got{"bindings":{}}forworld.playand forworld.create.local_draftemits{scope, key, type}, whichEveStateBindingDescriptordoes not have, so the binding resolves to the name"world.play.answers"whilecaptureBindingsasks for"answers".- Even on a match, the client sends
payload.bindings = {…}, and Dungeon's payloads are flatdeny_unknown_fieldsstructs. Nothing unwraps it.
-
PA.f150 (Cut 9, blocker): the fold does not wake the SSE path.
publish_projectionsends the unfolded world version and is called only at startup and from theeve_commandOk 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: trueis 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.summarystill 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 everyPlayErrorinto a log, after the route has already answeredaccepted. The errors PA.f134 taught to carry the open question have no caller.EmptyOpening,StaleAnswer,NoQuestionOpen,TurnStillRunningandPoisonedall look like success to the player. -
PA.f154 (8b-fix, medium): an answer arriving while
runstill holds the lock between breaking its loop and returning getsTurnStillRunning, and PA.f153 then discards it.try_lockis 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:2918and the target doc;PlayTurnView::turn_idandstatehave 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_timeandworld.seed, andbegin_turnresolves 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
distand re-spells nothing; the round trip posts throughapi_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, noanswerscontrol); 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.
- Held: the submodule pin is unmoved; the bridge imports the vendored
lowering's own
-
PA.f161 (Cut 10, high, blocker): the fold moved out of the document and into its consumer.
runtime.rs:1233compares the client's echoedsourceVersionagainstauthenticated_wake_version, which issurface_version + play.revision. Both the vendored lowering andweb/src/main.tsechodocument.versionalone, and any row that has ever held a question hasrevision >= 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_versionat the moment its question opened. An answer is stale when the client'ssourceVersionis 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.playRevisioneither gains this reader or goes (PA.f165).
- Ruling (Self, under the standing go): the play row records the
world's
-
PA.f162 (Cut 10, high, blocker): the round-trip test shells out to
nodeand needsjsdomandajv, which nothing in Ghostlight declares or installs. On a clean checkout it fails. Idunn's Linux gate runscargo test --lockedin 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 againstnode_modulesleft 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-opsrunbook gets that dev job. The fixture without the regeneration check would be a copy of the client's behaviour pretending to be the client.
- 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
- Ruling (Self, under the standing go):
-
PA.f163 (Cut 10, medium, fix): PA.f150's test is vacuous. Deleting the spawned task's
publish_projectionleaves the whole suite green, because this same cut made admission synchronous, sodispatch_world's own Ok arm already published. The test asserts only thatrecv()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.createform filled as labelled is refused with "missing fieldtargets".lens_weightscarries an authoredvalue, which the lowering captures;targetsandjurisdictionscarry onlyplaceholder, 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):
playRevisionhas 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:681still describes the fold Cut 10 undid;local_draft's doc callsaccessModeinert when the lowering disables a control without it;unwrap_bindingsdiscards 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.mdforbids 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):
4155823in Ghostlight, and42a477dingamecult-ops(the runbook's dev job).- PA.f161:
OpenQuestioncarriessurface_version, set fromeve::surface_versionwhen the question opens and cleared when it is answered. The route compares only against that.ScriptedPortand its helpers widened topub(crate)for tests, as authorised. - PA.f162:
crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.jsonholds the real intents, stamped with the vendor revision672c0c1e9fc8…. The gate test replays it with no node. The regeneration test diffs live against committed (ignoring the key and the timestamp), checks the stamp againstgit -C vendor/eve rev-parse HEAD, and printsSKIPPEDwith its reason when the bridge is absent. - Hands proved the clean-checkout claim: it renamed every
node_modulesin the tree, strippednodefromPATH, and ran the compiled binary. The gate test passes; the regeneration test skips aloud. - PA.f163 to PA.f168 are done.
playRevisionis 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
accessModefix has no test. The bridge sets values through jsdom and dispatches events directly, so it bypasses thedisabledattribute 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.
- PA.f161:
-
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 behindeve_client_bridge_is_available(). Withnodeoff the PATH the suite is 149 passed, 2 failed, and the same without the submodule. Thegamecult-opsrunbook 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.
- 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:
-
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:
findAuthoredBindingValuewalks the live tree. - A node whose renderer ignores
props.valuecan carry an authored value that is captured and never appears in the DOM. Nohiddentrick is needed, and the lowering supports none. - Simpler still: a button's own
props.actionobject is spread verbatim into the payload, so any field the server puts there rides back with no binding machinery at all. routeHintis hardcoded tosourceVersionandtransport; nothing else passes through.web/src/main.tsis 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'sdeny_unknown_fieldsand byunwrap_bindingsrefusing 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
QuestionIdand 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. PlayPayloadgains the token. An answer whose token is absent or does not match the open question's is refused as stale.question_surface_versionand 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.
- Each question mints an opaque random token and the row keeps it.
It is not the
- A binding with no matching node anywhere is not captured:
-
PA.f171 (Cut 11, medium, fix): the gate has no drift protection. Removing PA.f164's authored
valueleaves 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_versionwas added with no#[serde(default)]whileSTORE_SCHEMAstayed…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 setsplay = None: a world with no play card, andworld.playdenied 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_faultleavesquestionandquestion_surface_versionset, though the doc says every path clears them. It is unobservable, becauseopen_question_idreturnsNoneunless the state isAwaitingPlayer. Fix: clear them, or correct the doc to say what actually guards it. -
PA.f174 (Cut 11, low, fix): the
accessModerule 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_roundtakes 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_oncegoes on to open the vault and sweep against real inference, none of it exercised.GHOSTLIGHT_SEED_VAULT_ROOTappears nowhere indeployment/and nowhere ingamecult-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):
7052644and241b33ein Ghostlight,5e341f8ingamecult-ops.- PA.f169: all three bridge tests are guarded and print
SKIPPED. Hands verified it the way Idunn would, by rebuildingPATHwithout node: 156 passed, 0 failed. Removing one guard reproduced Soul's two failures exactly. - PA.f172:
STORE_SCHEMAis…play_turn_store.v2, and av1row 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 asplayStatusin 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 deletesquestion_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.
- PA.f169: all three bridge tests are guarded and print
-
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'sprops.action, the same DOM-invisible channelcommandalready rides. An answer whose token does not match the open question's is refused. question_surface_version, its comparison, and the extra snapshotexecute_roundfetched only to compute it are deleted.QuestionIdandOpenQuestionare kept:QuestionIdis stillplay.rs's own admission key, and neither existed only for the comparison.unwrap_bindingsmerges 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'sdeny_unknown_fields.- The store is
v3, readingv1andv2leniently through oneupgrade_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.playclick 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.
- Each question mints a
-
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()checksnodeand a committeddist/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 onjsdom, and still 4 failed onajvafter 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_keysandfaultwere added toPlayTurnwithout#[serde(default)]while the schema stayedv1, so an olderv1row fails to decode even throughupgrade_legacy_row: "missing fieldapplied_keys". The play table then does not open, the card vanishes, and onlyplayStatussays why.- Ruling (Self, under the standing go): the legacy reader is
genuinely lenient — every field added since
v1defaults 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.
- Ruling (Self, under the standing go): the legacy reader is
genuinely lenient — every field added since
-
PA.f178 (Cut 13, low-moderate, fix): with no question open,
payload.answer_tokenis 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_ofand the gate both fabricate an empty token throughunwrap_or_default(), so a missing token degrades to "the empty token is valid". Fix: compare theOptions directly, so absence refuses. -
PA.f180 (Cut 13, low, fix):
unwrap_bindingsflattens 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 oneve.rsnever 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 andadmit. 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):
58324f8and0808c6ain Ghostlight,86ba7bcingamecult-ops.- PA.f177: every field added since
v1defaults 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-swapswap_inuses, and warned about with its path, in the same stream asplayStatus. 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_admitscompares theOptions, so absence refuses. PA.f180:ACTION_ONLY_PAYLOAD_FIELDSkeeps 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_modulesremoved: 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_modulesto reproduce the bug live (166 passed, 4 failed). Nothing was reported as unmutatable. - Commits are grouped by file, because the items interleave inside
play.rsandruntime.rs; each message enumerates what it carries. - Dungeon bin 163 → 170.
- PA.f177: every field added since
-
PA.f183 (pre-existing, low, recorded):
seed_once'srunner.sweep(1)renders aControllerErrorto the player throughto_string(), andInference,ProviderContractandWorkPersistencecould carry internal detail. The type is shared by many routes, so its player-facingDisplayis 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 -iwithPATHlimited toC:\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.
- 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
-
PA.f184 (Cut 14, medium, fix): the retirement fallback is wired only into the
v1andv2arms. The current-schema arm and the catch-all stillbail!, 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 toPlayTurn's fields without movingSTORE_SCHEMAmakes 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):
opentreats all ofupgrade_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 ownreasonsays 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 letupgrade_legacy_rowdistinguish its exits. -
PA.f186 (Cut 14, medium, fix): retirement inverts the signal PA.f172 added.
playStatusis derived fromstate.play.is_some(), so a successful retirement reports"ok"while the player's turn is gone, and the only trace is onewarn. 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::Controllercannot be reached throughadmit, 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
openfailing, 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
gitrequirement the same test then asserts. Soul hit it by accident with a copiedvendor/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.tssetspollMs: 0and 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
.ccinto 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) and1dea8f5(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:
playStatusreads "a play-turn row was retired to " instead of"ok", from a newPlayTable::retired_row_sidecar(). - PA.f187:
describe_seed_failurenames 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.
- PA.f184: every arm retires, including the current schema and an
unrecognised future one. PA.f185:
-
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
okandunavailablecases; PA.f187 fully, sincedescribe_seed_failurematches 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/evea plain copy. Library 622.
- 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
-
PA.f193-A (Cut 15, medium, fix): the current-schema arm routes every
Errinto retirement, includingread_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 HEADdoes 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, andadmitrefuses 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):
playStatusreports 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.f188names 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):
- Split
read_current_schema_row's exits asupgrade_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. - Check the checkout with
git -C vendor/eve rev-parse --show-toplevelagainstvendor/eve's own path, orvendor/eve/.git. Build the fixture inside agit initroot, or the test certifies nothing. - Make a skip visible without
--nocapture. The workstation and the Linux release currently report the same number for different amounts of verification. - The hint gates on the run lock, not on the state alone; and the
TurnStillRunningtext stops contradicting itself. - The sidecar becomes readable: JSON, with the payload verbatim in
base64 beside it. A
.msgpacknobody can open is preservation in name only. playStatusderives the retirement from the sidecars on disk, so it survives the restart that follows it.- Renumber the duplicate PA.f188.
- Split
-
Target gap, for the reconciliation:
notes/ghostlight-dungeon.mddescribes 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-MarkdownVaultEvidenceSourcerather than the genericVaultProvidercontract, and noCanonCandidate; 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,d082cd2anddb4f574.- PA.f193-A and PA.f193-B:
read_current_schema_rowsplits intoCurrentSchemaRowError::{Decode, Other}, mirroringupgrade_legacy_row. OnlyDecoderetires; 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-toplevelagainstvendor/eve's own canonical path. The new test builds a realgit initroot with a copiedvendor/eveinside 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--ignoredopt-in, and their unavailable branch now panics instead of returning quietly. Cost: they no longer run automatically on a capable workstation. - PA.f193-D:
PlayTurnViewgainedrun_in_progress, a non-blocking peek at the run lock, and the hint gates onRunning && !run_in_progress. Lock contention became its ownTurnBusywith accurate text, soTurnStillRunningis 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_sidecarscans 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.
- PA.f193-A and PA.f193-B:
-
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-toplevelcomparison, sound against case, separators, symlinks and a git worktree; skips visible as179 passed; 4 ignored, with Idunn's gate unaffected by the ignore itself;--include-ignoredfailing loudly with remediation text; the sidecar decoded byte-exact outside the process, twice;playStatussurviving 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
v4row holding an open question and its token:opensucceeds, the sidecar is written, the turn is gone, and the sidecar'sreasonclaims 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_rowitself, as a precondition: the caller has attempted a decode and it failed.
- The rule is written at the two call sites that already obey it. It
belongs on
-
PA.f194-B (Cut 16, high, operator-facing, owed first): the runbook's weekly drift check is
#[ignore]d, so run verbatim it reports0 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
gitis on PATH, so an uninitialised or copiedvendor/evenow 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_sidecarhas no production consumer left, andretire_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
okfor nothing, areasonfield 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.jsexists, therefore the bridge works.rev-parseexited 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 printedok, 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.
- 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
-
Cut 17 landed (Hands, Sonnet, 2026-09-23):
abb84b4and07aae19in Ghostlight,9528ef8ingamecult-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 reportedokon nothing. The paragraph describing the oldSKIPPEDbehaviour 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.gitmetadata at all. Sovendor/evein 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 checksvendor/eve/.gitas 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.resolvingrow gated onrun_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_sidecarand its plumbing are deleted; readiness uses the disk scan. - Every mutation killed. Dungeon bin 182 passed, 4 ignored, and 186
with
--include-ignoredwhere the bridge is built.
- PA.f194-B: both documented commands carry
-
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 matchinggit -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.rsitself and agrees:git ls-treecarries no.git, and the parser refuses a path component named.gitanyway ("Git tree contains forbidden .git metadata"). A gitlink is cloned to a separate cache root, verified, materialised through the same raw path and deleted. Sovendor/evein 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-ignoredprinted zero skip lines on a real checkout. - PA.f194-A: Soul swept every write in
play.rs. The fourcompare_and_swap_batchsites 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.
- Soul ran the runbook's drift check verbatim:
-
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--ignoredrun. Fixed ingamecult-ops5d48e43. -
PA.f195-B (Cut 17, trivial, owed):
07aae19's message says the tuple plumbing went with the field;play.rs:504still destructures a_retired_sidecarevery 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 same182 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.v1isadditionalProperties: falseover exactlyschema,receipt,transientProjection,pluginPayloadanddraftDirective, 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/evecheckout 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.
- 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 —
-
PA.f197 (pre-existing, critical, fixed 2026-09-23,
94ab279): the next wall, found by the operator on the retry. Sign-in answered "unknown fieldowner, expected one ofschema,boundaryId, ...". Dungeon decoded Heimdall's publishedheimdall.command_boundary.v1withdeny_unknown_fieldsover the seven fields it reads; Heimdall has publishedowner,lifecycleAuthority,healthPublication,forbiddenWritersandcompatibilitysince June (Heimdall758892d), and the strict reader arrived in August (3411c1c). Every Heimdall receipt also carriesschema, 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:4101on 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 unscopedview.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: usesubject_row, and sweep the test module for any other unscopedfindover the view. -
PA.f78 (introduced by 7b, medium, fix in its owner): PA.f67's hand-written word boundary (
is_alphanumericat each edge) is the wrong rule.- It accepts "I can" out of "I can't" (straight or curly apostrophe), a
span ending between
eand a combining accent, and "run" out ofrun_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-segmentationcrate. That is established tooling for exactly this question, replacing a hand rule that failed in three scripts. It is a new dependency: its owner isghostlight-persona-projection'sSourceSpan::locate, and its consumers are the Persona capture and gap paths, the Interpreter and Dungeon'sspan_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.
- It accepts "I can" out of "I can't" (straight or curly apostrophe), a
span ending between
-
PA.f79 (introduced by 7c, low, fix): two refusal-text tests pass trivially.
- The expected-kind test asserts
contains("place"), but its handle ismissing_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.
- The expected-kind test asserts
-
PA.f80 (introduced by 7c, low, corrected): a report and the cut map said
PLAY_TOOLSheld 24 tools. Dungeon'splay.rs:72still listscommunicate. 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 aDebugdump 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) repeatsmatches!(…Retired)instead of callingis_retired(lib.rs:3283). - The exercise revision is computed in
action.rs:243and again inresolution_revision.
Fix: one predicate, and one source for the revision.
- The resolver's retired set (
-
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.rsswitches 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
versionstays the world's ownsurface_version, becauseruntime.rsderives 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
sourceVersionfrom the served document rather than counting.
- The surface document's
- 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_commanduses. - 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
sourceVersionthe 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.playadmits the request synchronously — a replayed key, a stale answer, empty text, a busy turn — and answersdeniedwith 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.playdescriptor are owner-gated likeworld.advance_timeandworld.seed, and the turn's player subject follows the principal.world.summarystops 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; givePlayTurnView::turn_idandstatea 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) and43b6af6(eve.rs,play.rs,runtime.rs).- The binding shape was found, not invented: the vendor's own
host-isolation.test.mjsbuilds alocal-draftdescriptor under a surface id, andeve::local_draftnow 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 newworld_create_seed_activate_and_play_round_trip_through_the_real_clientdrives create, approve, seed, activate and play throughapi_router.world.createhad no control forbrief, 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_captureaccepts either. Hands flags this as reaching past the envelope into payload shape. - Coverage gap, reported: the stale-
sourceVersionrefusal and the round trip's answer leg have no test at theruntime.rslayer, because reachingAwaitingPlayerneeds an inference backend and the scripted port is private toplay.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.
- The binding shape was found, not invented: the vendor's own