Compare commits

..
Author SHA1 Message Date
Meta Vens
c43eb54962 Merge ghostlight-verse/dungeon-eve-quarantine: the Dungeon's Eve surface, web host and daemon are parked at parked/ghostlight-eve-surface until Thing 2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 00:11:58 +02:00
Meta Vens
2b9143d303 README: drop the browser JSON boundary
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 00:10:55 +02:00
Meta Vens
f21b5ff1aa Docs: smoke note, AGENTS and README state the braked-and-stopped truth
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 00:10:45 +02:00
Meta Vens
fec39c2010 Docs: handoff, smoke note, AGENTS and README state the parked, braked-and-stopped truth
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 00:10:17 +02:00
Meta Vens
0e9def0ebc Verse map: name the Eve submodule without a path the tree no longer has
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-23 00:00:05 +02:00
Meta Vens
ef11427e24 Docs and comments: README leads with the kernel and says the Dungeon is parked at the tag; AGENTS, status lines, kernel comments
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:56:56 +02:00
Meta Vens
f40bbaac13 Park the Dungeon: delete ghostlight-dungeon, web/, Eve bridge, vendor/eve, recipe and Eve interface doc (tag parked/ghostlight-eve-surface)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 23:54:33 +02:00
Meta Vens
ee62fec98a README: a world's expressiveness is its affordance catalog; a fresh world knows only Speak
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:34:52 +02:00
Meta Vens
eaefed1e7d Merge ghostlight-verse/readme-front-door: the README leads with the kernel and its entry points
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 19:29:46 +02:00
Meta Vens
56496c7108 README: lead with the kernel, its thesis and entry points; state the dungeon's status and the planned direction
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 19:29:10 +02:00
Meta Vens
aa56736841 Merge ghostlight-verse/narrative-resume-relowers 2026-10-17 20:09:01 +02:00
Meta Vens
f503c85484 Narrative resume: pin the Confirmed arm's identity check; interrupted no longer re-decides the scope move
The kernel's ScopeChanged is the only decider of a moved scope and same_decision the only decider of identity. Deletes interrupted's fresh-vs-row digest compare; adds a mismatch test and an unmoved-scope test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 19:44:35 +02:00
Meta Vens
e0344ff5af A resumed narrative row names its decision only; the kernel refuses a moved scope and the same prose re-lowers once
Deletes row_answers_caller and both awaits_world methods; run_narrative
compares only same_decision. classify labels OpportunityMismatch as
OpportunityGone. selection_refusal owns the grouped selection label and is
pinned for ScopeChanged and LeftBeforeSelection arms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:47:13 +02:00
Meta Vens
e2924bbc50 ghostlight-verse map: who refuses a resumed narrative row whose scope moved (spec narrative-resume-relowers.r1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:43:11 +02:00
Meta Vens
73f26a5a32 Merge ghostlight-verse/grouped-scope-moved-label (cut-grouped-scope-moved-label r1, report h1, Soul s1 holds) 2026-10-17 15:55:57 +02:00
Meta Vens
1d75f14578 Grouped lane: a member whose scope moved before selection is ScopeChanged
run_group's blanket Err(_) arm labelled a moved-digest member
LeftBeforeSelection. ScopeMoved now maps to ScopeChanged; the other
selection errors keep LeftBeforeSelection. Label only; spend is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 15:26:40 +02:00
Meta Vens
ee9e18a745 verse map: grouped lane mislabels a moved scope LeftBeforeSelection (no spend difference)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 15:25:20 +02:00
Meta Vens
771a79dde2 Merge ghostlight-verse/in-flight-gate (fca3ec1) into main
Cut ghostlight-verse:cut_spec:cut-in-flight-gate.r1, report h1, Soul s1 holds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 13:19:44 +02:00
Meta Vens
fca3ec1a3a controllers tests: fresh narrative selects with a moved digest read ScopeMoved
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 12:58:54 +02:00
Meta Vens
e2c3511cbd controllers: one owner for the operational in-flight gate; ScopeMoved from select_scope when only the digest moved
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-17 12:56:13 +02:00
Meta Vens
824a4dea15 verse map: who refuses an operational in-flight row whose scope moved (probe at 4741d50)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:54:22 +02:00
Meta Vens
4741d50898 create-flow: pin the begun receipt's worldId and step-id key sensitivity; stale comments
Test-only plus comment text. The receipt's worldId is asserted against the
world Begin made; step ids are asserted to differ for keys differing only in
their first byte. Two comments cite BeginPayload, not the deleted
CreatePayload; the ship-verb test comment cites the standing ruling.
Trim mutants of the title, brief and name are equivalent: the kernel trims
title (normalize_title), brief (prepare_creation) and the subject label
(mailbox declare) itself.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:28:09 +02:00
Meta Vens
f90d408466 Merge ghostlight-verse/create-flow into main
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 18:26:53 +02:00
Meta Vens
9ccc9fd4f2 Begin is the one entry: world.create is gone, the route owns the defaults and how far Begin goes
world.create leaves the dispatch table and operation_schema. world.begin takes
ghostlight.world_begin.v1 (title, brief, subject_label; unknown fields refused);
begin_intent decides targets, roots, labels and lens weights, begin_plan decides
the steps, and where a seed vault is configured Begin stops at the approved Draft
so the seed lane stays reachable. The surface authors no constants. Tests that
needed a Draft start from the kernel's own create; the bridge fixture is edited
to match and still needs its Starfire drift run. May not build: first commit of
the batch, unverified.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-16 17:21:58 +02:00
Meta Vens
dac9686576 Ghostlight verse map: cut sequence of the four cuts withdrawn under map-only-what-runs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:01:04 +02:00
Meta Vens
d1111f4c4f Merge ghostlight-verse/verse-run (cuts verse-run r3, lookup, claim, refusals)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 20:08:04 +02:00
Meta Vens
9308c7d043 verse run: pin the membership guard and the unattributed note by size, position and cell; delete the fold's tool-name cap
membership-guard-pins-degenerate: a three-member cell at positions 0, 1 and 2 with a foreign extra, a duplicate, a missing last and middle member and a short list; the unattributed note recorded at cells 0, 1 and 4.
tool-name-bound-twice: UNATTRIBUTED_TOOL_CAP, its take and its direct-fold test are deleted; the provider contract owns the 64-byte bound.

Not built or run locally; verified on Yggdrasil in the next report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 19:08:17 +02:00
Meta Vens
9747957649 Verse run test: the stray call is made once, so the tick holds one note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:47:49 +02:00
Meta Vens
fe06efcf26 Controller tests: a call naming no handle refuses the members behind it, and the name kept is capped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:45:58 +02:00
Meta Vens
b3699908f7 Verse run tests: a group at a later cell, the resumed row's disagreement, the invariant break, the unattributed note
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:43:02 +02:00
Meta Vens
6d84c2b57b Verse run: the controller owns a resumed cell's member set and the driver only copies
A resumed grouped cell refuses each cell member its persisted row lacks as
RowDisagreesWithCell and runs no row member the cell lacks, in the controller's
row-and-cell intersection. The driver's after-the-fact repair branch (a
ScopeMoved relabel of unlisted members) is deleted; a finished cell whose lists
do not match its members now ends the run Failed (cell_membership). A grouped
call that names no handle is carried to the tick as a cell-level note naming
the tool, and the members who finished with no choice behind it are refused
CallUnattributed instead of declining.

Builds the library; the verse::run test module does not compile until the next
commit (TickCheckpoint, absorb and GroupedRun changed shape).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:43:01 +02:00
Meta Vens
03b04011b7 Verse run test: the frozen member a resumed row lacks is refused whether it is the cell's first or second
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:45:12 +02:00
Meta Vens
311c4bfaed Verse run test: the resumed row's second run answers a grouped request
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:27:34 +02:00
Meta Vens
8c6a01f2ad Verse run tests: the refusal paths, the frozen cover's membership on resume, and the undecodable member
Gone members per member and per cell at cells beyond 0, a resumed row smaller and larger than the frozen cell, absorb driven with each grouped mapping, the cell-level refusal of a group, Interrupted, and an undecodable member refused (and declined when it later finishes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:25:47 +02:00
Meta Vens
c3f0e3789c Verse run: the frozen cover owns a tick's membership, and an undecodable grouped member is refused
absorb now folds a cell's whole outcome (result or error) and reconciles a grouped cell against its frozen members: a persisted row's members outside the frozen cell are never listed, a frozen member the row never mentions is refused (ScopeMoved), and a member refreshed away is only its OpportunityGone refusal. A grouped member whose own tool call failed to decode and which never finished is refused (ProposalUndecodable) and no Decline is submitted for it.

Test-only overtaken_narrative_run builds the Interrupted outcome for a driver test. Tests follow in the next commit; the test target does not compile against this commit alone only if tests are built, the library builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:25:46 +02:00
Meta Vens
ef7299b673 Verse run refusal test: the foreign run's number no longer collides with a later claim's ledger
The port opened for "another run" used number 7, which the extra claims now reach. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:06:55 +02:00
Meta Vens
a501009210 Verse run: the operator log is read before the clock, claim insert and start revision are pinned, refusals are tested both ways
Fixes operator-log-join-untested (run_tick takes the log reader; an unreadable log leaves the tick open with no clock advance), claim-insert-atomicity-unpinned (insert_run extracted, collision test), claim-start-revision-from-stale-snapshot (run_manual refuses a claim whose start_revision is not the world's revision, WorldMovedSinceClaim) and refusals-pinned-one-direction (cap above and below, each config field, revision both ways). Not yet built when committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:05:12 +02:00
Meta Vens
b5b964c234 Verse binary: store-open errors name the flag, never the --state path; a refused start's abandon is pinned
Fixes state-path-echoed-by-store-open-errors (world, call, run and controller-work store opens map to fixed messages, dropping the source chain that carries the path) and start-refused-glue-unpinned (the binary test reads the run record back after a ledger-refused start). Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:05:12 +02:00
Meta Vens
2dabb85009 Verse run: every member of a tick's frozen cover ends committed or refused, by subject
Refusal names its subject. absorb reads GroupedRun.refused (LeftBeforeSelection
-> OpportunityGone, ScopeChanged -> ScopeMoved, WorldRefused -> WorldRejected)
and records an interrupted singleton's member; refreshed returns the frozen
members it found no opportunity for; a cell-level refusal records every member
of the cell. TickReport carries the refused subjects' labels and Display prints
"refused: cell N <label> <reason>". Unbuilt before this verification; no commit
here is known not to build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:36:31 +02:00
Meta Vens
706d0c3792 Verse run: the driver labels a world ScopeChanged refusal ScopeMoved
One fact, one label across lanes (ruling unsent-awaiting-row-is-awaiting).
RefusalReason::ScopeMoved's doc names the unsent-awaiting state. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:22:22 +02:00
Meta Vens
5f2e629c32 Controller lookup tests: committed declines, never-sent rows, uncertain custody, snapshot loss
Pins the lookup rule's untested halves: a committed NoProposal row is
answered with its receipt after its scope moved (both lanes); a
ReadyToSubmit or NoProposal row that never reached the world ends
World(ScopeChanged) with no provider call in the operational lane and is
lowered once more (one provider call) and committed in the narrative lane;
an in-flight row read in uncertain custody keeps the digest gate (both
lanes); a grouped cell whose world cannot be read at submission is pending
WorldUnavailable with nothing submitted. Fault injection lives in test
fixtures only. Test-only change; builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:22:22 +02:00
Meta Vens
d1883e9973 Verse run: the run store claims a run's sequence before its call ledger opens
next_sequence was a public read followed by a caller-chosen sequence on save,
so a second run could take number 1 and overwrite run 1's record, and a start
refused after its ledger opened bound that cap to the number the next run was
handed. VerseRunStore::claim now allocates and writes the Running record in one
insert-if-absent and returns a non-Clone RunClaim; next_sequence and save are
private and save is update-only. run_manual takes the claim, refuses a port
whose run id or cap is not the claim's, a claim for another world, or a config
other than the claim's, and abandons the claim as Failed in each case. The
binary claims before opening the ledger and abandons a start it cannot make.
Fixes old-sequence-overwrites-record and refused-start-binds-cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:47:52 +02:00
Meta Vens
92275cbe7c Verse run: an operator log that cannot be read ends the run after the tick's checkpoint
The tick's acts came from operator_log().map(..).unwrap_or_default(), so a
failed read reported a tick of no acts. acts_since returns None for an
unreadable log and run_tick ends the run Failed(operator_log_unavailable) with
the checkpoint as it stood. Fixes operator-log-error-swallowed. Seam test:
the log cannot be made to fail through the kernel's doors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:47:18 +02:00
Meta Vens
2a888da955 Verse command: a lane that cannot open names the flag or fault kind, never the endpoint
open_inference's error was wrapped with .context and printed through the
anyhow chain, so a refused non-loopback --base-url put the operator's address
on stderr. lane_fault maps each ControllerOpenError variant to one fixed
message; ControllerOpenError is exported for it. Fixes
local-endpoint-echoed. Builds; the workspace is unchanged elsewhere.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:46:47 +02:00
Meta Vens
5cf005eb2c Pin the tick ordinal edges and the racing open
Lifts Soul's p8 (closed tick at the current ordinal refused before any cell)
and p9 (a 45-minute external advance moves the ordinal) into tests, and a
racing-open test for the create-only open. Fixes s2 ordinal-edges-unpinned.
Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:19:43 +02:00
Meta Vens
b5dc76dd75 VerseRunStore: tick writers are private to the driver, open_tick is the store's insert-if-absent
open_tick, close_tick and close_ticks_before lose `pub`: run_tick through
open_or_find is the only caller, and a compile_fail doctest pins that an
exported-type holder cannot call them. open_tick uses
OwnedRedbMessagePackBackingStore::insert_entry_if_absent in one write
transaction instead of read-then-push, so two opens of one ordinal cannot both
succeed. Fixes s2 tick-rows-have-a-public-writer and open-tick-is-check-then-put.
Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 14:19:42 +02:00
Meta Vens
61042670d1 Test: a third subject speaks so the custody-uncertain fixture's scopes all move
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:51:44 +02:00
Meta Vens
cb1be44f92 Test: the custody-uncertain fixture binds the narrative subject as the world holds it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:43:23 +02:00
Meta Vens
9bf7932573 Tests: custody-uncertain rows, narrative in-flight gate, and committed cells answered in either order
The run-level order-reversed case now commits both cells through another
driver, each one's act having moved the other's scope, in either order; the
resuming run answers both with their receipts. Not built before this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:35:43 +02:00
Meta Vens
db12c26b31 Test: the committed cell is answered; the in-flight cell sharing its room is refused before inference
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:07:06 +02:00
Meta Vens
2b495e1f33 Test: soul_b refused-constituent test reads the typed refusal, not needs text
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:05:34 +02:00
Meta Vens
adb8cbff2b Test: a committed cell is answered whichever cell the resume reaches first
The cell that acted is second in the frozen order; the first is in flight when
the run ends. Closes the order dependence budget_exhausted_stops_before_the_clock_and_resumes
only passed around.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:57:38 +02:00
Meta Vens
158e8f9f3d Controller lookup-first: a committed row answers by command id; grouped refusals typed; exercises in attention order
A row that carries its submission body is resumed whatever the caller's scope
digest (the world answers it by command id); a row still in flight keeps the
digest gate. World and scope must always match. GroupedRun.refused names each
constituent that did not commit (LeftBeforeSelection, ScopeChanged,
WorldRefused); needs keeps decode problems only. A group submits declines
first, then exercises in the world's attention order read once.

Not verified at this commit (written before the first build).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:55:46 +02:00
Meta Vens
278033b90e Verse run test: a refreshed member is bound to its own subject's opportunity
Kills the cargo-mutants survivor on the subject match in refreshed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:45:24 +02:00
Meta Vens
be7d9eec75 Verse run test: a resumed tick runs its frozen cells in order and answers the finished one from its row
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:14:25 +02:00
Meta Vens
5277973afb Verse run: a tick is (world, clock-advance ordinal), its span and cells frozen in a tick document
The tick index is the world's TimeAdvanced count, read through a driver-only
mailbox door, and derive_cover takes it instead of now and the span. A tick
document ghostlight.verse_tick.v1 is written before the first cell and read by
any later run, so a resume under another span or cover budget finishes the open
tick as it was opened. The clock id is clock_command_id(world, ordinal) and is
pinned by a test. TickIndex::of and run_tick's per-call derive_cover are gone.

Fixes findings resume-rederives-tick-identity, tick-identity-collides-across-spans,
clock-id-unpinned. Not yet built or run at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:11:07 +02:00
Meta Vens
06a8373fbd Verse run test: a resumed tick reports only the acts the resuming run committed
Pins the operator-log cut at the tick's start revision against the act the
interrupted run left at that very revision. Builds; the mutation pass named it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:08:52 +02:00
Meta Vens
06e30dc921 Verse binary tests: a failed run exits nonzero, and zero counts are refused
Pins the exit status of a Failed run against a provider that refuses every call,
and the positive-integer guard for ticks and token cap. Builds; the earlier
mutation pass named these as survivors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:14:07 +02:00
Meta Vens
097bf02f1e Verse run: the open tick is recorded unclosed, and a resumed cell whose scope moved is its own refusal
A run that ends inside a tick (budget, pending cell, failed clock) now keeps
that tick's checkpoint with closed false, so what its cells committed is not
lost. A cell that committed before an interruption meets a moved scope
digest when the tick resumes and is refused as ScopeMoved; nothing commits
twice. Removes the temporary diagnostic. Builds; not yet re-verified.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:08:02 +02:00
Meta Vens
fc3f9fd668 diag (temporary) 2026-10-11 09:04:52 +02:00
Meta Vens
cef6436971 Verse run test: the resumed tick closes with the one cell the first run left
The faction that acted holds no opportunity until the clock moves, so the open
tick re-derives with one cell. Builds and passes except this assertion, which
this commit corrects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:01:01 +02:00
Meta Vens
2daa80e159 Verse run: the manual tick driver, its run record and the ghostlight-verse command
run_manual advances the verse world tick by tick under the recording port's
token cap: snapshot, opportunities and agency graph from the mailbox,
derive_cover, each cell on its members' current opportunities, one clock
advance, a checkpoint in ghostlight.verse_run.v1. A tick that cannot finish
leaves the clock where it was, so a later run re-derives the same cells and
command ids and the kernel answers from its ledger.

WorldMailbox::opportunities is the tick driver's own door beside agency_graph.
OperatorEvent now names the verb, its render path and the bound roles.

Not yet built or tested: this commit is the first write of the pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:58:20 +02:00
Meta Vens
4eff650755 Merge ghostlight-verse/verse-genesis: the canonical verse world is created from Aetheria's catalog
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-11 03:41:21 +02:00
Meta Vens
e854cf3671 Genesis tests: pin the faction Name trim, double genesis, and a ship verb's speech
Pins the trim of a faction Name into its institution label, genesis twice
(same intent: same receipt, nothing changes; another command id:
CreationTargetOccupied), and, as evidence for the open question
ship-acts-are-not-speech, that a phase A ship verb commits only as an
utterance. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 03:10:39 +02:00
Meta Vens
8f6b552e7a Verse genesis: the canonical Aetheria world is created from the catalog
CreateVerseWorldIntent lowers to one CreateWorld bound to the catalog's
grammar: a commons, one affordance per non-speak grammar verb (the kernel
supplies speak), and per faction an import-keyed institution granted every
verb plus a canonical fact citing aetheria:<commit>:aetheria.faction/<key>.
WorldMailbox::create_verse sends it through create_authenticated. The role
lowering moves into GrammarVerb::role_specs, which admits_roles now uses.
Also pins the reader's non-string allegiance key and invalid-UTF-8 refusals.
All commits build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 02:33:57 +02:00
Meta Vens
9afbdc879e Merge ghostlight-verse/grammar-reader: Ghostlight reads Aetheria's catalog file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:52:06 +02:00
Meta Vens
42e71d89d5 grammar-reader fix 1: pin the record-key sort with rows spliced out of order
The store re-sorts rows it writes, so the reversed-rows test never reached the
reader unsorted. Both file-shape tests now splice raw snapshot rows. Builds;
test-only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:29:31 +02:00
Meta Vens
677eb1b3a8 grammar-reader fix 1: the duplicate-faction test splices the second row into the snapshot bytes
The store's writer keeps one row per record key, so the previous test never
held two rows. Builds; the test crate is the only change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:15:31 +02:00
Meta Vens
3eb59068c7 grammar-reader fix 1: canonical keys through the one predicate, duplicate factions refused, bin/ext growth tolerated, guards pinned
The reader mints every faction and allegiance ImportKey and refuses a
non-canonical one as a typed NonCanonicalKey naming record and field (0 or 12);
two faction rows under one record key are DuplicateFaction. Payloads decode
through rmpv so a MessagePack bin or ext cell at a higher key (C# byte[],
DateTime) no longer fails the record. Tests pin the record-key sort and a
non-map Allegiance. Schema-version check (item 2) not done: CultLib gap.
All commits build (not yet run; the suite runs next).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:13:17 +02:00
Meta Vens
b5edc05b1f grammar-reader: pin every referent code (Person and Population arms survived mutation)
The Aetheria-written fixture binds only Faction, Cargo and Place, so mutants
deleting the Person and Population arms survived. The new test authors each
code at the role's key and reads it back as its own kind. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 00:14:50 +02:00
Meta Vens
84b8e3d6d5 grammar-reader: read Aetheria's catalog into a GrammarBinding and keyed factions
read_aetheria_catalog opens the catalog read-only, mirrors
aetheria.verse_grammar, aetheria.verse_verb and aetheria.faction by integer
key, and refuses unknown enums, bad verb or role names and a missing grammar
by record key. Fixture written by Aetheria's own fixture writer at master
92b82e9a (provenance beside it). Also the NFD/NFC position sweep for import
keys. Not yet built or run when committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 23:25:47 +02:00
Meta Vens
c3e16a57c2 Merge ghostlight-verse/import-key 2026-10-10 22:56:19 +02:00
Meta Vens
f167683e8d Merge ghostlight-verse/call-record 2026-10-10 22:56:15 +02:00
Meta Vens
308169ac51 call-record: pin all fifteen keyed fields of the replay key
The replay-key test now covers schema_id, reasoning_summary,
output_format_name, tool_choice and prompt_cache_key beside the ten it
had, over a Vec so a future keyed field is one more row. Tests only;
every commit builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:31:20 +02:00
Meta Vens
4126c83eff ghostlight import-key: pin whole-record NFC and the admit_resolved duplicate backstop
Tests only, no production change. Both build. A late NFD sequence and a
late format character in a long lore-shaped path kill an is_nfc-on-prefix
mutant; a resolved effect carrying two subjects with one key kills a
fresh declared set in admit_resolved.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 22:14:01 +02:00
Meta Vens
ecff1ac25b Call record tests: resumed and exact-cap cases keep one cap per run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:46:21 +02:00
Meta Vens
e651d9e863 Call record: fix the moved ledger borrow (previous commit does not build)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:33:27 +02:00
Meta Vens
2c5eb95da3 Call record: ledger carries cap and starting world; replay refuses another world
The run ledger is the one owner of what a run began as: its cap and the
world id and state digest it started from. Replay takes the store, the run
and a restored world and refuses a different world up front with a typed
error naming the field. Restores the decode checks the ledger rules do not
imply, makes the replay key an exhaustive destructure, and pins the
over-bound edge. Not yet built or tested at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:32:17 +02:00
Meta Vens
ee174c68ce Ghostlight import key: behavioural tests for the canonical spelling and the state gate
Soul's forgeries shaped as real keys (NFC vs NFD, ZWSP, NUL, BOM, RLO, BEL,
double space, tab, NBSP), a lore path with spaces admitted, and a loaded
state with a shared or malformed key refused. Test-only commit; builds if
the prior commit does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:09:08 +02:00
Meta Vens
1afd942be3 Ghostlight import key: one canonical predicate, one uniqueness function, state gate
The record key is canonical free text per ruling import-key-canonical-text
(NFC, no Cc/Cf, only U+0020 whitespace, no edge or doubled space). The
resolver, admit_resolved and verify_state_shape all call the same predicate
and import_key_fault; admit_resolved's second held-set iteration is deleted.
Adds unicode-normalization and unicode-properties. Not yet verified on
Yggdrasil; build status of this commit unproven.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:54:42 +02:00
Meta Vens
c750e21c2d Import key tests: a snapshot reader gets schema and record back separately
Test-only; compile-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:46:46 +02:00
Meta Vens
e085286126 Import key: the door test checks Draft doors in Draft and Active doors in Active; held and declared keys are separate sets
Compile-checked; tests run in the next job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:27:14 +02:00
Meta Vens
525666f79b Import key tests: digest pin reads the msgpack the digest is taken over
Test-only; compile-checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:25:19 +02:00
Meta Vens
b8465e50d3 Import key: a subject names the record it came from, and admission keeps the mapping one-to-one
ImportKey (schema, record) rides SubjectDeclaration and SubjectState as an
optional field (serde default, skipped when None, so keyless worlds keep their
digests) and is exposed on SubjectSnapshot. resolve_patch, the one resolution
owner for genesis and the owner, consumer, Play and elaborator doors, refuses a
malformed key, a key declared twice in one patch, and a key any existing subject
holds, retired included. admit_resolved, the subjects map's only writer, holds
the same injectivity as an invariant. Typed mismatches name the handle only.

Compile-checked (cargo check --tests) on Starfire; tests not yet run, so no
claim that this commit's tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:23:26 +02:00
Meta Vens
8c9b88b519 call-record: drop two record-decode checks the ledger and density rules already make
A record with a run id that is no key segment has no readable ledger, and a
record at sequence zero fails the density rule at open; the two decode_row
conditions were equivalent mutants (cargo-mutants missed both). Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:22:02 +02:00
Meta Vens
327cbe3c5e Grammar door test: owner and consumer in Draft, Play and elaborator in Active
A consumer declares unanswered only in Draft, so it joins the owner there.
Test-only; builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:46:46 +02:00
Meta Vens
bd8dc756d9 WIP call-record fix1: run ledger owned by the store (does not build until verified)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:45:18 +02:00
Meta Vens
fbd8bdb8ca Grammar door tests: owner is checked in Draft, the other doors in Active; distinct kinds in the forged-effect test
The owner declares unanswered only in Draft, and the forged-effect twin refuses
a kind the world already holds. Test-only; builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:44:06 +02:00
Meta Vens
e5ae6fe7af Grammar check has one owner; pin later-revision doors and the speak render path
GrammarBinding::refusal is the single grammar predicate. The resolver maps it
to Mismatch, and admit_resolved (the catalog's only writer, reached by
apply_effect) maps it to an Invariant error, so an effect resolved elsewhere
cannot install an out-of-grammar entry. The speak entry must also be bound as
a Conversation verb (Mismatch::SpeakRenderPathDisagreesWithGrammar).

Tests: owner, play, consumer and elaborator doors refuse kind and role
signature after the revision has moved past genesis; speak bound as ShipAction
is refused at bind; apply_effect refuses a forged effect.

Builds (cargo check --tests, Starfire compile only). Merge of origin/main
8d263bb was mechanical (no conflicts).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 16:40:33 +02:00
Meta Vens
c11e8a16ba Merge origin/main into grammar-binding 2026-10-10 16:35:54 +02:00
Meta Vens
2e9905f42f Verse call records: the in-flight test cannot hang when the first call is refused
Two reserve mutants timed out because the test spun waiting for a call a
mutant had refused. The wait is now bounded and fails. Builds: yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 15:39:15 +02:00
Meta Vens
f74dec6675 Verse call records: kill the first mutation round's survivors
The in-memory run tally no longer counts rows (only open used it); the store
test pins a record whose run id is not a key segment; the lend_tool_results
forwarding has a test; the in-flight test fails by timeout instead of hanging
when a held call is let through. Builds: yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:54:33 +02:00
Meta Vens
e28bf3fd55 Verse call records: a recording port owns the run's spend, a replay port serves the stored responses
ProviderCallRecord (ghostlight.verse_provider_call.v1) keyed (run id, sequence),
CallRecordStore over an owned redb CultCache file, RecordingInferencePort
(bound check before the call, real usage charged after, record written before
the output returns) and ReplayInferencePort. InferenceFaultClass gains
BudgetExhausted. Not yet compiled or run: this commit may not build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 14:02:48 +02:00
Meta Vens
b2447e4f99 Hosted lane tests: pin DEL (0x7f) as a key byte that cannot ride a header
Follow-up hosted-key-del-pin: a mutant allowing DEL survived the key-shape
test. The case list gains an interior DEL. Builds: yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 13:56:43 +02:00
Meta Vens
8d263bb60e Hosted lane: pin the key shape rule and the absent prompt count
Adds hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header (interior
newline, CR, tab, NUL, leading space, trailing space, non-ASCII, leading
newline; typed refusal at open, no key or path echoed) and the missing
prompt_tokens half of the usage-absence test. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:48:31 +02:00
Meta Vens
b0f57a009b Hosted lane: refuse a key that cannot ride a header at open; a null usage count is an integrity violation again
key-shape-late-fault: the key must be one run of visible ASCII after the trailing
newline is stripped; an interior newline, whitespace, control or non-ASCII byte
is refused at open with HostedKeyUnreadable, which names no value.
null-count-widened: a present-but-null prompt or completion count fails to
decode (BadReply, integrity violation) on both lanes as the local lane did
before the cut; only an absent count or absent usage is a typed absence.
Builds: yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:56:19 +02:00
Meta Vens
74f3b7011f Bump controller_work rows to v18: the usage field changed the persisted shape (old-rows-not-canonical)
InferenceOutput gained a persisted usage field without a version bump, so a v17
row written before the cut was refused at open as not canonical. Rows now bump
to v18 the way the v16 -> v17 bump did: the earlier version is refused at open
with the store's named fault, not migrated; the recipe slot, its drift check and
the ontology doc move with it. The earlier-version refusal test now pins v17.
Builds: yes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 10:56:19 +02:00
Meta Vens
b3d5843e71 Hosted lane: key-file test cases get their own files; overlapping hosted claims route longest-first
The empty, newline-only and padded key cases all pointed at one file that the
last write replaced, so the production empty-key check was never exercised
(cargo-mutants survivor 544:23).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:57:19 +02:00
Meta Vens
aef08ce3a6 Grammar tests: declare speech-carrying entries so the resolver does not refuse them as inert
Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 07:00:01 +02:00
Meta Vens
f178684391 Bind a grammar to a world at creation and refuse what it lacks
CreateWorld gains an optional GrammarBinding (schema id, revision, content
digest derived from the kind-sorted verbs, never caller-supplied). Genesis
installs it on the empty state, so resolve_patch checks the genesis patch and
every later AdmitPatch against it: an affordance kind the grammar lacks is
AffordanceKindNotInGrammar, a differing role signature (name, referent kind,
order) is AffordanceRolesDisagreeWithGrammar, and the kernel speak entry is
held to the same check. WorldSnapshot reports the grammar's identity and each
AffordanceSnapshot derives its render path from it. Worlds with no grammar
are unchanged: the field is absent from state and command bytes, pinned by a
journal written at base 367e2eb.

Builds: not verified on Yggdrasil yet at this commit (cargo check clean locally).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:43:49 +02:00
Meta Vens
831f10458e SDK lane test: token counts reach InferenceOutput (WIP, unverified)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:37:26 +02:00
Meta Vens
0d28508180 Hosted lane: the usage-absence test replays one prepared request (WIP, unverified)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:34:12 +02:00
Meta Vens
dd80e29484 Hosted lane: test call sites take the endpoint enum (WIP, unverified)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:27:18 +02:00
Meta Vens
7262e72a0d Hosted inference lane and token usage on every model reply (WIP, builds unverified)
The OpenAI-compatible port gains a hosted binding (https endpoint, bearer key
read once from a file); InferenceOutput carries Option<TokenUsage>.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:22:14 +02:00
Meta Vens
8912dde49f Begin test: the refusal names its step in the begin's own words
A mutant that dropped the step from the message survived because the kernel's
own cause text also contains the word approve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:47:01 +02:00
Meta Vens
90c5e3b337 Docs: the no-world surface is Begin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:39:28 +02:00
Meta Vens
1645f4a972 Bridge fixture: re-captured from the real pinned lowering for world.begin
Captured by running the drift test's own walk (begin, then play) with a
temporary, uncommitted patch that writes the regenerated intents instead of
comparing them (vendor/eve at 672c0c1, built and run on Starfire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:39:17 +02:00
Meta Vens
f89be06a33 Begin tests: reuse the untouched-form intent builder; the untouched-form walk begins instead of creating
Does not build the fixture replay test: the bridge fixture still carries the
old steps until it is re-captured from the real client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:35:48 +02:00
Meta Vens
efa7d5cff2 World creation is one step: title, name, optional brief, Begin
The no-world card asks for a title, a name and an optional brief and offers
one Begin button. world.begin takes the world_create.v4 payload (the fields
the form no longer asks for ride the button's action as authored constants)
and submits create, approve and activate as three separate kernel commands,
stopping at the first refusal. The create path is shared with world.create.
Step ids are derived from the Begin's key so a replayed Begin replays through
the kernel's ledger. A refusal never echoes a typed value, including the
jurisdiction-handle refusal on world.create.

Not yet built or run: the bridge fixture still carries the old
create/approve/activate steps (re-derived from the real client in a later
commit), and the ignored bridge tests have not run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:34:20 +02:00
Meta Vens
367e2eb50f Vault root: read through an environment lookup, pinned by a test; fix stale seed comment; README says restart
seed_vault_root_from becomes seed_vault_root_from_environment(lookup): the
only place naming GHOSTLIGHT_SEED_VAULT_ROOT. Open passes std::env::var_os.
A test pins that the root comes from that variable only. The comment at the
bridge test's no-world.seed assertion names AppState.seed_vault_root.
README notes the root is read once at startup. Builds: all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:15:46 +02:00
Meta Vens
9f2904ffb8 Bridge fixture: re-captured from the real pinned lowering
Captured by running the drift test's own create/approve/activate/play walk
with a temporary, uncommitted patch that writes the regenerated intents
instead of comparing them (vendor/eve at 672c0c1). Only idempotencyKey and
issuedAt differ from the previous file; the drift test normalizes both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:00:03 +02:00
Meta Vens
ea8538761b Bridge test: run the first process on a blocking thread so the test runtime keeps serving
Joining the thread from the test body starved the world mailbox's owner task
and hung the test; spawn_blocking leaves the runtime free. Ignored test,
verified on Starfire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:58:10 +02:00
Meta Vens
4f92840fe7 Bridge test: the first process is a runtime dropped after the turn opens
A second PlayTable over the same store cannot open while the first is alive
(the store takes an exclusive lock), so the first leg now runs on its own
runtime and dies with it, as a crashed process would. Does not run on the
stopgap (ignored test); verified on Starfire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:46:43 +02:00
Meta Vens
8ca2c5556c Bridge test: take the world handle before the table closure so the fixture can move
The previous commit does not compile (E0505 in the ignored bridge test's
closure); this one fixes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:38:31 +02:00
Meta Vens
82a0c1b4f0 Play surface fixes: vault root lives in state, cleared optional labels mean none, bridge test drives typed and untouched Play
- AppState carries the seed vault root, read once at open through
  seed_vault_root_from (blank or whitespace is no configuration); tests no
  longer set or remove the environment variable.
- world.create treats a cleared persona or agent label as none, at the one
  place the payload becomes the kernel's intent.
- The real-client bridge test types text to open a turn, restarts over the
  same store, and continues the Running turn with an untouched Play box.

Builds. Bridge tests are ignored and run separately on Starfire.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:35:23 +02:00
Meta Vens
5b6796d1e6 Seed offer test: an empty vault root is no configuration
Builds when the verify job is green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:04:18 +02:00
Meta Vens
0c0e1c73db Untouched-form walk: an empty Play opening is the table's refusal, not a route payload error
The previous commit's walk expected acceptance; with no running turn the table refuses an empty opening, which is the proof the payload arrived. Builds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:04:05 +02:00
Meta Vens
c47a0785c9 Play surface: an untouched Play box submits an empty continue; seeding is offered only where a vault is configured (PA.f214, PA.f213)
world.play.text carries an authored value "" so the lowering sends text "" for a box left untouched. seed_vault_root() is the one reader of GHOSTLIGHT_SEED_VAULT_ROOT; the surface offers the seed controls, card and descriptor only when it answers, and seed_once reads its root through it. The committed bridge fixture drops its world.seed step, which the real client can no longer produce on an unconfigured server.

Not yet compiled when committed; verification follows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:02:25 +02:00
Meta Vens
d79a96ede4 Merge remote-tracking branch 'origin/main' into ghostlight-verse/play-faults 2026-10-10 00:53:47 +02:00
Meta Vens
82a500eedb play-faults fix: every faulted-turn test pins the class on the warn line
The shared faulted-turn assertion now takes the expected class and checks the
warn line carries it (Connect, Timeout, BadReply, Status(404), Status(502)),
with the endpoint and reply canaries still absent. Not yet built or tested at
this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:35:24 +02:00
Meta Vens
b64b61c345 play-faults fix: the warn line carries the fault's typed class, HTTP status included
close_with_fault now takes a TurnFault (detail plus InferenceFaultClass) and
logs the class beside the fixed detail, so a 404 and a 502 read differently
again. absorb_or_close hands the fault's class through; every non-inference
caller is Unclassified. The &'static str comment now says what it does and
does not guarantee (Box::leak defeats it; the per-port canary tests are the
backstop). Not yet built or tested at this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 00:34:59 +02:00
Meta Vens
189d1d3c2b play-faults fix: the sidecar link errors carry no text
SidecarLinkError::Spawn and Codec held error text that the fault type now
refuses, leaving two never-read payloads; they are unit variants.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 23:40:12 +02:00
Meta Vens
97e39f48fd play-faults fix: bound the stalled-body test so a client that ignores its timeout fails it
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 23:36:51 +02:00
Meta Vens
3ee4ab8e88 play-faults fix: repair the malformed-reply test literal; the unroutable-model test no longer expects the model name in the fault
The previous commit (29efabe) does not build: a string literal in the new
malformed-reply test was mis-escaped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 23:30:12 +02:00
Meta Vens
29efabe4d8 play-faults fix: a fault's detail is fixed text by type; one timeout owner; pin the malformed-body and default-timeout legs
InferenceFault.detail is a &'static str, so no inference port (local,
connector, SDK) can put an endpoint, request or reply text into a fault. The
connector and SDK details that were built from error text are now fixed per
kind or reason. LocalBinding loses its public response_timeout field; the
short timeout the PlayTable canary needs comes from open_local_port_with_timeout,
which exists only under the ghostlight test-support feature (a dungeon
dev-dependency feature), so a release has one timeout, RESPONSE_TIMEOUT.
Canary tests on the malformed-body path (port and PlayTable), on the SDK and
connector ports, and on the default timeout at both resolving sites.

Not yet built or tested at this commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-09 23:26:21 +02:00
Meta Vens
5a876bf615 play-faults fix: read the body as bytes so a cut body is a transport failure
reqwest's json() reports a body cut mid-stream as a decode error, so the
is_decode branch in send_fault misclassed it BadReply. The body is now read
with bytes() through send_fault (timeout / connect / no complete reply), and
only a whole body that fails serde_json is classed by bad_reply().

Not yet built at this commit; verified by the following job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:35:13 +02:00
Meta Vens
6db5d0873e play-faults fix: one send classifier, timeout canary, composed retry test, honest comments
- send_fault now classifies the reply-body read too: a body-read timeout is
  Timeout, a body cut mid-stream is NoResponse (retryable), a whole body that
  does not decode stays BadReply. The response.json() map_err is gone.
- LocalBinding.response_timeout (None = RESPONSE_TIMEOUT) lets a test reach the
  timeout path through the full PlayTable path.
- Tests: stalled, cut and malformed bodies against loopback listeners; a
  timeout canary at the port and through PlayTable (stored fault, warn line,
  card row, Debug); a PlayTable turn that survives N empty replies and faults
  past ROUND_RETRY_BUDGET.
- Comments: CREATE_BINDINGS doc reattached, backoff comments state the curve,
  close_with_fault no longer claims "endpoint-free by construction".

Not yet built or tested at this commit; verified by the following job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 22:31:35 +02:00
Meta Vens
1e6da32ad3 Merge origin/main into ghostlight-verse/session-refresh 2026-10-09 21:41:25 +02:00
Meta Vens
f0adcc61c5 play-faults test: read the fault-closed turn's id off the view
The table mints the turn id, so the log assertion must read it from the stored turn, not the id the caller passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:41:38 +02:00
Meta Vens
e1c2632f03 play-faults: classify send failures, show the fault on the card, log it, lengthen the retry window
R3: every local send that fails before a status arrives is Retryable except a timeout; the fault carries a typed InferenceFaultClass (Connect, NoResponse, Timeout, Status, BadReply) and a detail without the request URL.
R1: PlayTurnView.fault and a fixed world.play.fault card row.
R2: close_with_fault logs the turn id and the (endpoint-free) detail at warn.
R4: backoff cap 20 s, shift clamp 8: the retry budget now spans about 105 s.

Not yet built or run when committed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:31:55 +02:00
Meta Vens
aa18e28925 docs: FactStanding, Eve logout and inference transports match their types
ontology:103 lists all three FactStanding variants (patch.rs:298); the Eve
table gains app.auth.logout (eve.rs operation_schema, 9 ids); mvp:466 names
the three RoutedInferencePort routes (sdk_inference.rs:700).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:18:05 +02:00
Meta Vens
26f533e8f4 Refresh tests pin the production edges: key-fetch typing, cache wiring, algorithm gate, cadence
VerificationUnavailable now carries an error class and never the fetch error,
whose text named the key-set URL. fetch_jwks returns the reqwest error so the
class is read from it, not from a string. run_refresh_loop loses its period
parameter: SESSION_REFRESH_PERIOD is decided in one place and a paused-clock
test observes it exactly and over twelve ticks.

Tests drive HeimdallClient::verify_access against a loopback issuer that
counts fetches (cache stored, read, ttl 299 s fresh and 301 s stale), a closed
port and a 503 (kept session and receipt, no URL in the error or the log line),
and nine wrong-algorithm tokens. The moved-session test also pins that the
newer claim is the one spent next.

Builds and passes under ygg-verify (28 of 28 in heimdall:: and refresh_loop_tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:13:25 +02:00
Meta Vens
5cb2ecbb31 ontology, consumer API, smoke note: enumerated sets match their Rust types
ComponentOp has 34 variants (table rewritten), Precondition 9, ActionMismatch
26, FactStanding 3, EdgeKind 1; CausalBoundary field shapes, PatchAnswer,
and the live state/commit/controller-work schema generations follow the
code. The consumer page lists the ops a confined consumer is refused. The
smoke note points at the Idunn acceptance step instead of the historical
play-agent page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:11:29 +02:00
Meta Vens
64071de685 mvp: enumerated sets name the Rust type that decides them
CommandBody has seven variants, WorldPhase two, EdgeKind one, WorldState
a fixed field set; the page now lists those and marks membership, contract,
archive and general relations as unbuilt. Persistence rows and WorldCommit
fields match journal.rs and lib.rs. The play-agent gate citation points at
the Idunn recipe's acceptance step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:09:50 +02:00
Meta Vens
d3f8975d3d Current-labelled pages cite only live files and state unbuilt features as unbuilt
ontology: drop the two citations of deleted pre-rebuild pages; Session Zero is
named as an unbuilt consumer. mvp: bounded co-op and the archived phase are
marked design intent, since crates/ has neither.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 20:02:09 +02:00
Meta Vens
424a20e64b README labels linked design pages honestly; drop dangling system-map citation
The README no longer calls unbuilt or historical pages current design records:
play-agent is historical, session-zero is unbuilt, public-architecture and
articles are unbuilt design and essays, extraction and stock-lenses are closed
records. The authority architecture page loses its citation of a deleted file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:58:09 +02:00
Meta Vens
9de9e53a5f Docs describe the machine on main: README, AGENTS, handoff pointer, live architecture pages
README and AGENTS.md state what exists (sealed kernel, Dungeon, persona
projection, sidecar, browser host) and what does not (the verse, storylets,
cast, episodes). The handoff becomes a pointer to the Eureka mind. The Eve
interface page matches eve.rs (world.create v4, Draft/Active cards, play
card). Live pages cite crates/ghostlight/src/<file> instead of world/*.rs.
The implementation plan gains a historical status line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:50:28 +02:00
Meta Vens
57b16df286 Refresh tests: pin the refresh owner's endurance and the signing-algorithm check
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:32:28 +02:00
Meta Vens
72a98d8468 Refresh: keep a receipt whose keys were unavailable, and pin the loop and the branch rules
The refresh pass keeps a spent-upstream receipt when verification could not
fetch the signing keys and verifies it on the next pass instead of refreshing
again; only a positive verification failure revokes. The loop body is
run_refresh_loop, tested for calling the pass; tests pin that a moved session
is not revoked and a higher revision is applied.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:23 +02:00
Meta Vens
2b6e8c7826 Heimdall: type the unavailable-keys failure and cache the signing keys for a bounded time
A JWKS fetch failure is now VerificationUnavailable, distinct from a receipt that
positively fails verification. The fetched key set is cached for five minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:21:18 +02:00
Meta Vens
447471825b Drop the dead npm scripts and ignore lines of the parked tree
Removes state:*, prestate:*, *:validate, schema:validate, images:count and
coverage:status (they called the deleted Python tools); keeps dungeon:* and
sdk:*. Description now names the Rust kernel and Dungeon. Drops .gitignore
lines for __pycache__, *.pyc, .venv, state scratch and experiments/images.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:45 +02:00
Meta Vens
86977e51f7 Cut six pre-rebuild notes pages
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:13 +02:00
Meta Vens
1f85d1c4d7 Cut 27 pre-rebuild architecture pages and the Aetheria brainstorm
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:13 +02:00
Meta Vens
ea91b33946 Cut the Python store's state/
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:02 +02:00
Meta Vens
a7ed853fcd Cut newspaper output and woodcut assets
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:01 +02:00
Meta Vens
90ec569d53 Cut acceptance, prompts and scripts of the pre-rebuild tree
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:01 +02:00
Meta Vens
62dcc6c5b3 Cut experiments
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:01 +02:00
Meta Vens
058c070a42 Cut pre-rebuild example fixtures
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:01 +02:00
Meta Vens
26623d6d2f Cut the pre-rebuild JSON schemas
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:00 +02:00
Meta Vens
ece28506e6 Cut the Python state store, validators and newspaper typesetter (tools/ except the Eve bridge)
Parked at tag parked/ghostlight-pre-rebuild (ruling ghostlight-verse:ruling:park-pre-rebuild-tree).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 17:05:00 +02:00
Meta Vens
1b31bfc399 Refresh tests: pin the denied-status split and the session and account custody checks
Mutation survivors of the first cut: the status comparison inside invoke (now
refuse_denied, tested directly) and the session-or-account custody check
(a receipt for another session, or another account, revokes).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:49:22 +02:00
Meta Vens
af7e544830 Refresh: apply an equal-revision receipt, key each refresh by its claim, revoke what cannot succeed
Heimdall's access_revision is a revocation epoch, so a refresh receipt at the
session's revision is the normal case (ruling refresh-revision-is-revocation-epoch).
The loop pass moves behind a SessionRefresher port; a typed HeimdallDenied
revokes the session, a backwards receipt revokes it, every local refusal
names its clause, and the idempotency key derives from the claim being spent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:37:52 +02:00
Meta Vens
c4bc865573 Map: name the clause that refuses an executed Heimdall refresh
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:32:55 +02:00
Meta Vens
46df4070a8 Map: Ghostlight verse phase 1, body facts and model page
Why the 2026-09-30 deploy died (Odin's route), what main's build and
tests say, the play-session findings against main, the live refresh
loop, the host's retired v1 body, and where phase 2's verb sets plug in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:22:47 +02:00
Meta Vens
1e5761d9e0 Target: every event the kernel admits has exactly one render path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:19:26 +02:00
Meta Vens
4441381987 Target: Ghostlight verse campaign (honest and running, factions and cast, the verse loaded)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:02:54 +02:00
Meta Vens
4fee97f089 Map and handoff: Ghostlight deploys from main
Operator ruling 2026-09-30: "Nope, I'd rather be on main". main was
fast-forwarded to the codex/ghostlight-dungeon-mvp tip (eeb3a68) and the
Yggdrasil ghostlight binding now admits refs/heads/main (gamecult-ops
45eb584). Historical cut records keep the branch they landed on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 12:33:54 +02:00
Meta Vens
eeb3a68042 Steer map and handoff to the live deploy and the fix list
Dungeon is live on Yggdrasil under Idunn v2 (454fc33); the operator ruled
"Stop here, we have shit to fix". Next action is PA.f211-f216 in order,
then the owed Soul pass; legacy-served and deploy-next text retired.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:21:30 +02:00
Meta Vens
f23adbbf49 Record the first deploy and play session: PA.f198-PA.f216
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:18:40 +02:00
Meta Vens
454fc33b8e Check Heimdall's session expiry against its refresh expiry, not access
Heimdall's session record lives as long as its refresh token: issueAccessClaim
reports session.expiresAt and refresh.expiresAt as the record's expiry, and
only the signed access claim carries the shorter access expiry. Dungeon
required the session expiry to equal the access claim's exp, so every
completion Heimdall redeemed was refused ("session expiry disagrees with
its signed claim") and no session was ever adopted.

The session expiry must now equal the refresh expiry, and refresh must
outlive the signed access claim. The new test builds the completion in the
shape Heimdall issues; restoring the old comparison fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:37:01 +02:00
Meta Vens
9457410365 Log every refused Eve command with its operation and message
A sign-in completion that Heimdall redeemed was refused by Ghostlight with
no trace outside the browser that received it. The command-result builder
now logs every refusal (not accepted, completed or pending) at warn, with
the operation and the refusal message, never the payload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:35:06 +02:00
Meta Vens
4c83d54964 Send the advertised route transport with the sign-in completion
The page builds heimdall.auth.complete itself rather than through the
vendored client, and its routeHint carried only sourceVersion. Ghostlight
refuses any invocation without the route transport ("invocation metadata
is incomplete"), so every completion was denied before it reached
Heimdall. The page now takes the transport the provider advertises for
ghostlight.eve.commands, as the vendored client takes it from a command
descriptor. Verified on Yggdrasil: the corrected shape reaches Heimdall's
private plane and gets Heimdall's own answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 21:12:14 +02:00
Meta Vens
e42e1c1b31 Finish browser sign-in by attempt id, and on returning to the tab
Heimdall's Discord callback issues a random completion code distinct from
the attempt id, but the vendored adapter required the two to be equal and
rejected every real return as a malformed witness, so no player could sign
in. Re-vendored from Heimdall e8e2832, which completes by attempt id and
never reads the completion code; the test fixture issues a distinct code as
the server does.

A player who finishes Discord elsewhere and switches back to this tab never
reloads it, so the load-time resume never ran. The page now reruns the same
resume when it becomes visible with an attempt in flight.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:51:20 +02:00
Meta Vens
c5bf090d62 Stop declaring Heimdall as an Idunn dependency
Odin (96a55ec) now computes Ready with an external-operator-binding
dependency not ready, but CultLib's shared contract
(runtime_authority_contracts.rs:797-805) refuses any correlation claiming
Ready while a non-optional dependency is not ready, so Odin rejected every
Ghostlight Active presence (30/30 in the last deploy). The external kind
existed only because Heimdall isn't v2-managed; that reason doesn't survive
contact with Odin's own Ready computation.

Ghostlight's deploy stops declaring Heimdall as an Idunn dependency. The
daemon discovers it through Odin at use instead: HeimdallClient already took
Option<HeimdallDependencyBinding> with None as the non-managed path, so
runtime.rs now passes None directly rather than computing it from a
dependency_bindings field that no longer exists, and the loopback-only,
non-secret-bearing, app-bound HMAC+AES envelope enforcement is unchanged.
When Heimdall becomes a v2 target the dependency returns as `required`
managed.

- deployment/idunn/recipe.toml: deleted the heimdall.command-boundary
  [[dependencies]] block. Ghostlight's own required_environment for
  GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE/GHOSTLIGHT_HEIMDALL_BASE_URL is
  unrelated (Heimdall's HTTP app integration, not the Idunn dependency) and
  stays.
- idunn_health.rs: runtime_dependency_bindings now requires exactly the Odin
  dependency (one, not two). Deleted HEIMDALL_CAPABILITY,
  required_external_dependency and its doc comment, the `heimdall` field on
  RuntimeDependencyBindings, the fixture's external_dependency helper and
  its Heimdall entry, and the recipe-drift test's Heimdall block/assertion
  (now asserts exactly one [[dependencies]] block and fails if a Heimdall
  dependency reappears without the daemon changing to expect it again).
  HeimdallDependencyBinding itself moved to heimdall.rs (see below) rather
  than being deleted outright: it's still the shape
  require_expected_heimdall_boundary checks a discovered boundary against
  when Idunn does supply one, which stays true the moment Heimdall returns
  as a managed dependency.
- heimdall.rs: now owns HeimdallDependencyBinding (moved from
  idunn_health.rs, which no longer has any use for it). No behavior change:
  HeimdallBoundaryLocator::Odin's `expected: Option<HeimdallDependencyBinding>`
  and its None-tolerant checking logic were already exactly the
  "discover through Odin, verify against a managed binding when one exists"
  shape this ruling asks for.
- runtime.rs: expected_heimdall is gone; `HeimdallClient::from_env` now
  takes `None` directly instead of a value computed from a
  dependency_bindings field that no longer exists, with a comment on why.

Mutation: re-added the heimdall.command-boundary [[dependencies]] block to
the recipe -> recipe_matches_the_daemon_it_deploys fails (asserts exactly
one dependency block). Restored; deployment/idunn/recipe.toml is
git-diff-clean after, one clean 7-line deletion.

Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
192 passed, 0 failed, 4 ignored (unchanged count: removed a test helper, not
a test).

Structural delta: 4 files changed, 42 insertions(+), 138 deletions(-) in
Ghostlight (net -96 lines) — a genuine subtraction, not a relocation with
padding; the only addition of substance is the doc comment on the relocated
HeimdallDependencyBinding explaining why it still exists.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:29:00 +02:00
Meta Vens
203dc3658e Retry the initial Active presence through Odin's correlation gap
Odin refuses Active until it has correlated the lease Idunn just granted
("Ready topology state is not supported by correlated evidence"); the
first v2 candidate to get past Warming and the lease died on that single
refusal. Retry it with the helper and window the Warming publish already
uses; the periodic republish already only logs. The cadence constant now
names both presences.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:11:26 +02:00
Meta Vens
140445dda8 Let the publisher carry its own custody choice, so the lease-retry tests run on Linux
aca5686's fix was right but its two new tests always called acquire_recent
(custody enforced), and a test lease necessarily lives under a tempdir —
never root-controlled. On the pinned Linux image that failed with "process
write-lease ancestor contains a non-root-controlled directory" instead of
exercising the retry logic; on Windows it passed only because
require_process_write_lease_custody is a deliberate no-op there, which is
exactly the platform gap this whole fix is about. The deploy platform is
Linux, so the tests have to run there for real, not slip through on a
Windows-only no-op.

RuntimePresencePublisher now carries the same enforce_custody choice
ProcessWriteLeaseGuard::acquire_inner already had: true from
from_environment (every production construction path), false from the test
fixture constructor. wait_for_write_lease calls acquire_recent or
acquire_recent_fixture to match. acquire_recent_fixture is no longer
#[cfg(test)]-gated, since production's own wait_for_write_lease source must
reference it (unreachable there, since production publishers always carry
enforce_custody: true, but the symbol has to exist in a release build). No
trait, no env flag — a plain bool threaded through the existing constructor.

Added production_custody_choice_rejects_a_lease_under_a_non_root_tempdir:
builds a publisher the way from_environment does (enforce_custody: true)
via a new fixture_with_state_contract_and_custody helper (the existing
fixture() stays enforce_custody: false, unchanged), points it at the same
empty-revoked-store shape the other two tests use, and asserts
wait_for_write_lease still refuses it with a custody-rejection error rather
than acquiring it. This one is #[cfg(target_os = "linux")]-gated —
correctly, not as a workaround: custody enforcement is architecturally a
root-ownership concept that idunn_health.rs's own code makes a no-op on
every other OS, so there is nothing for the assertion to observe on Windows
regardless of this fix. The two tests from aca5686
(an_empty_revoked_lease_store_times_out_not_not_admitted,
a_lease_written_after_an_empty_revoked_store_still_wins) needed no changes
beyond inheriting enforce_custody: false through fixture() as before — they
were already testing the retry logic, not custody, and now do so honestly on
both platforms since custody enforcement is out of their path entirely.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon (Windows): 192
passed, 0 failed, 4 ignored, unchanged from aca5686 — the new Linux-gated
test does not compile into this binary here, so it does not appear even as
ignored. Awaiting the Linux run for its count and the custody-rejection
assertion.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 20:01:54 +02:00
Meta Vens
aca5686f01 Retry write-lease acquisition, not just its existence check
Live failure under Idunn v2 (deploy up-180cf07f): the candidate died in ~2s
with "process write lease was not admitted: ... zero records". Idunn's own
revocation of a prior candidate's lease leaves the store and its .lock
present but empty (a 22-byte zero-record CultCache file), which
`path.exists() && lock.exists()` cannot distinguish from a real lease.
wait_for_write_lease called acquire_recent exactly once on that signal and
let its failure propagate.

wait_for_write_lease's job is "wait until the exact lease for this
incarnation is admitted", not "wait until something exists at the path". On
each poll where the store exists, it now attempts acquire_recent; on failure
it remembers the error and keeps polling (heartbeats unaffected); only the
outer deadline bails, and that bail carries the last acquisition error as
context for diagnosis. acquire_recent's own exact-match checks are untouched
and stay the authority — nothing here special-cases empty stores by size or
by matching error text, so the same retry covers empty, stale-incarnation,
and not-yet-written stores alike, exactly as asked.

Tests (idunn_health.rs, all run on Windows — decode_write_lease and
acquire_inner's enforce_custody path have a Linux-only branch inside
require_process_write_lease_custody, but that function no-ops on non-Linux,
so this whole path is exercised here too, not gated):
- an_empty_revoked_lease_store_times_out_not_not_admitted: builds a
  present-but-zero-record store via push-then-delete of a throwaway envelope
  (the crate's own public store API, not hand-encoded bytes) — the same
  shape Idunn's revocation leaves. Asserts the wait runs its own full 600ms
  timeout (not an instant "not admitted") and that the timeout error still
  carries "not admitted" in its chain.
- a_lease_written_after_an_empty_revoked_store_still_wins: same empty start,
  but a valid lease for the fixture's own incarnation is written 300ms in
  from a spawned task. Proves the retry re-attempts acquisition on every
  poll, not just the first: the wait returns the written lease.

Mutation: reverted to a single-shot acquire (return on first attempt
regardless of outcome) -> the empty-store test fails in ~1ms with the
pre-fix symptom instead of running its 600ms timeout. Restored; diff-clean
after.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 192 passed, 0
failed, 4 ignored (was 190 before this pass; +2 new tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 19:57:01 +02:00
Meta Vens
a397132223 Tolerate Idunn's activation-publication gap in the daemon's own Warming path
Soul's diagnosis: Idunn publishes Expected and starts the unit before it
observes the running process on a later tick and publishes the activation
(Idunn/src/control_plane.rs). Until that activation exists, Odin refuses
every Warming presence this daemon publishes ("no exact current activation
and provider anchor"), and the daemon's single initial publish_warming()
call, and every heartbeat inside wait_for_write_lease, propagated that
refusal through `?` and killed the process. Muninn tolerates the same gap by
re-publishing every 2s and only logging failures
(Muninn/crates/muninn-daemon/src/main.rs); this cut matches that posture in
Ghostlight, touching only the daemon.

1. initialize_production_admission now retries the initial publish_warming()
   at a 2s cadence (matching Muninn), bounded by the same 120s window
   wait_for_write_lease already used — pulled into one named constant,
   WRITE_LEASE_WAIT_TIMEOUT, reused by both rather than inventing a second
   value. Each retried publish is a genuinely new message, not a duplicate
   Odin would drop: signed_record increments publisher_sequence and refreshes
   observed_at_unix_millis on every call (verified by reading it, not
   assumed). Every failed attempt is logged at warn with its error; the loop
   exits only when the window expires, and returns the accepted presence so
   it carries forward into wait_for_write_lease's recent_warming_proofs
   exactly as the single call used to.

   The retry policy is retry_until_deadline, generic over the attempt
   closure and a sleep/clock seam (no trait hierarchy on the publisher) so
   it's unit-testable without waiting real time out: production passes
   tokio::time::sleep and Instant::now, a test passes a no-op sleep paired
   with a fake clock that only advances when the sleep seam is called.

2. wait_for_write_lease's own heartbeat publish no longer propagates a
   failure through `?`; it logs a warning and keeps waiting, exactly like
   the initial-publish retry, until the wait's own outer deadline.

3. Tests (crates/ghostlight-dungeon/src/runtime.rs, idunn_health.rs):
   - retry_until_deadline_returns_the_first_accepted_value: refused twice
     then accepted succeeds and returns the accepted value, via a fake
     clock — runs in 0.00s, no real waiting.
   - retry_until_deadline_fails_with_the_last_error_after_the_window:
     always-refused fails once the fake clock crosses the deadline,
     carrying the last attempt's own error.
   - a_failed_heartbeat_does_not_abort_the_wait_for_lease: a real fixture
     publisher pointed at a closed port (127.0.0.1:9, the existing fixture
     convention) so every publish fails fast; proves the wait survives past
     one heartbeat interval and only ends at its own outer timeout, not the
     heartbeat's own error. This one uses real timing (~6s) since
     wait_for_write_lease's internal loop has no clock seam and adding one
     was out of scope for this cut; verified by mutation that it would have
     caught the old bug (see below).

Mutations, all verified and reverted (diff-clean after):
   - retry_until_deadline: made the first failure unconditionally fatal
     (`if true || now() >= deadline`) -> both new retry tests fail, the
     first on attempt 1 instead of retrying to attempt 3. Restored.
   - wait_for_write_lease's heartbeat: restored the old `?`-propagating
     form -> a_failed_heartbeat_does_not_abort_the_wait_for_lease fails in
     ~5.6s with "publishing Warming heartbeat ... timed out connecting" (the
     pre-fix symptom), instead of running to the 6s outer deadline. Restored.

Decisions made without a further ruling:
   - Named the shared timeout constant WRITE_LEASE_WAIT_TIMEOUT (both the
     initial-publish retry and wait_for_write_lease's own timeout parameter
     use it) rather than threading one Duration value through both call
     sites by hand.
   - Put retry_until_deadline in runtime.rs, beside
     initialize_production_admission, its only caller — not in
     idunn_health.rs, which owns the publisher/protocol, not retry policy.
   - The heartbeat-tolerance test uses real timing rather than adding a
     clock seam to wait_for_write_lease's own internal loop; that loop's
     shape (poll write-lease file + sleep 100ms + heartbeat check) is
     unrelated to this fix and reworking it for a seam felt like scope creep
     against the operator's instruction to avoid a trait hierarchy.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 190 passed, 0
failed, 4 ignored (was 186 before this pass; +3 new tests here, +1 from
Self's own commits since the last pass).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 17:38:31 +02:00
Meta Vens
1fd52b7c60 Stop comparing LISTEN_PID to the daemon's own pid
Idunn launches managed targets with PrivatePIDs=yes; systemd writes the
outer-namespace pid into LISTEN_PID, which the daemon cannot observe, so
the first v2 probation process exited at once with "systemd authority FDs
belong to another process". Odin made the same change in 8e16f56.

The exact descriptor count and name order, the root-owned read-only
descriptor checks and the signature verification of their contents bind
the descriptors to Idunn; LISTEN_PID must still be a canonical non-zero
pid. The layout test is now pure and runs on every platform; restoring
the own-pid comparison fails it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 17:17:05 +02:00
Meta Vens
9a000e9a30 Declare the build cache environment on every Idunn recipe step
Idunn passes a runner container only the variables its step declares, and
the image's own CARGO_HOME is read-only, so the first v2 build failed
writing cargo's git database for the codex-connector dependency. Rust
steps declare CARGO_HOME and HOME, web steps HOME and NPM_CONFIG_CACHE, as
Odin and Heimdall do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:34:16 +02:00
Meta Vens
4ed5b26688 Retire the Raven-hosted Dungeon launch script
Dungeon runs on Yggdrasil under Idunn v2; Raven hosts only Bonsai and the
link. The scheduled task is unregistered and the secret copies removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 16:27:19 +02:00
Meta Vens
e1c54c8f28 Fix runtime-bundle gid check and strengthen the recipe-drift test (Soul pass 2)
S1 (critical): require_runtime_bundle and require_root_read_only_file required
gid 0 on the Idunn runtime bundle directory and its documents. Idunn creates
the bundle inside runtime_root, which is setgid to the workload's own group
(so the process write-lease record beside it can share that gid,
Idunn/src/drivers.rs), and hardens only uid 0 + mode (0555 dir, 0444 files) —
never gid. The bundle inherits a real workload group, never 0: the live Odin
bundle is root:odin and Odin accepts it. Every managed daemon on Yggdrasil
would fail this exact check at Warming.

Extracted the rule into two pure functions over (uid, gid, mode) —
is_root_service_read_only_directory and is_root_read_only_file — so it is
testable without a Linux filesystem instead of sitting unexercised behind
#[cfg(target_os = "linux")] the way the real check always has (nothing in
this crate built a chowned/chmodded fixture for it before). gid is accepted
as a parameter but never checked, with a comment saying why. Also routed
require_root_controlled_directory_chain's existing check (which never
required gid 0) through the same directory predicate, for one owner of the
rule instead of two copies of it.

Added root_read_only_accepts_any_gid_and_refuses_group_or_other_write,
covering: any gid accepted, owner-write allowed on directories (0o022 never
checked it), group/other-write refused on both files and directories, wrong
uid refused. Mutation: restored `&& gid == 0` in
is_root_service_read_only_directory, confirmed the any-gid assertions fail,
reverted (diff-clean).

S3: recipe_matches_the_daemon_it_deploys previously matched anywhere in the
recipe text, so it passed with Odin's and Heimdall's dependency kinds
swapped, passed when only [[provides]]'s schema was wrong (the world slot's
own copy was still right), was blind to the world/mesh slot schemas
specifically, and never checked STATE_CONTRACT_SHA256 at all. Added
recipe_blocks/find_block to anchor every assertion to its own table (by id
for state slots, by capability for provides/dependencies), and pinned
EXPECTED_STATE_AND_SLOTS_TEXT beside STATE_CONTRACT_SHA256 — the exact
[state]+[[state.slots]] source text the hash was computed from (LF, matching
the committed git blob, not a Windows checkout's autocrlf) — with a comment
that both constants change together. No TOML parser or msgpack encoder is in
this workspace, so the test cannot recompute the hash itself; pinning the
source text it was computed from is the cheapest honest proxy.

Verified all four of Soul's named mutations now fail correctly and were
reverted (recipe.toml is git-diff-clean after): provides schema changed alone
(world slot untouched) -> fails on the provides block; Odin/Heimdall
dependency kinds swapped -> fails on the Odin block; world slot schema alone
-> fails on the world block (previously the blind spot); an app-sessions
writer/recovery edit -> fails on the STATE_CONTRACT_SHA256 text pin.

Found and fixed one collateral break: my new doc comment for
EXPECTED_STATE_AND_SLOTS_TEXT named `StateDeclaration`, which matched the
existing play::tests::soul_no_forbidden_writer_name_appears_in_dungeons_own_source
pin (it scans non-test production source, including doc comments outside a
`#[cfg(test)]` line, and it forbids the substring "Declaration"). Reworded to
"the recipe's own parsed `[state]` struct" — no functional change.

Verification: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon ->
186 passed, 0 failed, 4 ignored (185 -> 186, +1 for the new pure-predicate
test). cargo test -p ghostlight --lib controllers:: -> 131 passed, 0 failed,
1 ignored, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 16:25:12 +02:00
Meta Vens
c716b57730 Cut CodexConnector out of managed admission; fix Heimdall's dependency kind and recipe/binary drift
F1 (critical, Soul): managed admission still bailed without
GHOSTLIGHT_CONTROLLER_CONNECTOR and required Idunn's Expected to carry a
third, connector, dependency that no longer exists anywhere upstream.
idunn_health.rs now reads no connector env, requires exactly Ghostlight's two
real dependencies (Odin, Heimdall), and RuntimeDependencyBindings/
RuntimePresencePublisher carry no connector field. runtime.rs's
open_controller no longer takes an endpoint parameter for a lane it cannot
offer: Dungeon passes None for ConnectorBinding and says why in a comment.
configured_connector_endpoint is deleted. The library's CodexConnector
transport (crates/ghostlight) is untouched, and play.rs's own use of the
codex_connector crate for transcript types is unrelated and untouched.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 15:13:45 +02:00
Meta Vens
6c4a489a10 Steer the map and handoff to the Yggdrasil topology
Operator ruling 2026-09-23: Dungeon on Yggdrasil via Idunn, Bonsai on
Raven over a restricted reverse tunnel on the WireGuard mesh;
CodexConnector is not a Dungeon inference option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:54:40 +02:00
Meta Vens
43f0eb75f7 Record PA.f197: Heimdall documents read as a schema, not as a consumer
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:39:57 +02:00
Meta Vens
94ab279ac4 Read Heimdall's documents as its consumer, not its schema (PA.f197)
Dungeon decoded Heimdall's published command boundary and every private
receipt with deny_unknown_fields over the subset of fields it reads.
Heimdall has published owner, lifecycleAuthority, healthPublication,
forbiddenWriters and compatibility on the boundary since June, and a
schema field on every receipt, so sign-in failed at discovery with
"unknown field `owner`" and would have failed again on each receipt.

Heimdall owns those documents; Dungeon now reads the fields it needs and
ignores the rest. The signed PrivateEnvelope, whose shape both sides fix,
stays strict. The new test decodes the shapes Heimdall emits and fails
with the reported error when the boundary is made strict again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 14:39:29 +02:00
Meta Vens
00475fa400 Record PA.f196: the command result shape the client always rejected
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:23:25 +02:00
Meta Vens
0bf36c7a8a Return command results in the shape the contract admits (PA.f196)
The browser validates every result against gamecult.eve.command_result.v1
before it reads a field of it, and that schema is additionalProperties:false
over schema, receipt, transientProjection, pluginPayload and draftDirective.
Dungeon returned a flat object -- state, message, sourceVersion, providerId,
surfaceId, operationId, idempotencyKey, updatedAtUtc at the top level -- so
the client rejected every result whole with "invalid Eve command result: data
must not have additional properties". Sign-in was simply the first command a
player could press.

The per-command detail now rides in the receipt, which is
gamecult.eve.command_receipt.v1 and does admit its own extra fields, so the
sixth argument keeps working as receipt detail rather than a sibling.

The tests read the shape they now produce, and one new test reads the two
contracts off the pinned vendor/eve checkout and checks a real result against
them, rather than restating the allowed keys in an assertion that would only
ever agree with its own author.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 14:22:01 +02:00
Meta Vens
3a56bf3389 Record the Raven playtest bring-up script
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:27:45 +02:00
Meta Vens
5d3f296747 Record the pass close-out in evidence and drop the compaction scratch
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:54:16 +02:00
Meta Vens
ff156f1c0a Point the map at the playtest and the two unbuilt capabilities
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:53:51 +02:00
Meta Vens
f256fe011b Reconcile the target with the Body, and say what the Dungeon brief has not built
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:53:26 +02:00
Meta Vens
500b13ae74 Write the play agent postmortem
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 11:52:40 +02:00
Meta Vens
9565097c65 Record Soul on Cut 17: the bar is met
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 07:25:57 +02:00
Meta Vens
fe4eb0ed7f Record Cut 17 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 07:14:01 +02:00
Meta Vens
07aae19dae Cut 17: the fourth exit decodes before retiring, the card stops going blank mid-round, and a dead accessor is gone (PA.f194-A/D/E)
PA.f194-A: `PlayTurnStore::open`'s catch-all arm retired a row onto its
declared `schema_id` alone, with no decode attempted. An Idunn rollback
across a schema-id move can hand a build a row stamped with a schema id it
has never seen whose bytes still decode cleanly under the current
`PlayTurnStoreState` shape (nothing about the struct changed, only the id).
That arm now attempts the decode first, exactly like the current- and
legacy-schema arms beside it: retires only on a genuine decode failure, and
leaves a readable row untouched with `open` failing loudly instead. The
sidecar's own `reason` field no longer claims "could not be read" when
nothing tried. `retire_unreadable_row`'s own doc comment now states this as
an explicit precondition on its caller. New test
`a_readable_row_declaring_an_unrecognized_future_schema_is_not_retired`
proves the readable case is not retired; the existing
`a_row_declaring_an_unrecognized_future_schema_is_retired_not_refused_forever`
still proves the genuinely undecodable case is. Mutation: reverting the
catch-all arm to retire on the schema id alone (no decode attempt) fails the
new test; reverted after confirming.

PA.f194-D: with the running hint correctly suppressed while a round is
genuinely in flight (PA.f193-D/PA.f194's own `run_in_progress` gate), the
play card rendered zero rows for the whole span between a player submitting
a turn and its own narration/question/refusal landing — a player who
submits sees nothing and has no sign anything is happening. A "resolving"
row now renders whenever `run_in_progress` is true, regardless of `state`,
staying inside invariant 8 (the player's own turn processing, no world
truth). The surviving hint's own text no longer claims "still being
resolved" — false in the only case it renders (`Running`, nothing actively
working on it) — and now says the turn did not finish. New tests
`the_card_shows_a_resolving_row_while_a_round_is_in_flight` and
`the_running_hints_own_text_does_not_claim_the_turn_is_still_in_flight`.
Mutation: removing the resolving-row block, and separately reverting the
hint text, each fails its own new test; reverted after confirming both.

PA.f194-E: `PlayTurnStore::retired_sidecar` (the field cached once at
`open`) has had no production consumer since PA.f193-E moved `/health`'s own
readiness reporting onto `PlayTable::retired_row_sidecar`, which scans the
store's directory fresh on every read instead of trusting a per-process
flag. Deleted the field, its accessor, and the tuple plumbing that carried it
through `open`'s match arms; `retire_unreadable_row`'s own doc comment no
longer credits it with `runtime_readiness` visibility. The two tests that
exercised it now assert on the sidecar file's own presence on disk directly,
which is what they were actually proving.

crates/ghostlight-dungeon --bin ghostlight-dungeon: 182 passed, 0 failed,
4 ignored (186 with --include-ignored and the Eve bridge built).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:11:24 +02:00
Meta Vens
abb84b49f2 Cut 17: the checkout-detecting test no longer asserts against Idunn's own gate (PA.f194-C)
`vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy`
asserted its positive case unconditionally whenever `git` is on PATH.
Idunn's own gate (`cargo test --locked -p ghostlight-dungeon --bin
ghostlight-dungeon`, the exact command the Yggdrasil runbook documents)
materializes `vendor/eve` byte-for-byte from the Git object store
(`Idunn::drivers::materialize_tree_raw`/`materialize_gitlink_raw`, confirmed
by reading `F:\Projects\Idunn\src\drivers.rs`) and that writer refuses to
ever write `.git` metadata, on the main tree or any Gitlink. So inside
Idunn's own gate `vendor/eve` is unconditionally a copy, not a checkout —
exactly the shape this test's own negative case proves unavailable — and the
positive assertion failed the gate for a reason unrelated to the code under
test.

The positive case now checks `vendor/eve/.git` directly (ground truth
independent of the function under test) and skips with a named reason when
it is absent, asserting only when a real checkout is actually present.
Verified: forcing that check to report absent reproduces Idunn's exact
condition and the test now reports SKIPPED instead of failing; reverted
after.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 07:10:58 +02:00
Meta Vens
6f9a1351a4 Record Soul's final pass (PA.f194) and its judgement
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:43:58 +02:00
Meta Vens
be862f0c15 Name Cut 9 SHAs on its own Landed line
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:31:52 +02:00
Meta Vens
f5cba2051b Record Cut 16 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:23:40 +02:00
Meta Vens
db4f574c57 Cut 16: the retired-row sidecar becomes readable and the retirement signal survives a restart (Soul's owed items 5 and 6, PA.f193-E)
`RetiredPlayTurnRow` wrote `rmp_serde` bytes to a `.msgpack` sidecar and
nothing in this Body could read it back — preservation in name only. It is
now JSON (the GameCult substrate doctrine's own xenos-boundary carve-out: an
operator opening a forensic dump by hand is exactly that boundary, not a
CultCache consumer), with the row's original bytes preserved verbatim as a
base64 field, `payload_base64`, and the sidecar extension renamed from
`.msgpack` to `.json` to match. A round-trip assertion on the existing
undecodable-bytes test decodes the sidecar and proves the recovered bytes are
byte-for-byte the original payload.

`PlayTable::retired_row_sidecar` cached the retirement fact once, at
`open`, keyed off this process's own store construction — so `playStatus`
on `/health` reported plain `ok` again after any restart, even though the
sidecar a prior process wrote was still sitting on disk with the only trace
of what happened. It is now a fresh directory scan on every read
(`retired_row_sidecars_on_disk`, filtered to this store's own filename
stem), so a restarted process still names the retirement until an operator
actually clears the sidecar file. A new test opens a second `PlayTable`
over the same store path after the first one's retirement and proves
`/health` still reports it.

Mutations run and reverted (not committed): `RetiredPlayTurnRow.payload_base64`
back to raw lossy text — killed by the round-trip test. `retired_row_sidecar`
back to a field cached at construction — killed by the restart test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 06:21:37 +02:00
Meta Vens
d082cd2277 Cut 16: a readable current-schema row was destroyed, and the running hint contradicted admit's own refusal (PA.f193-A/B, PA.f193-D)
`PlayTurnStore::open`'s current-schema arm routed every `Err` from
`read_current_schema_row` into `retire_unreadable_row`, but that function had
three exits and only one was a genuine decode failure: a canonical
re-encode mismatch or an inner `schema` tag disagreement both mean the row
decoded fine. Before this fix either case destroyed a perfectly readable
turn on the very next restart, the identical failure PA.f185 already fixed
for the legacy arms. `read_current_schema_row` now returns
`CurrentSchemaRowError::{Decode, Other}`, mirroring `LegacyRowError`: only
`Decode` reaches `retire_unreadable_row`, `Other` propagates as an ordinary
`open` failure and leaves the row untouched. Two new tests build a row that
decodes but fails each check and prove it survives open unretired.

Separately: `eve.rs`'s play card rendered "submit an empty message to
continue" hint for every `Running` turn, including the seconds a healthy
turn is genuinely running its own round loop. In that window `admit` fails
`run_lock`'s own `try_lock_owned` before inspecting anything and refuses —
contradicting the hint it had just shown. `PlayTurnView` now carries
`run_in_progress`, a non-blocking peek at `run_lock` taken alongside
`current_turn_view`'s other reads, and the hint gates on `state ==
Running && !run_in_progress`. `PlayError::TurnStillRunning` is split: the
lock-contention exit is now `TurnBusy` (its own accurate text), leaving
`TurnStillRunning`'s "only an empty continue is accepted" true for the one
case it still covers — text is non-empty on an otherwise-idle Running turn.

The `eve.rs:479`/`play.rs` doc comments both carried "PA.f188" for this
finding, colliding with the real PA.f188 (`open_question_of`'s construction-
site token coverage, `play.rs:9197`); both renumbered to PA.f193-D with a
note where the old number was.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 06:06:21 +02:00
Meta Vens
63964b067d Cut 16: the vendor/eve checkout check certifies a copy as a checkout, and the eve-bridge tests skip silently (PA.f193-C, Soul's owed item 3)
`vendor_eve_git_checkout_available` ran `git -C vendor/eve rev-parse HEAD`
alone, which succeeds for a plain copied directory: git walks up past the
missing `.git` and answers with whichever enclosing repository it finds,
which inside this checkout is Ghostlight's own HEAD. Soul measured it
directly. The existing unit test never caught this because its own fixture
was a bare tempdir outside any repository, so it only proved "outside any
repo" fails, never "inside an enclosing repo, copied, not cloned" wrongly
succeeds.

Now the check also compares `git -C vendor/eve rev-parse --show-toplevel`,
canonicalized, against `vendor/eve`'s own canonical path, so a copy sitting
inside an enclosing repository is correctly rejected. A new test builds a
real `git init` scratch root, copies a fake vendor/eve into it, proves the
naive check really would have wrongly succeeded there, then proves the fixed
check refuses it.

Soul's owed item 3: the four tests that drive the real vendored Eve client
bridge (node + a built eve-browser-lowering + eve-contracts) used to early-
return with an `eprintln!` when those dependencies were absent, which cargo
captured and discarded on a passing test — the suite reported "178 passed"
identically whether or not the bridge was actually exercised, and only
`--nocapture` showed the difference. They are now `#[ignore]`d with a
documented `--ignored` opt-in, so a normal `cargo test` run's own summary
line distinguishes "not exercised" (ignored count) from "passed" with no
extra flags, and attempting an explicit `--ignored` run without the
dependencies now panics loudly instead of silently succeeding.

Cost: these four tests no longer auto-run on a workstation that happens to
have the dependencies installed; running them is now an explicit opt-in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 06:06:02 +02:00
Meta Vens
e53764389a Record Soul on Cut 15 (PA.f193) and rule Cut 16
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 05:54:32 +02:00
Meta Vens
30c710e3e0 Record Cut 15 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 05:37:19 +02:00
Meta Vens
1dea8f5d67 Cut 15: the play card names the empty-submit continue while a turn is stuck Running
A turn a process died mid-round leaves the store's own single current-turn
row at Running forever: nothing resumes it on its own, and run's own admit
arm for a Running turn only continues it on an empty-text submit (non-empty
text is refused as TurnStillRunning). The card rendered nothing at all in
that state — no narration, no question, no refusal, since none of those are
set on a turn that never reached a close, a question, or a refused act since
it last opened — so a player had no way to discover the empty-submit
continue exists at all.

eve.rs's play-card construction now adds a world.play.running row whenever
the current turn's own view reports Running, naming the empty-submit
continue directly. This is a control hint about the player's own turn, not
world truth, so it stays within invariant 8 the same way the existing
narration/question/refusal rows already do.

Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 178
passed (177 + this one). Ran the mutation quoted in the new test's own doc
comment (replacing the state check with `if false`) and reverted it; it
failed the test as expected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:36:22 +02:00
Meta Vens
ce382a8557 Cut 15: subscribe to revisions after any sign-in, not only the redirect flow, and keep a poll fallback (PA.f191)
main.ts subscribed to the api/eve/events revision stream exactly once, in an
`if (api.hasAuthenticatedSurface())` check that ran right after host.start()
and the redirect-flow resumeHeimdallAccess() call resolved. authenticatedSurface
reflects whatever the *last* surface() fetch saw, and it can flip from false
to true well after that point: a player who signs in through the in-page gate
(heimdall.auth_completion_status.v1, resolved entirely inside the vendored
host's own submit()/refresh() cycle, on its own 100ms-later timer) never
retriggers that one-shot check. That tab never got a revision subscription
for the rest of the session, so every answer after the first was refused as
stale and the card looked frozen.

trySubscribeToRevisions() replaces the one-shot check: idempotent, called
after host.start(), after the redirect flow's own refresh, and on a 2s
recheck interval, since main.ts has no direct hook into the in-page flow's
own completion moment.

pollMs moved from 0 to 5000: the app relied entirely on SSE for live
updates, so a dropped stream (closed by an intermediary, or simply never
subscribed) had no fallback at all. The vendored host's own poll already
no-ops when the surface version has not moved, so this is a low-frequency
safety net, not a second primary channel.

Verified: npm run typecheck (clean) and npm test (3/3, unchanged) in web/.
No headless-browser harness exists for main.ts's transport/host wiring; this
was verified by tracing the vendored host's own submit()/refresh()/consumeCommandResult
control flow (vendor/eve/packages/eve-browser-lowering/dist/index.js) against
the fix, not by an automated end-to-end test. Flagging as a follow-up in the
Cut 15 report rather than adding test infrastructure out of scope for this cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:32:17 +02:00
Meta Vens
d366316a21 Cut 15: readiness reports a retirement, seed refusals stop naming the inference host, and the bridge probe checks git (PA.f186, PA.f187, PA.f190)
runtime_readiness derived playStatus from state.play.is_some() alone, so a
PlayTable::new that succeeded by retiring an unreadable row (PA.f184) still
read as plain "ok" on /health — a trade Cut 14 made without meaning to: the
same row reported "unavailable" before PA.f184 existed. playStatus now
carries the sidecar path when this process's own open retired a row,
through PlayTable::retired_row_sidecar (the previous commit's own plumbing),
reusing the existing field rather than adding a sibling channel.

seed_once's runner.sweep(1) call rendered its ControllerError straight
through error.to_string() into RuntimeCommandError::Payload, which
dispatch_world hands the player verbatim. Soul measured the real string on a
fresh workstation with no inference endpoint running: "invalid command
payload: OperationalAgent inference failed: the local inference endpoint
refused the connection: error sending request for url
(http://127.0.0.1:11434/v1/chat/completions)" — this process's own host and
port, on the single likeliest seed failure there is. describe_seed_failure
now categorizes instead (ControllerError's own Display is untouched; every
other caller still sees the detailed message). The vault-open path and the
play route were confirmed clean by Soul and are not touched here.

eve_client_bridge_is_available proved node, the built lowering, and its
module graph, but not the `git -C vendor/eve rev-parse HEAD` precondition
one of its own gated tests asserts with .expect(...) rather than a skip.
Soul hit that .expect by accident against a copied (not cloned) vendor/eve.
vendor_eve_git_checkout_available is now its own parameterized check
(checked last, after the cheaper node/file checks) so a copied directory
reports unavailable instead of reaching that .expect at all; a scratch-dir
test proves the copy case without touching the real submodule.

Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 177
passed (node and the real vendor/eve checkout were both available, so every
gated test ran rather than skipped). Ran the mutations quoted in every new
doc comment by hand and reverted them; each failed the test it names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:32:00 +02:00
Meta Vens
f295a3a4fb Cut 15: retire every unreadable play-turn row, not just legacy ones (PA.f184, PA.f185, PA.f188, PA.f189, PA.f192)
play::PlayTurnStore::open's current-schema arm and its final catch-all still
bail!ed unconditionally on a row it could not read, so a damaged or
future-schema row (any PlayTurn field-set change that lands without moving
STORE_SCHEMA) left the play card gone forever, exactly the PA.f177 gap but
for the arms PA.f177 itself did not touch. Both now retire through the same
retire_unreadable_row path the legacy arms use.

upgrade_legacy_row's own three non-decode exits (an inner schema tag that
disagrees with the envelope, and two compare-and-swap failure shapes) used to
be indistinguishable from a genuine decode failure once open caught the Err,
so a readable turn (Soul's probe: envelope v1, inner schema v2, decodes
cleanly) was destroyed under a sidecar reason that falsely claimed it "did
not decode". LegacyRowError now splits Decode from Other; only Decode
retires, Other propagates as an ordinary open failure that leaves the row
untouched.

Adds test coverage at the two construction sites PA.f179 fixed but never
pinned (open_question_of, and run's own StaleAnswer arm) proving a missing
question_token surfaces as None, not a fabricated Some(""); both fail under
Soul's exact mutation.

The retirement sidecar is now named *.msgpack instead of *.cc (PA.f192): it
is a one-shot forensic dump via std::fs::write, never a CultCache store, and
should not read as one. retire_unreadable_row's own doc comment now states
the write-before-destroy ordering and the orphan-sidecar risk explicitly
(PA.f189): a failed compare-and-swap after a successful sidecar write leaves
that sidecar in place with no corresponding successful retirement.

PlayTurnStore and PlayTable now remember the sidecar path a startup
retirement wrote (PA.f186's own plumbing; runtime.rs wiring in the next
commit).

Verified: cargo test -p ghostlight-dungeon --bin ghostlight-dungeon, 177
passed. Ran the mutations quoted in every new doc comment by hand and
reverted them; each failed the test it names (see the Cut 15 report for the
transcript).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 05:31:39 +02:00
Meta Vens
03576fd3e5 Record Soul on Cut 14 (PA.f184-PA.f192)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 05:03:14 +02:00
Meta Vens
3d63749e14 Record Cut 14 landing and PA.f183
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 04:32:16 +02:00
Meta Vens
0808c6a7fd Cut 14: refuse a stale tab's token, a token smuggled via bindings, and a leaked seed config name (PA.f178, PA.f179, PA.f180, seed refusal)
PA.f179 (runtime side): the `world.play` gate compared
`payload.answer_token.as_deref() != Some(question.token.as_str())`, which
forced `question.token`'s own `""` fallback (play.rs's own PA.f179 fix,
previous commit) into a value an attacker could literally send. Extracted
to `answer_token_admits(given, expected)`, which requires both sides
`Some` and equal — `expected: None` refuses unconditionally, including
against `given: None` or `given: Some("")`. Directly unit-tested
(`answer_token_admits_only_two_present_and_equal_tokens`), independent of
the HTTP round trip.

PA.f178: with no question currently open, `payload.answer_token` was
never read at all — a stale tab resubmitting its own spent token fell
through to `answers: None`, and its stale prose silently opened a *new*
turn, spending a real inference budget on an answer to a question that no
longer exists. A token arriving with no question open is now refused
outright ("that question is closed"), before `admit` is ever called.

PA.f180: `unwrap_bindings` merged the envelope's `bindings` object and a
button action's own sibling fields into one map with no memory of which
channel a field arrived on. `answerToken` is only ever legitimately an
action-sibling field (`eve.rs` never renders it as a captured control),
so a request built by hand that sent the *correct* token through
`bindings` instead would have been indistinguishable from the real thing.
`ACTION_ONLY_PAYLOAD_FIELDS` (currently just `answerToken`) is now
checked before the merge and refused if found inside `bindings`; every
other command's own real shape (action fields as siblings, captured
values inside `bindings`, arriving together) is unaffected.

Seed refusal leak: `seed_once`'s vault-root refusal named
`GHOSTLIGHT_SEED_VAULT_ROOT` directly to the player — a fact about this
process's own environment, not about the world (invariant 8). Replaced
with a player-safe "seeding is not available on this world"; the actual
env var name is still logged at `warn` for the operator. The other
refusals on this route (`seeding a world is its owner's lane`, `the
cognition organ is closed`, `VaultEvidenceSource::open`'s own errors,
which are deliberately vault-relative and never carry a path) were
checked and do not leak. `ControllerError`'s own `Display` (from
`runner.sweep`) is out of scope for this cut — see the Hands report for
why.

PA.f176: `eve_client_bridge_is_available()` checked only `node
--version` and that the vendored lowering's *committed* `dist/index.js`
is a file — always true regardless of whether either vendored package's
own `npm install` had ever run. Measured: with `node` present and no
`npm install` run anywhere, the old check reported "available" and the
four gated tests failed outright on `jsdom`; after installing only
`eve-browser-lowering`'s own deps, still failed on `ajv` (the sibling
`eve-contracts` package's own dependency, in its own `node_modules`,
which an install scoped to `eve-browser-lowering` never populates). The
check now spawns a real `node` process that does exactly what
`tools/eve_client_bridge.mjs` does at import time (resolve `jsdom`
through the lowering's own `package.json`, then dynamically import the
built `dist/index.js`, which is what actually pulls in `eve-contracts`
and `ajv`) and reports "available" only if that succeeds — cached behind
a `OnceLock` so the four gated tests share one real probe. All four
SKIPPED messages updated to name both vendored packages' own `npm
install`.

Tests added: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn,
an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted,
an_ordinary_answer_with_text_via_bindings_and_token_as_a_sibling_is_still_accepted
(negative proof for PA.f180), answer_token_admits_only_two_present_and_equal_tokens,
the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable.

Mutations verified by hand this pass (applied, confirmed the named test
failed, then reverted):
- removing the `payload.answer_token.is_some() && !is_awaiting_player`
  guard: a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn
  fails (the stale resubmission is wrongly "accepted").
- reverting `answer_token_admits` to
  `given.unwrap_or_default() == expected.unwrap_or_default()`:
  answer_token_admits_only_two_present_and_equal_tokens fails (`None,
  None` and `Some(""), None` wrongly admit).
- removing the `ACTION_ONLY_PAYLOAD_FIELDS` check from `unwrap_bindings`:
  an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted fails
  (the smuggled token is wrongly "accepted").
- reverting the seed refusal to
  `format!("{SEED_VAULT_ROOT_ENVIRONMENT} is not configured")`:
  the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable
  fails.
- making `eve_client_bridge_is_available()` return `true` unconditionally
  once `dist/index.js` exists (the pre-PA.f176 shape): with both vendored
  packages' `node_modules` removed, the suite goes from 170 passed to 166
  passed / 4 failed with a real `Cannot find module 'jsdom'` node crash —
  reproducing the exact bug this cut fixes.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 170 passed,
with both vendored packages' own dependencies installed. With `node`
present and both packages' `node_modules` removed: 170 passed, 0 failed,
4 skipped (verified this pass by temporarily moving both directories
aside and restoring them afterward). A true "no `node` on PATH" run could
not be produced in this sandboxed workstation (multiple node shims exist
on PATH outside the vendored packages); the early `node_on_path` check
this cut did not touch is unchanged from before, and
`the_committed_eve_client_bridge_fixture_round_trips_through_api_router_with_no_node`
already proves the node-independent fixture-replay path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:30:43 +02:00
Meta Vens
58324f8233 Cut 14: retire unreadable legacy play rows, stop degrading a missing answer token to empty (PA.f177, PA.f179, PA.f181)
PA.f177: `applied_keys`/`fault` predate `question_token` under the same
`LEGACY_STORE_SCHEMA_V1` schema id, but only `question_token` carried
`#[serde(default)]`. A row older than either field failed
`upgrade_legacy_row`'s decode with "missing field `applied_keys`",
`PlayTurnStore::open` returned `Err`, and the play card silently
disappeared forever (every restart hit the same row). Both fields now
carry `#[serde(default)]` for the same reason `question_token` already
does; the current-schema (v3) canonical re-encode check still catches a
current row missing either, since `to_vec_named` always re-emits them.
A legacy row that still cannot be decoded even leniently is now retired
to a timestamped sidecar file beside the store and play starts fresh,
logged at `warn` next to the log `open_play`'s own failure path already
uses, instead of leaving `open` failing on the same row forever.

PA.f179: `OpenQuestion::token` was `String`, fabricated from
`Option<String>` via `unwrap_or_default()`. A turn whose `question_token`
was `None` (the mint defeated) collapsed to `token: ""`, and the runtime
gate compared `Some("")` — an attacker-typable value — instead of
refusing outright. `token` is now `Option<String>`; the two construction
sites (`open_question_of`, the `StaleAnswer` arm) carry the real value.

PA.f181 (no code change): documents, at the `QuestionId` equality match
in `admit`, that a genuine mismatch is reachable in production only
through the race window between `runtime.rs`'s own read and this
function's fresh read — the one test that exercises it forges
`PlayRequest.answers` directly, since the token gate refuses everything
else upstream. The check stays; the narrowness is now recorded where it
lives.

eve.rs: `question.token` (now `Option<String>`) serializes to a JSON
`null` when no token was minted, which `answer_token_admits` refuses the
same as any other mismatch — comment only, no behavior change on this
side.

Tests added: a_legacy_v1_row_predating_applied_keys_and_fault_still_opens_and_upgrades,
a_genuinely_undecodable_legacy_row_is_retired_to_a_sidecar_and_play_starts_fresh.
Mutations verified by hand (documented in each test's own comment; each
was applied, run, and reverted this pass): removing `#[serde(default)]`
from `applied_keys` turns the first test into a panic ("the legacy row's
own turn must be recovered"); removing the sidecar-retirement fallback
from the `LEGACY_STORE_SCHEMA_V1` branch turns the second into a panic
("a damaged legacy row must be retired, not fail open outright").

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon: 170 passed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 04:30:06 +02:00
Meta Vens
52828fff7b Scope the interpretation boundary to the library, and mark step 16 as cutting
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 03:52:42 +02:00
Meta Vens
8ec8a3d8c5 Steward the map and handoff onto the post-Cut-14 body
The map's next action and the handoff still described the world before the
play-agent pass.

Falsified against source: both claimed no local-model transport exists.
crates/ghostlight/src/local_inference.rs has existed since Cut 2, and
runtime.rs opens it on GHOSTLIGHT_LOCAL_ENDPOINT. Corrected to "exists,
unexercised" so the next session does not rebuild it.

Also retired: "world.speak is the only human action" and the story-card
description, both deleted by Cut 9 (f1732c0); and "the cut map is next",
written before Cuts 1 through 14 landed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 03:51:33 +02:00
Meta Vens
8cc92e2f88 Record Soul on Cuts 12 and 13 (PA.f176-PA.f182)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 03:39:49 +02:00
Meta Vens
a978a82f86 Record Cut 13 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 03:11:45 +02:00
Meta Vens
2f2a8b671f Land Cut 13: bind an answer to its question with an opaque token (PA.f170)
Two questions asked in one turn carried the same eve::surface_version
(asking is conversational and never commits), so the version-only
staleness check runtime.rs's world.play arm ran could not tell Q1 and Q2
apart: an answer composed for Q1 was accepted and silently resolved Q2.

Each question now mints an opaque random token (play::mint_question_token,
uuid v4) that carries no turn id, round, slot, subject, or other world
handle. PlayTurn.question_token replaces question_surface_version;
OpenQuestion.token replaces OpenQuestion.surface_version. The "Play"
button's own props.action carries the token (eve::authenticated_surface),
riding DOM-invisibly the same way props.action.command already does — a
real client spreads a button's own action fields straight into the
payload beside bindings (commandPayload,
eve-browser-lowering/src/index.ts:2101). runtime.rs's world.play arm
refuses an answer whose answerToken does not name the currently open
question's own token; PlayPayload gains answer_token.

unwrap_bindings no longer refuses a sibling next to the envelope
bindings object outright (PA.f168's original rule) — it merges bindings'
own fields with the sibling action fields instead, refusing only a
genuine name collision between the two. An unrecognized sibling is still
refused, one door downstream, by the operation's own
#[serde(deny_unknown_fields)] payload struct.

The play-turn store moves to ghostlight.play_turn_store.v3 and reads v1
and v2 leniently, upgrading in place (PlayTurnStore::open,
upgrade_legacy_row) the same way Cut 12 upgraded v1 to v2. A recovered
row whose question is still open mints a fresh token as part of the
upgrade, since no earlier schema recorded one and the card is re-rendered
from whatever the row holds.

Deleted with the version comparison: question_surface_version and the
per-round fresh-snapshot fetch execute_round took solely to compute it
(PA.f161/PA.f175's own reason to exist). QuestionId and OpenQuestion
survive: QuestionId (turn_id/round/slot) is still play.rs's own internal
admission-matching key, and OpenQuestion still carries it plus the
question's text — neither existed only to serve the deleted comparison.

Soul's own probe is now a passing regression test
(soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw): two
scripted questions in one turn, an answer carrying Q1's own token
(read off the served surface) is accepted for Q1 and refused as stale
against Q2. Mutation coverage: a missing token, a mismatched token, and
Q1's already-spent token replayed against Q2 are each refused. The real
client bridge round-trips a token through props.action and a bound text
field arriving together in one payload
(the_real_client_carries_the_answer_token_and_the_bound_text_together).
The committed eve_client_bridge_intents.json fixture needed no
regeneration: its own world.play click never has an open question, so
its action never carried a token to begin with.

crates/ghostlight-dungeon/src/play.rs, crates/ghostlight-dungeon/src/runtime.rs,
crates/ghostlight-dungeon/src/eve.rs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 03:10:58 +02:00
Meta Vens
1560c35403 Record Cut 12 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:49:02 +02:00
Meta Vens
241b33ef2f Refuse to type into a disabled control in the Eve client bridge (PA.f174)
`tools/eve_client_bridge.mjs`'s `set` step found the rendered control and
typed into it without checking whether it was disabled, so a control a
real browser would render disabled -- and refuse keystrokes into -- still
silently captured a value here. That made PA.f167's own gap (a disabled
control cannot be proven untestable through the bridge) cheap to close:
the bridge now throws "bridge: node id <id>'s own control is disabled; a
real client cannot type into it" before assigning `element.value`.

BEHAVIOR CHANGE NOTE: this changes tools/eve_client_bridge.mjs's own
runtime behavior -- a `set` step against a disabled control now throws
instead of silently succeeding. Anyone driving the bridge directly against
a surface that renders a disabled field for the node ids it types into
will see a new failure where there was none before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:28:01 +02:00
Meta Vens
7052644467 Land Cut 12: gate the answer path, recover pre-Cut-11 play stores (PA.f169, PA.f172, PA.f173, PA.f175)
PA.f169 (blocker): two more `client_intents` callers
(`world_create_seed_activate_and_play_round_trip_through_the_real_client`,
`a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted`)
ended in `.expect("node must be on PATH ...")` with no skip guard, so
`cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon` itself
failed in a `node`-less container -- Idunn's own gate. Both now carry the
same `eve_client_bridge_is_available()` skip guard the third caller already
had, printing a plain SKIPPED line naming themselves. Verified with `node`
scrubbed from PATH: all three skip and the full bin suite is still
156 passed, 0 failed.

PA.f172: `question_surface_version` (added in Cut 11 with no
`#[serde(default)]`) made a pre-Cut-11 `PlayTurnStore` row fail to decode,
which `PlayTurnStore::open` reported as "corrupt" and `runtime.rs` turned
into a silently missing play card. `STORE_SCHEMA` moves to
`ghostlight.play_turn_store.v2`; `question_surface_version` now carries
`#[serde(default)]`, so the same struct is a compatible read of a genuine
v1 row (`LEGACY_STORE_SCHEMA_V1`). `PlayTurnStore::open` recognizes that
schema id as legacy, not corrupt, recovers the turn, and upgrades the row
to v2 in place and durably. The strict canonical re-encode check still
applies to current-schema rows; a v1 row's bytes were never canonical
under v2 and the legacy branch does not attempt the comparison. A row that
matches the current schema id but is genuinely undecodable is still
refused as corrupt. `runtime.rs::runtime_readiness` now reports
`playStatus` ("ok"/"unavailable") on `/health`, the same
`Option -> ok/unavailable` shape `projectionStatus`/`controllerStatus`
already use, so a play table that failed to open is visible there instead
of only in a startup `tracing::warn!`.

PA.f173: `close_with_fault` left `question`/`question_surface_version` set
on the turn it closed. Unobservable through `open_question_id` only
because it gates on `state == AwaitingPlayer`, which closing always
leaves behind in the same write. `close_with_fault` now clears both,
matching `answer_turn`.

PA.f175: `execute_round` took one snapshot at round entry and reused it
for every arm in that round, so a round that authors (a `PLAY_TOOLS` run)
and then asks in the same round recorded `question_surface_version` from
before its own commit. The `ASK_PLAYER_TOOL` arm now fetches a fresh
snapshot at the moment the question actually opens.

Tests added: a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt,
a_row_with_the_current_schema_id_and_undecodable_bytes_is_still_refused_as_corrupt,
close_with_fault_clears_the_open_question_off_the_persisted_row,
a_round_that_authors_and_then_asks_records_the_version_as_of_the_question_not_the_round,
health_reports_play_status_from_state_play.

Dungeon bin: 156 passed, 0 failed (bridge available); 156 passed, 0 failed
with `node` scrubbed from PATH (3 SKIPPED).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:27:44 +02:00
Meta Vens
5b1f55c30c Rule PA.f170: an opaque per-question token, and delete the version comparison
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:07:51 +02:00
Meta Vens
54ed2b3df0 Record Soul on Cut 11 (PA.f169-PA.f175)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 02:02:44 +02:00
Meta Vens
d8b8e86100 Record Cut 11 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:41:24 +02:00
Meta Vens
4155823250 Land the answer path and the release gate (Cut 11, PA.f161-PA.f168)
PA.f161: `world.play`'s staleness check compared a client's echoed
`sourceVersion` against `authenticated_wake_version` (surface_version +
play.revision), a value no real client ever echoes and that only ever grows
once a row has held any question. Every answer was refused, permanently. The
play row now records the world's own `surface_version` on `OpenQuestion`
itself at the instant a question opens (`execute_round`'s `ASK_PLAYER_TOOL`
arm); an answer is stale only when it names a version older than that.
`ScriptedPort` and its fixture helpers in `play.rs`'s own test module are
widened to `pub(crate)` (test-only) so `runtime.rs` can script a deterministic
question through the real HTTP route and prove both the accept and the
refusal.

PA.f162: the release gate shelled out to `node tools/eve_client_bridge.mjs`,
which Idunn's pinned Linux containers install no dependencies for. The real
vendored lowering's own intents for the full create/approve/seed/activate/play
round trip are now captured once, stamped with the `vendor/eve` submodule
revision that produced them, and committed
(`src/fixtures/eve_client_bridge_intents.json`). The gate replays them through
`api_router` with no `node` anywhere — verified by running with every
`node_modules` directory in the tree renamed out of the way and `node` off
`PATH`. A second, dev-only test regenerates the fixture through the real
bridge and diffs it, skipping (and saying so plainly) when `node` or the built
lowering is unavailable.

PA.f163: deleting the spawned turn's own `publish_projection` call left the
wake test green, because `dispatch_world`'s Ok arm already publishes a
revision for the immediate `accepted` response, before the turn's own round
ever executes. The test now drains that first message, waits for the turn to
actually close, and only then asserts a second, distinct wake.

PA.f164: `targets` and `jurisdictions` carried only a `placeholder`, which the
real lowering never captures; a create form filled exactly as its labels
suggest was refused as missing those fields. They (and `brief`, documented
"required, may be empty" the same way) now carry an authored `value` the
lowering's own `findAuthoredBindingValue` falls back to.

PA.f165: the surface document's own `playRevision` field had no reader beside
one direct-JSON-access test; deleted. `PlayTurnView::revision` still drives
`authenticated_wake_version`'s SSE wake internally.

PA.f166/PA.f167: corrected `PlayTurnView::revision`'s doc (still described the
fold Cut 10 undid) and `local_draft`'s doc, which called every field but
`bindingName` inert — `accessMode` is load-bearing too: an absent one defaults
the lowering's binding to `"read"` and disables the control.

PA.f168: `unwrap_bindings` now refuses a payload carrying both the envelope
`bindings` object and a flat sibling field instead of silently discarding the
sibling.

Cut the scar-tour narration at eve.rs's `authenticated_wake_version` doc, the
`authenticated_surface` play-arrival comment, and `publish_projection`'s own
comment; each now states the current rule directly instead of narrating the
cut that produced it.

Every rule above ships with a test that fails under a mutation of its
production call site; each was verified failing under that mutation and
reverted by hand before this commit.

Dungeon bin: 145 -> 151 tests (6 added). Library untouched, still 622.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 01:40:26 +02:00
Meta Vens
c3da080f85 Record Soul on Cut 10 (PA.f161-PA.f168)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 01:00:50 +02:00
Meta Vens
0c0932427a Record Cut 10 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 00:38:21 +02:00
Meta Vens
43b6af61a5 Land the client round trip (Cut 10, PA.f148-PA.f160)
Cut 9 finished the play loop and gave the player an Eve card. Soul's
own probe over the vendored browser lowering found the player could
not actually use any of it: every bound command submitted
{"bindings":{}}, the card never woke after the first refresh, and one
play commit poisoned the version the kernel's compare-and-swap is
derived from.

One owner per version (PA.f148/PA.f160). The surface document's own
`version` names world.revision alone -- the one meaning
routeHint.sourceVersion and the kernel's compare-and-swap require of
it. The play row's own revision travels beside it as its own
`playRevision` field instead of folded in. The SSE wake signal
(`state.revisions`, `/api/eve/events`) is the one place the combined
value still lives, renamed `authenticated_wake_version` -- it is
never echoed back by a client, so folding is exactly its job.

The client round trip (PA.f149). `eve::local_draft` now emits the
vendored lowering's own EveStateBindingDescriptor shape -- copied
field-for-field from the vendor's own test fixture
(test/host-isolation.test.mjs), which builds exactly this under a
`ghostlight.play` surface id for its own local-draft binding -- so
`bindingName` actually matches what captureBindings names, instead of
falling back to the control's own node id. runtime.rs now unwraps the
lowering's own payload.bindings envelope before deserializing (the
documented gamecult.eve.command_invocation.v1 shape), and JSON-typed
textarea fields (targets/jurisdictions/lens_weights) accept the JSON
string a real textarea actually captures, not only an already-parsed
value. world.create's own form was also missing a control for
`brief`, a required field CREATE_BINDINGS had always advertised --
found by driving the real lowering over the real surface
(tools/eve_client_bridge.mjs), which no hand-built payload could ever
have caught.

The card wakes (PA.f150). publish_projection now runs after a play
turn's own execution finishes, on the same door eve_command's Ok arm
uses, not only at startup and after a world commit.

The question id never reaches the player (PA.f151). The lowering has
no authored, non-editable binding value ("hidden" is not a prop any
renderer reads), so the answers control is deleted outright --
server-side, execute_world's world.play arm resolves the open
question itself, and refuses a stale routeHint.sourceVersion (one
that predates the question) instead of trusting a client-named id.
PA.f84's guarantee survives: an answer cannot land on a question the
player never saw.

Play failures reach the player (PA.f153/PA.f154). PlayTable::run is
split into admit (replay/stale-answer/empty-answer/busy-turn/empty-
opening, synchronous) and execute (the round loop, spawned). The
route now awaits admit and answers denied with the reason instead of
discarding every refusal behind an already-sent accepted response.

Ownership (PA.f152/PA.f159). The play card, its controls, and the
world.play descriptor are owner-gated like world.advance_time and
world.seed, on both the surface and the route. player_subject now
resolves by human_controller matching the calling principal, not
merely by ControllerMode::Human alone. world.summary drops the
subject census; phase and clock stay.

Cleanup (PA.f155-PA.f158): stale doc references to the deleted
world.speak/story surface; PlayTurnView::turn_id and .state given
production readers (an error log line, and the admission gate's own
condition); the play row's revision surviving a restart pinned by a
new assertion; mutation M9.1 retired with the reason it can no longer
be expressed (the parameter it needed was deleted in Cut 9).

Tests: Dungeon bin 139 -> 145 (6 new), lib 622 -> 622 (untouched).

Discrepancies and known gaps, reported rather than resolved silently:
- The envelope-unwrap fix extends past the literal "envelope" wording
  into payload *shape*: the JSON-textarea deserializer
  (deserialize_json_capture) is an extrapolation beyond what the map
  named, needed because the lowering has no JSON-typed control and so
  can only ever capture such a field as text.
- The stale-answer refusal (execute_world's source_version check) has
  no dedicated runtime.rs test: reaching AwaitingPlayer needs a
  working inference backend, and the crate's only scripted one
  (ScriptedPort) is private to play.rs's own test module, not
  reachable from runtime.rs. The round-trip test's own "answer" leg is
  scoped down to create/seed/activate/play for the same reason; PA.f151's
  server-resolved-answer mechanism is proven instead at the play-table
  layer, in play.rs's own (updated)
  a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration.
- world.play's own route now checks world ownership explicitly before
  admission; this was not named as an explicit sub-item but follows
  directly from "owner-gated like world.advance_time and world.seed."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 00:36:43 +02:00
Meta Vens
d9f01595a0 Add the Eve client bridge Cut 10 verification runs on
Drives the real vendored eve-browser-lowering (submodule pin unmoved)
over a jsdom-rendered surface document: types into controls, clicks
buttons, and prints the resulting gamecult.eve.command_invocation.v1
intents. tools/eve_client_bridge.mjs is the harness the runtime.rs
round-trip test (next commit) uses to prove the client round trip
against Dungeon's own served surfaces, not a hand-built payload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 00:36:11 +02:00
Meta Vens
aa8cb86ddb Record Soul on 8b-fix and Cut 9 (PA.f148-PA.f160)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:55:43 +02:00
Meta Vens
50cde3fd47 Record Cut 9 landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:35:00 +02:00
Meta Vens
f1732c0ae5 Delete world.speak and the story card (Cut 9, part b)
The play card and its own revision counter landed in the prior commit and
were verified end to end there (a question answered through the card's own
rendered binding, a turn closing with its narration visible through the
card, and the card's own subtree never rendering the operator feed). This
commit removes the mechanism the play card replaces.

Deleted: SpeakPayload, the "world.speak" arm in execute_world, eve.rs's story
card and speak control (the opportunity/affordance lookup that fed it),
"world.speak" from operation_schema, and current_operator_view (its only
purpose was fetching the operator log for the surface; authenticated_surface
no longer takes that parameter at all). world.summary's own body no longer
counts committed events, since that count depended on the same log read.
WorldMailbox::operator_log stays in the library; nothing in this crate's own
surface path calls it any more except one Cut 9 regression test that proves
the play card structurally cannot reach it.

Rewrote the tests that drove speech through Eve: http_eve_journey_uses_one_
world_owner's last leg now plays instead of speaking, and observes the play
table (current_turn_view) rather than trusting the accepted response alone
(PA.f138). eve.rs's payload_cannot_claim_authority and invocation_metadata_
rejects_legacy_authority_extensions now exercise world.play; the former
absorbed the now-redundant world_play_operation_schema_validates_though_not_
yet_advertised, since world.play is advertised as of the prior commit.
removed_operation_is_unknown gained "world.speak" as a case. every_operation_
the_panel_emits_has_a_schema now asserts world.play is among the buttons it
walks.

`rg -n 'world\.speak|operator_log' crates/ghostlight-dungeon/src` is not
literally zero: two deliberate hits remain (see the Hands report for exact
lines) — one regression assertion that the operation id is gone, and one
test-local call into the surviving library method to prove the play card
cannot reach what it returns. No production path matches.

Dungeon bin: 140 -> 139 tests (two redundant world.speak/world.play schema
tests merged into one), all passing. ghostlight lib: 622, untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:33:33 +02:00
Meta Vens
322bdf69c6 Add the Eve play card and the play row's own revision (Cut 9, part a)
PlayTurnStoreState carries a persisted `revision: u64`, incremented once by
PlayTurnStore::swap_in (the row's one write door) on every committed write.
It never resets across a new turn or a restart, per the operator's ruling on
the fork Hands raised: a counter derived from existing row fields (calls.len,
rounds.len, ...) cannot see a refusal, which changes turn.refusal without
growing calls or rounds. PlayTurnView now carries the same revision, read
through PlayTable::current_turn_view.

eve.rs's authenticated_surface reads PlayTurnView and renders a play card
(world.play.card: narration, open question text, refusal line) plus a
world.play control binding ["text", "answers"], where "answers" is the open
question's own QuestionId, JSON-encoded into a bound control exactly as
lens_weights/jurisdictions/targets already encode a local-draft "json" field's
default value. authenticated_surface_version folds surface_version(snapshot)
with the play row's own revision, so a play-row-only change (no kernel patch,
e.g. a refusal or a closed turn's narration) still moves the surface version.

world.speak, the story card, and current_operator_view's log read for the
surface are all still present and unchanged; this is the "add" half of
add-first-delete-second. Both mechanisms coexist on the Active surface for
this commit.

New tests (play.rs): the_play_surface_shows_only_the_projection (the play
card's own subtree never renders operator_log content, even when it is
durably committed and world.story still legitimately shows it elsewhere
pending the deletion commit — mutation-verified by hand: rendering
operator_log into the card fails this test), a_question_is_answered_through_
the_cards_own_binding_and_the_turn_closes_with_narration (PA.f134 end to end,
answering with the exact id the rendered surface emitted, and proving
surface_version moves even though world.revision does not), and
a_commit_changing_only_the_refusal_still_moves_the_rows_own_revision (the
isolated regression for the fork itself — mutation-verified by hand: keying
the increment off calls.len() growing fails this test).

Dungeon bin: 137 -> 140 tests, all passing. ghostlight lib: 622, unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 23:24:48 +02:00
Meta Vens
4a1a700005 Rule the play row's own revision counter for Cut 9
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 23:07:04 +02:00
Meta Vens
46c09f1d48 Record fix batch 8b-fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:59:50 +02:00
Meta Vens
22eb918d1b Fix batch 8b-fix: answerable questions, dispatch resume, busy refusal (PA.f134/f136/f137/f139/f142/f143/f144/f140)
RunOutcome now names the turn and its open question on every Ok path
(Ran/Replayed), and PlayError::EmptyAnswer/StaleAnswer carry the still-open
question a caller can retry against, so a consumer never has to read the
store to learn a QuestionId. PlayTable::current_turn_view is the small new
door a later, separate reader uses instead, since world.play's own route
spawns run in a task and answers before that call resolves (PA.f134).

Dispatch now resubmits its recorded subject list on resume instead of
re-parsing the tool call's arguments against whatever the round's own
snapshot says now, matching every other door's "recorded body decides" rule
(PA.f136). run_lock is acquired with try_lock, not a blocking wait, so a
request racing an in-flight turn is refused at once with TurnStillRunning
rather than queued behind up to ROUND_BUDGET rounds of inference (PA.f142).
turn.refusal is cleared when a player act commits, so it names only the most
recent one (PA.f144). service/play-turn-v1.cc joins the admitted state
layout's own pre-flight symlink/hardlink check (PA.f140).

Soul's own findings get their production-path tests: round_snapshot's two
callers pinned behaviorally through infer_round and execute_round separately
(PA.f137), dispatch_owner's earliest-call-wins rule pinned against a
duplicate dispatch (PA.f139), and three-subject concurrent dispatch pinned
against JSON order in both the summary and persona_turns (PA.f143). The
world.play routing test now polls current_turn_view to prove the spawned
task actually calls table.run, not just returns the same accepted JSON
(PA.f138).

PA.f147 (missing idempotency key) and PA.f141/f145/f146 (doc claims) were
investigated and are covered in Hands' own report rather than landed as
code: the described idempotency-key hole is already closed by
execute_world's own unconditional command_id parse before world.play's
branch is ever reached (mutation-verified), and the doc fixes are the
smaller diff already included in eve.rs/play.rs/runtime.rs here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:58:57 +02:00
Meta Vens
4e8f36ca80 Amend Cut 9 for the question binding and the deletion's sequencing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:27:04 +02:00
Meta Vens
064c031c62 Record Soul on 8a-fix5 and Cut 8b (PA.f134-PA.f147)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:25:57 +02:00
Meta Vens
e35454c3d6 Record Cut 8b landing
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 22:08:27 +02:00
Meta Vens
a99fdfe412 Fix batch 8b-runtime: wire the play table into AppState, GHOSTLIGHT_PLAY_MODEL, world.play routing, ghostlight.world_play.v0
- AppState gains play: Option<Arc<PlayTable>>, populated exactly when
  controllers is (both come from the one open_controller call). Cut 8b's
  own map bullet asked for a play field plus a mutex; the "one running turn
  per world" mutex (PA.f68/PA.f112) instead lives inside PlayTable itself
  as run_lock (landed in the prior play.rs commit), since this process
  holds exactly one Arc<PlayTable> for the one world it owns and a second
  AppState-level lock over the same table would be a second owner of the
  same invariant. Reported per the brief; see the Hands report for the
  full account.
- open_controller now reads GHOSTLIGHT_PLAY_MODEL beside its four
  siblings and passes it into the same open_inference call, returning the
  opened inference organ and models alongside the ControllerRunner
  (OpenedControllers) so the play table shares one connection to the
  transports rather than opening a second. A new open_play helper builds
  the PersonaLane and PlayTable from that shared organ, using
  controller_permits as the play table's own concurrency pool.
- execute_world routes "world.play" ahead of the CommandBody match (it is
  not one kernel command): spawns PlayTable::run in a background task and
  returns {"kind":"accepted"} immediately, exactly as the map calls for.
  A missing play table is denied, not a panic.
- eve.rs's operation_schema carries "world.play" =>
  "ghostlight.world_play.v0" beside "world.speak", unchanged otherwise;
  world.speak and the story card are untouched (Cut 9's own deletion).

Tests: world_play_reaches_the_play_table_and_is_accepted and
world_play_without_a_play_table_is_denied (runtime.rs), plus
world_play_is_advertised_and_payload_cannot_claim_authority (eve.rs,
beside payload_cannot_claim_authority for world.speak).

Dungeon bin: 125 -> 128 tests. ghostlight lib: 622 (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 22:06:33 +02:00
Meta Vens
1b20a0fc5d Fix batch 8b-play: round_snapshot owner, actor refusal line, concurrent dispatch PA.f13/f68/f105/f112/f124/f127/f133/PA-Q8
- Collapse infer_round's and execute_round's own duplicate snapshot-fetch
  and handle-collision check into one round_snapshot() owner both call
  (PA.f127/PA.f133). Delete the structural
  soul_execute_round_and_round_tools_share_the_collision_refusal_call test
  now that there is only one collision_refusal call site to count, and add
  a_forced_handle_collision_refuses_the_round_through_round_snapshot, a
  behavioural test driven through a new #[cfg(test)] force_round_snapshot
  seam, matching before_submit_hook's own shape.
- execute_actor_call's ActionRejected arm now resolves the invocation's own
  AffordanceSnapshot from the live snapshot and sets turn.refusal from
  describe_refusal_to_actor (invariant 8: bare role names, no id, no
  resolved label), instead of the agent-facing describe_refusal text.
  Covered by a_refused_player_act_sets_the_refusal_line, seeding a
  Draft-phase affordance with a real (Holds) precondition through
  decode_authoring_call/CommandBody::AdmitPatch, since PLAY_TOOLS has no
  door to a custom precondition.
- execute_dispatch now dispatches a round's fresh Personas concurrently:
  each pending subject's turn is spawned under its own permit drawn from
  the shared controller_permits pool (PersonaLane is now held as an Arc),
  preserving PA.f124's own within-call duplicate-subject rule with an
  explicit claimed_this_call set (concurrent dispatch means a repeat can no
  longer be detected by checking turn.persona_turns alone, since neither
  occurrence has landed there yet). Persists remain sequential once every
  concurrent turn has resolved, so two subjects never race the same turn
  row's compare_and_swap_batch. close_turn's own narration now also draws
  a permit from the same pool, so it queues like a dispatch would.
- PlayTable gains run_lock, held for the whole body of run (PA.f68/f112):
  the key-replay check and a fresh turn's own open now happen under one
  acquisition, so two concurrent run calls for one table can never
  interleave. Two new tests:
  two_concurrent_world_play_calls_for_one_world_give_one_turn (same key,
  concurrent calls: one Ran, one Replayed, one turn) and
  two_worlds_own_play_tables_run_independently (two tables never share a
  lock).

Dungeon bin: 122 -> 125 tests. ghostlight lib: 622 (unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:59:21 +02:00
Meta Vens
032cb3e738 Record play loop fix 8a-fix5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:32:46 +02:00
Meta Vens
43987dcfb0 Fix batch 8a-fix5: dispatch call body durable before its effects PA.f125-PA.f132
PA.f125 (blocker): execute_dispatch persisted a dispatched Persona's own
turn before execute_round ever recorded the Dispatch call's own body. A
crash in that window left persona_turns durable with no call record
naming it; on resume the already-durable turn was misread as a
different call's completed dispatch, so the resumed call's own tool
result became "<id> already acted" and the Persona's prose never
reached the agent (PA.f64). execute_round now records and persists the
call's own body before execute_dispatch runs any persona turn;
execute_dispatch tells its own not-yet-finished work apart from a
different call's completed dispatch through the new
PlayTurn::dispatch_owner (first call, in call order, to name a
subject). Replaced PA.f123's own test with
a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted,
which guards both the live durability ordering (before_submit_hook off
disk) and the resumed-call outcome (hand-built crashed turn).

Cleanup:
- PA.f128: closed a fourth turn in the ledger retry test so the
  retried key's own entry sits at neither end of the ledger deque,
  catching a `self.closed.back()` mutation the prior two-turn shape let
  through.
- PA.f129: handle_for derives its handle from the library's own
  id_text instead of a second hand-written debug-paren strip.
- PA.f130: deleted PlayError::NoPlayerOpportunity — no production site
  raises it.
- PA.f131: removed the unneeded #[allow(dead_code)] on
  WorldFixture::owner (the abort tests already read it).
- PA.f132 (ghostlight-persona-projection): corrected SourceSpan::locate's
  doc comment, which claimed the first-byte check changed the worst
  case rather than being a constant factor over non-matching
  candidates; replaced the guard test's quote (which never called
  starts_with at all) with a shared-prefix quote that forces the real
  worst case.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 21:31:30 +02:00
Meta Vens
75a154a1a9 Record Soul on 8a-fix4 (PA.f125-PA.f133)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 21:11:13 +02:00
Meta Vens
647917d53e Record play loop fix 8a-fix4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 20:46:05 +02:00
Meta Vens
9a2cb2eafe Fix batch 8a-fix4: SourceSpan::locate performance and guard tests PA.f120, PA.f121
locate compared quote against every boundary with starts_with before
checking anything cheap, and allocated a fresh HashSet copy of the boundary
list on every call. Now compares the first byte before starts_with, and
binary-searches the already-sorted boundaries Vec for the end offset instead
of a second HashSet. The doc comment's stale "repeated-find shape below this
comment" line (referring to code this file no longer has) is replaced with
the real bound and a pointer to the new regression guard.

The existing guard (locate_completes_over_a_long_source) used a 64k-char
single-word source with only two boundaries, so it never exercised the
O(boundaries) shape the bound is about. locate_is_fast_over_many_boundaries_
and_a_long_quote adds a 528,000-char many-boundary source against a
264,000-char non-matching quote. Measured on this workstation (this test
alone, cargo test's own reported time): pre-fix ~0.31s, post-fix ~0.13s —
real, but well short of the 1.73s figure this task's own brief quoted for a
"528k against a 264k quote"; that number was not reproduced with this exact
input shape and is not claimed here.

Two rules had no test: locate_returns_the_leftmost_boundary_valid_candidate
(reversing the boundary walk returns the last candidate instead of the
first) and locate_refuses_an_empty_quote (removing the empty-quote guard
locates it as the vacuous span 0..0). Verified live: reversing the walk
returned start_byte 8 instead of 0; dropping the guard located "" as
Some(0..0); both reverted before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:42:44 +02:00
Meta Vens
5b9671b267 Fix batch 8a-fix4: dispatch summaries print through id_text PA.f124
execute_dispatch's own summaries ("already acted", "holds no live Persona
opportunity") printed {subject:?}, a fifth id spelling (raw SubjectId(...)
debug) alongside table_view's printer, handle_for, and dispatch's own
matcher. Both now print through the library's id_text.

New test dispatches the same subject twice in one call to reach the
"already acted" branch and asserts the summary contains id_text's own
spelling and never "SubjectId(". Verified live: reverting either print to
{subject:?} fails the test; reverted before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:39:12 +02:00
Meta Vens
91e80d37c2 Fix batch 8a-fix4: dispatch persists its own recorded Persona turn PA.f123
execute_dispatch was the only call kind that did not persist at its own
site: every other tool call's own door persists its recorded body before
submitting, but a dispatch call's own continue reached none of that until
the round's own later persist. A crash between two subjects in one dispatch
call, or between the dispatch call and the round's own trailing persist,
would re-infer the subject's Persona prose on resume, and an actor call
already checked against the recorded prose would be checked against
different prose instead. execute_dispatch now calls self.persist(turn)
immediately after recording each dispatched subject's own PersonaTurn.

PA.f99's before_submit_hook test seam now also fires from execute_dispatch
right after that persist, so a test can read the durable (disk) turn at
that exact instant rather than the in-memory copy, which is mutated
regardless of whether the persist happened. A first naive test (checking
disk state from the *next* call's own before-submit point) passed even with
the persist deleted, because that next call's own persist writes the whole
turn regardless — a false sense of coverage worth recording. The real test
reads disk from execute_dispatch's own hook fire. Verified live: deleting
execute_dispatch's own persist call while keeping the hook fire made the
test read the stale pre-dispatch turn and fail; reverted before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:37:01 +02:00
Meta Vens
a395210036 Fix batch 8a-fix4: retry delay is a real field, not a cfg(test) swap PA.f122
skip_retry_delay_in_test was a #[cfg(test)] switch, so production's
retry_delay never ran the tested path. retry_delay_base_ms is now an
ordinary AtomicU64 field on PlayTable, defaulting to RETRY_DELAY_BASE_MS
(100ms, the value backoff_delay always used); backoff_delay itself now takes
the base as a parameter instead of a hardcoded constant. Production and test
both call the identical retry_delay -> backoff_delay path; only a test
explicitly zeroes the base through the test-only set_retry_delay_base_ms
setter (matching set_before_submit_hook's own shape). before_submit_hook
stays #[cfg(test)], with a comment on why it differs from the delay: it is
Dungeon's own crash-window test seam with no production caller, not a
formula production must also exercise.

The two retry tests now set the base to zero explicitly and one asserts
elapsed wall time stays under 2 seconds. Verified live: making retry_delay
ignore the field and always scale from the production base pushed
a_retryable_fault_exhausts_its_budget_then_closes to 36.5s real time,
failing the new bound; reverted before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:31:44 +02:00
Meta Vens
d042dc7c31 Fix batch 8a-fix4: shared collision check and covered snapshot faults PA.f118, PA.f119
PA.f118: round_tools's own handle-collision check and execute_round's
resume-path copy now share one owner, collision_refusal, so the two cannot
drift. WorldFixture keeps its mailbox's own background owner task instead of
discarding it as `_owner`, so a test can `.abort()` it and make the very
next mailbox call fail deterministically (aborting only kills future calls;
it does not affect the persona lane's own scripted inference port, since
that never touches self.play). Two new tests exercise this: one forces
execute_round's own snapshot() to fail on a resumed round; the other
dispatches two Personas together with a custom AbortAfterFirstPort that
aborts the owner right after the first dispatched subject's own inference
call completes, so that subject's dispatch finishes normally and the
second's execute_dispatch-internal snapshot() then fails deterministically —
in-process and race-free rather than timing-based. Both assert the turn
closes with its fault recorded rather than propagating the error out of run
unhandled. The false in-code claim that no deterministic way to force this
existed is deleted.

PA.f119: the resume-path collision check (execute_round) could not
previously be reached with a genuine two-real-subject collision the way
round_tools's own test reaches it, because execute_round always re-fetches a
fresh snapshot from the live kernel and SubjectId is only ever minted as a
random v4 UUID with no public forgery path — only round_tools takes a
caller-supplied snapshot. A source-presence test
(soul_execute_round_and_round_tools_share_the_collision_refusal_call), the
same shape this file already uses for
soul_exactly_one_production_site_mints_play_port, asserts collision_refusal
is called exactly twice in production, catching deletion of either call
site; the reachability gap is recorded, not silently declared solved.

Verified live: reverting execute_round's and execute_dispatch's own snapshot
match arms to a bare `?`, and deleting either collision_refusal call site,
each broke exactly the test meant to catch it; all four mutations reverted
before commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:27:06 +02:00
Meta Vens
5af2a180c3 Fix batch 8a-fix4: ledger and cross-persona quote coverage PA.f115-PA.f117
PA.f115: span_is_exact's own subject filter (invariant 4) had no test that
would fail if it were deleted. New test dispatches two Personas together and
has one speak the other's own recorded turn prose verbatim, expecting
refusal. Verified live: deleting the filter made the test fail with
`applied` instead of a refusal; the filter itself was not touched.

PA.f116: the_ledger_forgets_a_closed_turns_keys_past_the_window now reads
through turn_id_for (contains is gone, see PA.f117) and,
separately, a_retried_key_from_a_turn_before_the_most_recent_is_a_no_op adds
a third closed turn and retries the middle one so the matched ledger entry
is not the front entry. With only two closed turns the retried key's entry
always happened to sit at the front, so a `turn_id_for` that ignored the
search and returned the front entry's id passed unnoticed. Verified live:
that mutant now fails the assertion; reverted before commit.

PA.f117: KeyLedger::contains is deleted — its only remaining caller was the
eviction test, now moved onto turn_id_for. Rebuilding with `cargo test`
(the module has no production caller outside tests until 8b wires it, so a
plain `cargo build` flags nearly everything and is not the right lens)
turned up exactly four items in play.rs genuinely unused even by the test
suite: PlayError::NoPlayerOpportunity (no site raises it yet — narrow
#[allow(dead_code)] added, reported below) and a trio of orphaned test
helpers (persona_model, TableFixture, table_with) with no caller anywhere,
which are deleted rather than allowed, since nothing else touches them.
`main.rs`'s blanket `#[allow(dead_code)] mod play` is removed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:15:23 +02:00
Meta Vens
7bede2f6d1 Fix batch 8a-fix4: dispatch id parsing and refusals PA.f113, PA.f114
PA.f113: Dungeon's `full_id_text` and the fourth hand-copy of it in the test
module (`subject_id_text`) are deleted. `annotate_actor_tools` and
`parse_dispatch` now read/match ids through the library's own `id_text` and
`id_text_matches`, so a change to what `table_view` actually prints cannot
pass silently again. The test helper `subject_id_text` now reads a subject's
id out of a real `table_view` render's own `Subjects:` section (not the
`Places:` occupant listing, which prints the same "label [id]" shape earlier
in the string) instead of re-deriving it, and every call site was updated to
pass the snapshot. Verified live: mutating table_view's Subjects-section id
print to append a suffix made `dispatch_by_the_printed_full_id_runs_the_persona`
fail with a refusal quoting the mutated text; reverted before commit.

PA.f114: `parse_dispatch` used to silently drop a non-string subjects entry,
a malformed (non-object or non-JSON) body, and a missing `subjects` key,
contradicting PLAY_INSTRUCTIONS' promise that a bad id is refused rather than
dropped. Each shape is now refused in the tool result, quoting what was
given: invalid JSON, a non-object body, a missing `subjects` key, a
non-array `subjects`, and a non-string entry. Three new tests exercise
`parse_dispatch` directly, the exact function the dispatch tool call handler
runs arguments through.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:08:02 +02:00
Meta Vens
340d5836e5 Fix batch 8a-fix4: library id printer/parser PA.f113
table.rs owns one id printer (id_text, now pub) and its matching parser
(id_text_matches), both against the same debug-paren strip. table_view
prints through id_text; id_text_matches is exact equality against it, so a
consumer reading an id an agent typed cannot drift from what the printer
prints without a shared edit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 20:07:48 +02:00
Meta Vens
2305ae7651 Record Soul on 8a-fix3 (PA.f113-PA.f124)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:55:59 +02:00
Meta Vens
3b156c0a28 Record play loop fix 8a-fix3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 19:26:43 +02:00
Meta Vens
754c7fe288 Fix batch 8a-fix3: Dungeon play loop (PA.f98-PA.f109, PA.f111)
- PA.f98 (high): dispatch takes each subject by its full id, exactly as
  table_view prints it in brackets, never the short <handle>__ actor-tool
  prefix. parse_dispatch now matches on a new full_id_text (mirrors the
  library's own id_text, undocumented-but-identical, without handle_for's
  8-hex truncation) and returns unmatched ids separately; the dispatch call
  site folds them into the result as a named refusal ("`<text>` names no
  dispatchable subject") instead of dropping them. Actor tool descriptions
  now carry their own subject's label and full id (round_tools annotates the
  library's own actor_tools output in place, so table.rs stays untouched).
  dispatch's own tool description and PLAY_INSTRUCTIONS restated to match.
- PA.f104 (medium): dispatched is no longer snapshotted once before a
  round's own calls execute; an actor call now derives it fresh from the
  turn's own record at the point of resolution, so a same-round
  [dispatch(Mara), <mara>__speak, end_turn] resolves speak identically on a
  first pass and on resume.
- PA.f99 (medium, test gap): added a before-submit test hook
  (#[cfg(test)] only) fired right before each of the three submit doors
  (commit_authoring_run, execute_advance_time, execute_actor_call). It hands
  the hook a fresh PlayTurnStore::pull_current_from_disk_for_test() read —
  a fresh `pull_all` against the store's own already-owned redb Database,
  never the cached self.state and never a second store (CultCache's
  single-owner lock refuses a second OwnedRedbMessagePackBackingStore::new
  on the same path outright; discovered building this seam). Three new
  tests observe the durable body at the moment of submission.
- PA.f100 (medium, test gap): the same request key, reused only after
  eviction from the KeyLedger window, must mint two distinct fresh turn_ids.
- PA.f101 (medium, test gap): answers: None while a question is open is
  refused (StaleAnswer) and records nothing.
- PA.f102 (medium, test gap): split the old "exhausted beyond retries" test,
  which actually only ever drove RecoveryRequired, into an honestly-named
  RecoveryRequired test (asserts exactly one call) plus a new Retryable
  budget-exhaustion test (asserts exactly ROUND_BUDGET + 1 calls, pinned to
  a literal, not to the ROUND_RETRY_BUDGET symbol, so a budget mutation
  can't drag the expectation along with it). retry_delay's zero-under-test
  behavior is now its own small #[cfg(test)] bool (skip_retry_delay_in_test)
  rather than a #[cfg(test)] swap over the whole formula; the formula itself
  (backoff_delay) is a free function, always compiled, with its own unit
  test.
- PA.f103 (medium, test gap): added tests for a narrate failure right after
  end_turn and at the round budget, both closing with the fault recorded.
  The snapshot-failure site in execute_round is reported, not covered: no
  deterministic way to fail WorldMailbox::snapshot exists in this crate's
  test harness without tearing down the mailbox's own background actor
  independently of the fixture; documented in place.
- PA.f105 (low-medium): the resume path through execute_round now runs the
  same handle-collision check round_tools runs (it never did). Added a real
  test forcing a genuine collision between two real SubjectIds (spliced via
  serde_json, since SubjectId is #[serde(transparent)]) and calling
  round_tools directly — this was wrongly reported infeasible in the prior
  pass. The resume-path (execute_round) wiring itself has no live-collision
  test: forcing one needs the kernel's own snapshot to already carry one,
  and the kernel mints every SubjectId internally; documented alongside the
  precedent this codebase already uses for the same shape of gap
  (a_refused_player_act_sets_the_refusal_line).
- PA.f108 (low): empty or whitespace-only text with no turn open to
  continue (none at all, or Closed) is refused (PlayError::EmptyOpening)
  before a fresh turn opens, making zero inference calls.
- PA.f109 (low): run's outcome is now RunOutcome::Ran or
  Replayed { turn_id }; a replay reads ClosedTurnKeys::turn_id for the
  first time anywhere in production (via a new turn_id_for_key on the
  store and turn_id_for on the ledger).
- PA.f111: PLAY_INSTRUCTIONS' own doc comment rewritten off the "fresh
  table_view per round" framing into the recorded-opening rule (S6).

Every mutation named in the task spec (delete/move each of the three
PA.f99 persists, turn_id=hash(key), delete the round_tools collision call,
accept a None answer, accept an empty opening, make RecoveryRequired retry,
raise the retry budget, remove the backoff cap, turn each of the two
testable close-with-fault sites back into a bare `?`) was applied, run
against its matching test, confirmed to fail, and reverted.

Test counts: Dungeon bin 98 -> 113, lib 622 -> 622 (one test renamed and
reshaped, no net add), persona-projection 30 -> 32.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:25:58 +02:00
Meta Vens
d28912cf64 Fix batch 8a-fix3: library seams PA.f106, PA.f107, PA.f110
- PA.f106: SourceSpan::locate no longer repeats a `find` over a shrinking
  haystack on each rejected overlap candidate. It now tests `quote` at each
  UAX #29 word boundary directly, dropping the repeated-find shape that made
  a pathological input (a long single-word run) quadratic. Doc comment
  rewritten off "used to return None" history into the current rule
  (PA.f111). Added a 64k-char regression guard.
- PA.f107: added Soul's multibyte overlap case
  ("xéé éé éé", "éé éé"), which a byte-stepping mutation (iterating raw byte
  offsets instead of the boundary list) fails against directly (verified,
  reverted).
- PA.f110: sets_persona_of_existing_subject_scans_every_operation_not_just_the_first
  put the persona write at the end of a 2-op patch, so a `.rev().take(1)`
  mutation on the production scan would still pass. Renamed and rebuilt with
  the persona write strictly in the middle of a 3-op patch (harmless
  Relocate before and after), so both `.take(1)` and `.rev().take(1)` fail
  it (verified both, reverted).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 19:25:26 +02:00
Meta Vens
cd7bcaa523 Record Soul on 8a-fix2 (PA.f98-PA.f112)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:31:55 +02:00
Meta Vens
935c9d061e Record play loop fix 8a-fix2
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 18:16:11 +02:00
Meta Vens
74b628dfd7 Fix batch 8a-fix2: Dungeon play loop (PA.f83-PA.f97)
One Dungeon commit: turn identity, the answer/continue contract, fault
closing, and retry disposition all read and mutate the same PlayTurn/
PlayTurnStore state machine inside run()/execute_round(), so splitting them
across commits would leave intermediate commits that don't build toward a
coherent state (e.g. PA.f84's answers-matching depends on PA.f83's
turn_id/QuestionId identity; PA.f85's close_with_fault helper is what
PA.f94's retry-disposition and PA.f97's collision fault both close through).

PA.f83 (turn identity): turn_id is minted fresh (Uuid::new_v4) when a turn
opens; never taken from a request key. The store's PlayTurnStoreState now
also carries a KeyLedger: a closed turn's own applied_keys are archived into
it (PlayTurnStore::open_new_turn) when the next turn opens, covering the
most recent KEY_LEDGER_WINDOW (64) closed turns plus the still-open one's
own applied_keys (PlayTurnStore::key_known). A key found either way is a
no-op.

PA.f84 (answer identity): PlayTable::run now takes a PlayRequest{text,
answers: Option<QuestionId>} (PlayRequest: From<&str>/From<String> keep
almost every existing call site unchanged via `.into()`). QuestionId is
(turn_id, round, slot), derived by PlayTurn::open_question_id from the
currently open ask_player call. An AwaitingPlayer turn only accepts an
answer whose QuestionId matches exactly (PlayError::StaleAnswer otherwise).
A Running turn accepts empty text as a plain continue and refuses non-empty
text (PlayError::TurnStillRunning). `answers` given with no question open
is PlayError::NoQuestionOpen.

PA.f85/PA.f93 (close-with-fault helper): one close_with_fault(turn, detail)
closes the turn, records the fault, and persists it. Every fault path after
a turn opens now goes through it: the three duplicated inline blocks
(authoring-run Invariant, advance_time Invariant, actor-call Invariant) are
collapsed into it; a request-build failure, a snapshot failure (execute_
round's own, execute_dispatch's per-subject one), and a narrate failure
(close_turn, called from both the round-budget path and end_turn) all route
through it too, rather than leaving the turn Running behind a bare `?`.

PA.f94 (retry disposition): infer_round's retry loop now reads
InferenceFault::disposition() through the new absorb_or_close helper: only
Retryable retries, bounded by ROUND_RETRY_BUDGET with a small capped
exponential backoff (retry_delay, #[cfg(test)] zeroed — the simplest
injection seam). RecoveryRequired and IntegrityViolation close on their
first occurrence. The stale "library gap" doc and its dangling "module docs
at the bottom" reference are deleted.

PA.f86: no production change was needed — rebuild_conversation already used
turn.opening_prompt, not a fresh table_view. Added the missing regression
test and ran the described mutation against it.

PA.f87: deleted the byte-offset pin test. Replaced with two behavioural
ones: a_resumed_authoring_run_resubmits_its_recorded_shared_patch (a 2-call
run's crash-window resume, mirroring the existing single-call precedent) and
commit_authoring_run_records_the_run_even_when_its_submit_is_refused (a
first-attempt run whose kernel submission is genuinely refused still records
its own decoded patch before/regardless of that refusal). Both mutations
named in the spec (delete the record, move it into the success arm) were
run against the second test and fail as required.

PA.f88 (Dungeon side): added play_tools_is_pinned_to_its_exact_contents_
with_no_communicate, pinning PLAY_TOOLS's exact 24 entries.

PA.f90: PlayTurn::dispatched_subjects now requires a persona_turns entry for
the subject, not just a Dispatch call record — a dispatch whose Persona
faulted or found no opportunity no longer counts as dispatched.

PA.f95: the round-budget doc, applied_keys/fault's dead #[serde(default)]
(and the same reasoning applied to the new KeyLedger field), and
close_turn's/begin_turn's unused parameters are all gone.

PA.f96: replaced the old dispatch_command_id_for_handle unit test with
dispatch_order_never_changes_the_request_id_a_subject_gets: two Persona
subjects dispatched under one shared turn_id, once [Mara, Borin] and once
[Borin, Mara], comparing each subject's own inference request_id (which
carries dispatch_command_id's actual output) across both orders via the
production execute_dispatch path itself, not a recomputed hash. The
position-hashing mutation was run against this test and fails.

PA.f97: handle_for is now the subject id's first 8 hex digits. A collision
among the snapshot's own subjects is a turn fault via find_handle_collision,
wired into round_tools (its Result<_, String> already flows through
infer_round into close_with_fault). Two subjects sharing a handle cannot be
forced from real, randomly issued SubjectIds (no public constructor exists
outside ghostlight), so find_handle_collision's own detection algorithm is
tested directly against two real ids under a shared, hand-assigned handle
string; the genuine end-to-end fixture collision is reported as infeasible
in this pass's own report rather than faked.

Test counts: Dungeon bin 78 -> 98, lib 621 -> 622 (unchanged by this
commit, from the prior library batch), persona-projection 26 -> 30
(likewise). Every new/changed rule was run against its own reverting or
loosening mutation and confirmed to fail before being restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 18:15:07 +02:00
Meta Vens
3d6a58e958 Fix batch 8a-fix2: library seams PA.f89, PA.f92, PA.f88 note
PA.f89: SourceSpan::locate (ghostlight-persona-projection/src/lib.rs) used
str::match_indices, which never reports overlapping matches, so a
boundary-invalid candidate at an earlier byte offset hid a later,
boundary-valid candidate that overlapped it: locate("Juno no no", "no no")
and locate("xab ab ab", "ab ab") both returned None. Fixed by scanning with
find() from an advancing offset and, on a rejected candidate, resuming one
char past that candidate's own start rather than skipping past its end. The
UAX #29 boundary rule itself is unchanged; whitespace-only and
punctuation-only spans still locate.

PA.f92: WorldPatch::sets_persona_of_existing_subject (ghostlight/src/patch.rs)
had only single-op fixtures, so a .take(1) mutation on its operations scan
would have passed every test. Added a multi-op test where a harmless
Relocate sits at index 0 and the persona write to an existing subject arrives
at index 1.

PA.f88 (partial, library side): the PLAY_TOOLS fixture in
ghostlight/src/table.rs is the library's own test copy, not a policy owner;
added a one-line comment saying so and naming Dungeon's PLAY_TOOLS in
ghostlight-dungeon/src/play.rs as the actual owner. The Dungeon-side pin test
lands in the Dungeon commit.

Tests: cargo test -p ghostlight-persona-projection (30 passed, was 26) and
cargo test -p ghostlight --lib (622 passed, was 621). Both new/changed tests
were run against their own reverting mutation (match_indices restored;
.take(1) added) and failed as expected before being restored.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 17:33:56 +02:00
Meta Vens
cdf48f4a80 Record Soul on 7d and 8a-fix (PA.f83-PA.f97)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:28:19 +02:00
Meta Vens
80e66496cc Record play loop fix 8a-fix
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:10:17 +02:00
Meta Vens
029970a2c5 Fix batch 8a-fix: PA.f54, f61-f66, f69
Repairs the Cut 8a play loop against the cut map's own fix batch:

- PA.f61: `communicate` leaves PLAY_TOOLS (24 tools, matching table.rs's own
  pinned list); `set_persona_material` is refused on an existing subject
  and stands only for a subject this same authoring run declares, using
  the new `WorldPatch::sets_persona_of_existing_subject`.
- Actor prefix: `execute_actor_call` passes the real handle prefix and the
  full, unstripped call name to `decode_actor_call`, so the library's own
  prefix strip is the only one; `round_tools`/`execute_actor_call` now
  share one `actor_prefix` helper so generation and decode can never drift.
- PA.f54: `commit_authoring_run` decodes a run through the library's
  `decode_authoring_calls`, commits the whole run as one shared patch under
  one command id (derived from the run's own anchor slot), and a
  `PatchRejected` refusal is described through the run's own `DecodedBatch`
  site map so it names the exact failing call. `execute_authoring_call` is
  now dead and removed.
- PA.f62/PA.f63: turn identity (`turn_id`) is separated from the request
  key. `PlayTurn.applied_keys` records every key this turn has answered to;
  a replayed key (open or closed) is a no-op making zero inference calls,
  a fresh key on an open turn continues it, and a fresh key with no open
  turn (none stored, or the stored one closed) opens a new turn under it.
- PA.f64: a dispatched Persona's own prose is the dispatch call's own tool
  result (labelled by subject), not "{label} acted" — `rebuild_conversation`
  was already generic over every call's recorded result, so this is the
  whole fix.
- PA.f65: an empty/whitespace answer is refused before anything is
  recorded; an inference exhausted beyond its retry budget closes the turn
  with `PlayTurn.fault` recorded instead of leaving it `Running`.
- PA.f66: (a) the round budget closes the turn and narrates through the
  same door `end_turn` uses; (b) a kernel `Invariant` fault closes the turn
  with the fault recorded, set directly in `execute_round` at each of its
  three poisoning sites; (c) `dispatched_subjects` was already
  record-derived in call order on every path — pinned with a direct test,
  no code change needed; (d) `dispatch_command_id`/`dispatch_command_id_for_handle`
  replace `dispatch_slot`, deriving from the dispatch call's own slot and
  the subject rather than the subject's position in that call's own JSON
  array, so two subjects or two dispatch calls in one round can never
  collide.
- PA.f69: strengthens `an_act_for_an_undispatched_subject_is_refused` (S2)
  and `the_turn_closes_with_the_players_narration` to assert real outcomes;
  adds a Persona-exact-quote-accepted test (S1); pins
  `commit_authoring_run`'s record-before-submit ordering at the source
  level (S4, no runtime seam exists to interrupt one synchronous call);
  and adds a fresh-turn-after-close test proving no transcript crosses
  turns, checked against the actual seen inference request (S7 / invariant
  1). S6 was already covered by the existing restart test.

Dungeon bin: 63 -> 78 tests. Library: 620 -> 621 tests (the new
WorldPatch predicate's own unit test, previous commit). Every mutation in
the cut map's fix list was reverted/loosened against the final code and
confirmed to fail, then restored; see the session report for the full list.

Discrepancy not covered by this batch: `round_tools` did not append
HANDLE_SEPARATOR to the prefix it handed `actor_tools`, which would have
made every real (non-scripted) actor tool name undecodable against the
`<handle>__<kind>` convention this file's own instructions document. Fixed
alongside the actor-prefix cut as a necessary companion, since decode and
generation must share one prefix by construction.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 16:30:58 +02:00
Meta Vens
4fea8fdd36 Use UAX #29 word boundaries in SourceSpan::locate (PA.f78)
The hand-written is_alphanumeric neighbour rule in SourceSpan::locate got
several boundary cases wrong: it accepted "I can" inside "I can't go" (an
apostrophe is not alphanumeric, so it read as a boundary even though it
binds the contraction into one word), accepted "the cafe" ending right
before a combining acute mark, accepted "run" inside "run_fast", and
falsely refused every CJK quote because every ideograph counts as
alphanumeric and CJK prose carries no spaces.

Replace the rule with real UAX #29 word segmentation from the
unicode-segmentation crate: collect the boundary byte offsets
split_word_bound_indices() reports for the source, plus source.len(), and
accept an occurrence only when both its start and end land in that set.
Delete starts_on_word_boundary/ends_on_word_boundary; the crate is the
authority now, not a hand rule.

unicode-segmentation is pinned to 1.x in the workspace
[workspace.dependencies] table (Cargo.lock gains only that one package)
and added to ghostlight-persona-projection's own Cargo.toml.

Verified against UAX #29 with new tests: contractions (straight and curly
apostrophe) are refused, a quote ending before a combining mark is
refused, an underscore-joined word is refused, a leading-CJK two-character
quote is accepted, a quote with its own leading space is accepted, a
hyphen-bounded quote is accepted, and "Stop." in "Stop.Now" is refused
(the crate glues a MidNumLet full stop between two letters into one word,
so this is the crate's called shot, not a special case carved into the
rule).

Callers (Interpreter capture, Dungeon's span_is_exact) are unchanged; the
lib and Dungeon bin suites both still pass in full (618 lib tests with the
one known PA.f77 flake, 63 Dungeon bin tests).

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:54:20 +02:00
Meta Vens
6d6897445e Name a call and a subject's civic standing in the agent-facing refusal and table (PA.f73, PA.f74)
describe_refusal's generic patch-mismatch path dropped detail three ways:
UnresolvedDraft lost the handle that never resolved and the kind it was
expected to resolve to; a handle declared twice across two calls could only
ever be blamed on one of them, through the same "the call that declared
`handle`" wording a single declaration gets; and EmptyEvidence named no
call at all, because DecodedBatch never recorded which call contributed
which evidence entry. UnresolvedDraft and DuplicateHandle now get their own
rendering, and DecodedBatch carries evidence_calls alongside site_calls so
EmptyEvidence can name its call the same way a declaration or operation
site does.

table_view printed nothing about a subject's own authority grants, the
offices it holds or constitutes, or the forums whose standing covers it,
even though create_commitment's own checks and a restricted route's
requires all resolve through exactly that structure. It also printed a
commitment's kind as Rust Debug, occupant and known-by lists as labels with
no id to disambiguate a duplicate label, and re-sorted a subject's persona
values and memories out of their authored order. Each subject row now
prints its authority grants, offices held/granted (with incumbent), and
forums (with grievance kind); commitments print their kind in words;
occupant and known-by entries carry an id beside the label; and persona
values/memories keep their stored order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:44:45 +02:00
Meta Vens
dc43be5d8a Refuse a mismatched actor prefix and stop garbling roleless preconditions (PA.f71, PA.f72)
decode_actor_call fell back to the bare tool name whenever strip_prefix
failed, so a name actor_tools never emits under a given prefix (the bare
granted-entry name, unprefixed) still decoded. A non-empty prefix must now
be carried, or the call is refused.

precondition_roles dropped CanBroadcast's and CanReach's channel role
entirely, and describe_refusal_to_actor's roleless wording spliced "a
precondition with no role" into the "a precondition over role ..." template,
producing a garbled sentence for NoStanding, NoAudience, and an
out-of-range precondition index. precondition_roles now reads the channel
role through a shared audience_role helper, and the actor-facing roleless
case gets its own plain sentence instead of being spliced into the
role-bearing template.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:30:12 +02:00
Meta Vens
e85de6e68b Record play agent kernel fix batch 7b
1d50509, 7d59e41, 56020d4 and 275b0f2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:18:09 +02:00
Meta Vens
275b0f21e6 Require word boundaries at both ends of a located source span (PA.f67)
`SourceSpan::locate` in ghostlight-persona-projection was a raw substring
`find`: "I can" byte-exact matched inside "I cannot go" and was accepted as
the quoted span, even though it is not the word the model claims to be
quoting. "Speech is a verbatim span" only holds if the span's edges are word
edges.

`locate` now walks every occurrence of the quote (`match_indices`) and takes
the first one whose start and end each land on a word boundary: the edge of
the source, or a character that is not Unicode-alphanumeric
(`starts_on_word_boundary` / `ends_on_word_boundary`). A first occurrence
that fails either edge is skipped in favor of a later one.

The Interpreter (crates/ghostlight/src/controllers.rs) calls the same
function through `SourceRange`; `cargo test -p ghostlight --lib` still passes
at 603, so no existing test relied on a mid-word span.

Added five tests: a mid-word quote is refused, a whole-word quote is
accepted, a span equal to the whole source is accepted, a later
boundary-valid occurrence is found after an invalid first one (the first
occurrence fails only the start edge), and punctuation at a span's edges is
handled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:15:22 +02:00
Meta Vens
56020d4155 Pin the Persona checkpoint's request check and the forged-effect replay refusal (PA.f50, PA.f51)
PA.f50: nothing pinned `NarrativeCheckpoint::Persona`'s integrity check —
replacing its `prepared_matches_request` call with `true` failed no test.
Added a synchronous `persona_checkpoint` fixture plus a test that persists a
Persona checkpoint, tampers its `guidance` after `invocation` was already
prepared from the honest one, and checks both `integrity_is_valid` and the
real store's own resume gate (`CultCacheControllerWorkStore::open`, which
decodes and re-checks every row's integrity) refuse it.

PA.f51: `a_forged_mirror_acted_event_is_refused_by_verify_history` in
consumer.rs recomputed the forged commit's own digest but left the head
snapshot's `last_commit_digest` pointing at the original, honest digest. That
mismatch alone made `verify_history` refuse the forgery earlier, as "head
state does not equal replayed history", so the test passed even with the
effect-equality check in `apply_committed_command` (journal.rs) disabled.
Pointing `head.last_commit_digest` at the forged digest closes that
unrelated gap, leaving the effect-equality check as the only clause that can
still refuse it.

Doc fix: the comment on `SubjectRow::last_acted_at` (lib.rs) still described
the recency marker `projector_knowledge` stamps as `minted_at > last_acted_at`;
the code (controllers.rs) has read `entry.acquired_at > acted_at` since
PA.f44. Corrected to match.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 15:07:55 +02:00
Meta Vens
e929d130e4 Record the operator's review of the PA.f61 ruling
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:05:22 +02:00
Meta Vens
98451c9b69 Record Soul on play agent fix batch 7a: invariants 2 and 8 hold
P7.1 is falsified in a small way: a bare name still decodes (PA.f71).
Refusal wording, the table's missing authority, and test gaps are
PA.f72 to PA.f75, to fix in batch 7c. PA.f76 is recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:01:00 +02:00
Meta Vens
7d59e41869 Stamp Knowledge.acquired_at with the revision a commit actually resolves to (PA.f49)
Every `Knowledge.acquired_at` write computed `state.revision + 1` inline, which
is right for every commit except genesis: genesis applies at revision 0 and
resolves to revision 0 (`resulting_revision: 0`, `state.revision` never
advances past 0 for the creating commit), so a fact a genesis patch both
declared and granted knowledge of landed at `acquired_at: 1` — one revision
ahead of the state it was written into.

`apply_operation` and `apply_operations` now take the resolution revision as
an explicit `resolves_to` parameter instead of deriving it locally. Genesis
passes `state.revision` (0); every other caller passes
`state.revision + 1` through a small `resolution_revision` helper, computed
once per `apply_effect` branch instead of four times inline. Callers that
invoke `apply_operation`/`apply_operations` directly for a forged effect
(tests, and `action.rs`'s component-writer probe) now supply the same value
explicitly.

`verify_state_shape` in journal.rs gained a companion check: a knowledge
row's `acquired_at` must not exceed the world's own revision, so a forged or
corrupt row of this shape cannot pass replay silently.

Added three tests: a genesis-seeded knowledge row is acquired at revision 0,
a later commit's row is acquired at that commit's resolved revision, and the
shape check refuses a row forged ahead of the world's revision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:52:37 +02:00
Meta Vens
1d50509535 Share one predicate for a retired institution between the resolver and the kernel (PA.f48)
`candidate_effective_authority` in patch.rs, the resolver's twin of
`delegated_authority`, still counted a retired institution's delegated
authority after fix batch 5 taught the canonical reducer to skip it. The
resolver and the kernel held two different opinions about whether a dead
institution's office still lent authority: a grant directly to the reeve
after retiring the treasury was refused as OverlappingJurisdiction against a
lend that no longer existed, and a Relocate admitted by the resolver on the
strength of that same dead lend blew up at apply time as a
KernelError::Invariant instead of a normal resolver refusal.

`candidate_effective_authority` now takes the same `retired` set the
resolver already threads through patch resolution and skips a retired
institution's office exactly as `delegated_authority` does. Both callers
(`graph_overlaps` and the Relocate restricted-route check) pass it through.

Added two tests: a grant to the reeve after retiring the treasury is
admitted, and a Relocate through a restricted route that relies only on a
retired institution's delegated authority is refused by the resolver as an
ordinary RouteAccessRestricted mismatch, not a later Invariant.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:31:20 +02:00
Meta Vens
2391fd7a92 Record play agent library fix batch 7a
8de082a, 71a5c81, 1847974 and 2035fe8.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 13:14:19 +02:00
Meta Vens
2035fe8722 Widen the shared-decoder test to every tool and route two more copies through it (PA.f59)
`the_elaborator_and_the_table_share_one_decoder` proved agreement between
`apply_tool_call` and `decode_authoring_call` for exactly one tool,
`open_route`. Renamed to `the_elaborator_and_the_table_agree_over_every_tool`
and widened to every non-session `PATCH_TOOLS` entry's own generated
example, asserting the whole `WorldPatch` — declarations, operations, and
evidence — agrees, not only operations.

patch.rs's `decodes` carried its own copy of the tag-injection-then-
deserialize logic `decode_authoring_call` already owns; it now builds the
same arguments string and calls `decode_authoring_call` directly (`Session`
still short-circuits to `Ok(())`, matching `decode_authoring_call`'s own
refusal of that shape). `every_declaration` and `every_component_op`
similarly deserialized `arguments_for`'s output straight into `Declaration`/
`ComponentOp`, bypassing the table's decoder entirely; both now decode
through `decode_authoring_call` and pull the single declaration or operation
out of its `WorldPatch`.

Corrected `authoring_tools_admits_every_known_name`'s doc comment, which
described the unknown-name-refusal test above it rather than its own
assertion.

Added `describe_refusal_never_swaps_declaration_and_operation_site_wording`
(mutation X6): a declaration site (`EmptyHandle`'s `position`) and an
operation site (`SubjectNotAtOrigin`'s `operation`) render "declaration tool
call #N" and "operation tool call #N" respectively, and must never trade
words.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:12:57 +02:00
Meta Vens
1847974b62 Fix refusal text, split its actor-facing twin, and read a fault's disposition (PA.f56, PA.f57, PA.f60)
PA.f56: `describe_refusal` had six separate correctness bugs. `EmptyEvidence`'s
`position` is an index into the patch's evidence list, but the generic site
reader printed it as "declaration tool call #N" — the same text `EmptyHandle`'s
own, differently-meaning `position` produces; the two are now told apart before
the generic reader runs. `WrongKind` dropped the expected and actual kinds
entirely; it now renders both, in plain words. `CustodyNotConserved` printed no
site at all; it now names the resource whose ledger failed to balance.
`TargetKindMismatch` printed Rust `Debug` for its `RefKind`s; both `Mismatch`
and `ActionMismatch` render kinds in plain words now. The dead `RefName`
branch inside the old `describe_referent` — unreachable because `WrongKind`,
the only variant that carries a `RefName` referent, also carries a `site`
that the site-detection chain always matched first — is deleted; `WrongKind`'s
own arm resolves its `RefName` referent directly, through a new
`describe_ref_name`. `ActionRejected` read no precondition detail at all;
`describe_action_mismatch` now takes the failed entry and the actor's own
invocation and, when both are given, appends the label of what the actor
actually bound to the failed precondition's role. Declaration and operation
site indexes were raw indexes into the patch, not call indexes; when the
caller holds the `DecodedBatch` that produced the rejected patch,
`describe_refusal` now resolves them through its site map.

PA.f57, ruling: two functions. `describe_refusal` is for the agent, with full
detail (it gained `entry`, `invocation`, and `batch` parameters for the
PA.f56 fixes above). `describe_refusal_to_actor` returns only what the
actor's own attempt reveals: for `ActionRejected`, the failed precondition's
role names — bare, from the entry's own precondition list, never resolved to
a label, an id, or a fact's statement, because the actor's own call already
supplied whatever it bound there and nothing else is handed back. Every
other `KernelError` — `MissingApprovals`, a store or journal error, anything
else — returns one fixed "refused" line that names nothing.

PA.f60: Dungeon had no way to read an `InferenceFault`'s disposition —
`requires_recovery` and `integrity_was_violated` are both `pub(super)`.
`InferenceFaultDisposition` is now `pub`, and `InferenceFault::disposition()`
is a public, read-only accessor of the existing three-way value; no
constructor changed and no new one was added.

Dungeon's `play.rs` has one call site of `describe_refusal`, for an actor
call's own refusal; per the rule not to redesign Dungeon's dispatch in this
cut, it now passes `None` for `entry`, `invocation`, and `batch`, keeping
its current behaviour exactly (full detail, not yet the actor-narrow
function) until Cut 8b wires the new parameters and switches this call to
`describe_refusal_to_actor` properly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 13:05:04 +02:00
Meta Vens
71a5c81240 Add the batch decoder and fix the actor vocabulary (PA.f54, PA.f55, PA.f58)
PA.f54, ruling: in a round, each maximal run of consecutive authoring
calls is one patch, committed atomically under one command id, with
draft handles shared inside the run. Adds `decode_authoring_calls`,
which decodes a whole run into one shared `WorldPatch` plus a
`site_calls` map from each declaration/operation site back to the call
index that produced it, so a later call's reference to an earlier
call's draft handle resolves within the run and a decode error names
its own call index. `decode_authoring_call` stays as the one-call entry
point with its own unprefixed error text; both now read the same
`decode_one_authoring_call`, so there is one decoder rather than two
copies.

PA.f55: `actor_tools` offered `record_need` and `finish_without_proposal`
through `catalog_tools`, but those end a turn — the controller lane's own
vocabulary — and `decode_actor_call` has no arm for either, so the play
agent could be handed a tool call it would always refuse. Splits
`catalog_tools` into `affordance_tools` (one tool per granted entry, no
turn-enders) plus the two pushes `catalog_tools` still makes for its own
callers; `actor_tools` now reads `affordance_tools`. `decode_actor_call`
also gains a `prefix` parameter and strips it from the incoming name
before matching, so a name `actor_tools` emits (`{prefix}{kind}`) decodes
as emitted through the one decoder rather than a caller stripping the
prefix itself first.

PA.f58: `decode_actor_call` silently dropped a non-string `display`
(`and_then(Value::as_str)` swallows the wrong type) and the shared
`decode_catalog_call` silently dropped a `text` field on an entry that
carries no speech. Both are now refused. Also widens the
`table_view_never_reaches_persona_or_projector_code` pin from grepping
only `controllers.rs` to scanning every library source file except
`table.rs` for a `table_view(` call.

Dungeon's `play.rs` is adjusted only for the new `decode_actor_call`
signature: its one dispatch call site and one test call site pass `""`
for the new `prefix` parameter, since Dungeon already strips the
`<handle>__` prefix itself before calling. Behaviour is unchanged; Cut 8b
is the one that wires Dungeon's own dispatch through the table's prefix
handling properly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:42:43 +02:00
Meta Vens
8de082a36c Widen table_view to the whole PLAY_TOOLS vocabulary (PA.f53, PA.f47)
table_view omitted every id `PLAY_TOOLS` takes outside the plain
subject/place/route/resource/affordance rows: no commitment keys
(discharge_commitment), no pressure rows (advance_pressure,
reduce_pressure), no channels (communicate's audience), no persona
material (set_persona_material), and no subject dependencies (bind,
release). Facts were gathered only from subjects' own knowledge rows, so
a fact nobody held was invisible to the one reader that is supposed to
see everything.

WorldSnapshot gains two omniscient, crate-private fields built straight
from world state in `snapshot()`: `channels` (every declared channel,
independent of who controls it) and `facts` (every fact the world holds,
independent of who perceives it). SubjectSnapshot gains `dependencies`,
lowered from the private `components.dependencies` the same way
`commitments`, `pressures`, and `material` already are. `table_view` now
prints all of it, plus route access, subject kind, and controller mode in
plain words instead of Rust `Debug`.

`FactStandingView` gains a `Ruled` variant. `snapshot()`'s per-subject
knowledge projection still collapses `Ruled` to `Canonical` (a subject's
own perception cannot tell a ruling from an ordinarily canonical fact),
and `controllers.rs`'s `typed_knowledge` keeps that promise explicit with
its own arm. The new omniscient `WorldSnapshot::facts` does not collapse
it, so the play table's own view prints `ruled` for what it is.

Replaces `table_view_prints_every_id_the_tools_take` with a fixture
holding one of every kind PA.f53 named — a claimed fact, a ruled fact
nobody knows, a channel, a commitment, and a pressure row — and takes a
real example per `PLAY_TOOLS` entry, composite fields included, rather
than the placeholder `field_example` composites the old loop silently
accepted. Adds direct tests for the named mutations: dropping the
granted-affordance filter (X1), printing a claimed fact as canonical
(X3), dropping the known-by list (X4), and printing `Ruled` as canonical
in the table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 12:21:35 +02:00
Meta Vens
d63caccc01 State PA.f61's persona-material rule in the play agent target
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:04:35 +02:00
Meta Vens
cddbef48d8 Record Soul on play agent Cut 8a: P8.4 falsified
Answers and continues arrive with new request keys and discard the open
turn (PA.f62). Persona prose never reaches the agent (PA.f64). An empty
answer wedges the turn (PA.f65). communicate and set_persona_material
break invariants 3, 4 and 2 in substance (PA.f61): communicate leaves
PLAY_TOOLS, and persona material is limited to new subjects. Self's
rulings under the standing go are recorded in each finding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 12:03:57 +02:00
Meta Vens
ec4ac5ee88 Record play agent Cut 8a as landed at 6b69270
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:44:34 +02:00
Meta Vens
6b69270a34 Add the play table's store and loop (Cut 8a)
PlayTurnStore (one row, over service/play-turn-v1.cc) and PlayTable run
a role-playing turn over Dungeon's PLAY_TOOLS subset of the library's
authoring catalog plus dispatch/advance_time/ask_player/end_turn. The
agent's conversation re-derives each round from opening_prompt, the
recorded InferenceOutputs, and each call's persisted body/result, so
nothing but world state crosses a turn boundary. Authoring calls commit
in maximal per-round runs through one commit_authoring_run seam (fix
batch 7a's batching rule); each call still submits as its own one-item
patch until the library ships a batch decoder. Actor calls quote-check
against the player's own texts or a dispatched Persona's recorded turn
before submitting through submit_principal or ControllerPort, and a
kernel Invariant from any commit poisons the table until restart.

Wiring into AppState, GHOSTLIGHT_PLAY_MODEL, world.play routing, and
the Eve schema is Cut 8b.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 11:43:18 +02:00
Meta Vens
4d071abc48 Record Soul on play agent Cut 7: P7.1 falsified, P7.2 unproven
The table view omits ids the play tools need (PA.f53), and decoded
calls cannot join one patch (PA.f54). Actor tools emit tools the decoder
refuses (PA.f55). Refusal text is wrong and serves both the agent and the
player (PA.f56, PA.f57). Self's rulings: one patch per run of authoring
calls, and an actor-facing refusal form.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:28:55 +02:00
Meta Vens
4a6adb9181 Record Soul on play agent fix batches 5 and 6
P4.1 is falsified: the resolver still counts a retired institution's
authority, and the kernel does not (PA.f48). The genesis acquired_at stamp
is off by one (PA.f49). The checkpoint check and the PA.f40 test are not
properly pinned (PA.f50, PA.f51). These are to fix; PA.f52 is recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 11:04:02 +02:00
Meta Vens
9187facb3d Record play agent Cut 7 as landed at 7d40393
PA.f47: table_view cannot print ruled, because the snapshot collapses
Ruled to Canonical for every reader. The omniscient view gains Ruled;
subject-facing renderings keep it canonical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 10:47:25 +02:00
Meta Vens
7d40393bcd Add the table module: one consumer-neutral vocabulary for the play agent (Cut 7)
Dungeon's play agent needs to build tools, decode calls, and read inference
output without ever touching a pub(crate) kernel type. crates/ghostlight/src/table.rs
is now the library's one owner of that vocabulary:

- authoring_tools/decode_authoring_call wrap PATCH_TOOLS. apply_tool_call in
  elaboration.rs now calls decode_authoring_call for its Declare/Operate arms
  instead of carrying a second copy of the same decode.
- actor_tools/decode_actor_call wrap catalog_tools/decode_catalog_call
  (decode_catalog_call widened controllers.rs-crate-private to pub(super) so
  the table module can reuse it). decode_actor_call resolves the subject's own
  currently issued opportunity from the snapshot, never a value a caller
  supplies (PA.f41's rule, applied here).
- describe_refusal renders ActionRejected by precondition/slot/role and
  PatchRejected by the offending tool call's site; every other KernelError
  falls back to Display.
- InferenceRequest::play and InferenceOutput::events/receipt_digest are the
  two inference seams Dungeon needs to run and read a Play-purpose inference.
- table_view is the play agent's own richer renderer (ids, occupants,
  holdings, granted names, retired flag, facts with standing and knowers,
  clock, brief) and does not share text with render_world_structure, which
  moved here unchanged so the seed prompt's bytes did not move with it.

Known gap: FactStanding::Ruled collapses to FactStandingView::Canonical at
snapshot construction (lib.rs), so table_view cannot yet distinguish a ruled
fact from an ordinarily canonical one. Widening that is a lib.rs change
outside this cut.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 10:42:44 +02:00
Meta Vens
d2de5766e8 Carry PA.f13, PA.f14 and the command-id rule into play agent Cut 8
There is no world quarantine at the play table. Inference faults become
tool results for the agent, and only a kernel invariant stops the table.
Cut 8 is split into 8a (the loop) and 8b (the wiring).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:50:15 +02:00
Meta Vens
dd398fbd2a Record play agent fix batch 6 (14d85da, 3b1f412, 55f8bc6)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 09:48:56 +02:00
Meta Vens
55f8bc679d Cover the assembled Projector request and three untested PersonaLane paths (PA.f42, PA.f43)
Tests only.

PA.f42: no prior test inspected the Projector request build_projector_invocation
assembles — the existing leak tests cover the Persona request built from its
prose, not the Projector request both turn and narrate build from a
SelectedDecision. the_projector_request_carries_no_structured_state_through_turn_or_narrate
asserts no subject id, controller id, world id, scope digest, state digest or
revision reaches it through either path. Kills the mutation that put the
full typed view into the Projector's guidance instead of the world's brief.

PA.f43, three untested PersonaLane paths, one test each:
- turn_refuses_an_operational_agent pins that turn refuses OperationalAgent
  before any inference runs. Kills the mutation that let turn accept it
  alongside NarrativePersona.
- turn_refuses_a_stale_scope_digest pins that a scope digest which moved
  since the opportunity was issued is refused. Kills the mutation that
  swapped PersonaLane::select's digest requirement for select_fresh's
  looser scope-only match.
- narrate_succeeds_for_a_human_subject_with_only_its_own_slice pins the
  success path for a real Human subject: its Projector request carries its
  own heard line and never another subject's knowledge or an absent
  subject's label.

mixed_controller_world adds a Human, a NarrativePersona and an
OperationalAgent subject to one fixture — reusing crate::tests's
human-approval draft flow lowered onto WorldMailbox — since no existing
fixture carried all three controller modes PA.f43 needs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:47:55 +02:00
Meta Vens
3b1f412b92 Stamp every knowledge row with its own acquisition revision (PA.f44)
The recency marker was `minted_at > last_acted_at`, and minted_at is set only
by a speech or a display event's own fan-out. A row AcquireKnowledge lands, a
row Witness lands, and a row declaration-time seeding lands all carry no
minted_at, so `new` computed with `>` a `None` was always false for them: a
fact landed by a patch, a seed, or a ruling could never surface to a Persona
as new, however long after the subject's last act it arrived.

Knowledge now carries acquired_at: u64, the revision this write's own commit
resolves to (matching how DecisionEvent.revision and the minted_at scan built
from it are computed: one past the pre-increment state.revision apply runs
against). Every writer of the knowledge map sets it: AcquireKnowledge,
Communicate's fan-out, Display's fan-out, and Witness. The marker becomes
`acquired_at > last_acted_at`, with a subject that never acted (last_acted_at
is None) seeing every row it holds as new outright, matching the ruling
exactly rather than leaning on Option ordering.

minted_at keeps its own meaning — which act minted a spoken or displayed claim
— and stays on KnowledgeSnapshot and on the wire; no schema bump, since no
consumer.v6 store exists yet. AcquireKnowledge's own no-op refusal now
compares confidence and source alone, not the whole row, so a later
acquired_at cannot make an identical re-assertion stop refusing as
"knowledge operation changes nothing".

Tests: a row AcquireKnowledge lands after the subject's last act is new; one
that lands before it is not; the acquisition revision survives a journal
reopen; and the Cut 0 stimulus test's third row — seeded with no minted_at,
previously "new": false — now reads "new": true, because its subject never
acted and PA.f44 says every row such a subject holds is new.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:33:13 +02:00
Meta Vens
14d85dac59 Refuse a forged opportunity in the Persona lane, and cut its duplication (PA.f41, PA.f46)
select_one matches subject, mode, controller, scope and digest, then
substitutes the caller's own copy of the opportunity into its result: correct
for ControllerRunner, which persists and resubmits one run's bound
opportunity, wrong for PersonaLane, which persists nothing. A forged
opportunity (new world_id, revision 999999, emptied affordance_ids) passed
select_scope's scope-and-digest match untouched and landed straight in the
PersonaTurnBinding turn records.

PersonaLane::select now calls select_scope directly, requires the caller's
opportunity to equal the snapshot's own issued copy exactly, and returns that
kernel copy. turn builds its binding from selected.opportunity, never from
the caller's parameter.

Cut the duplication PA.f41 touched alongside it: prepare_inference,
run_inference and snapshot_for_selection are now the one body
ControllerRunner and PersonaLane both call; record_persona_turn is the one
place a PersonaTurnBinding is built from an opportunity and a receipt pair,
used by both run_persona and PersonaLane::turn; and the NarrativeCheckpoint
Persona integrity check now proves its persisted invocation against
persona_inference_request, the same unprepared builder build_persona_invocation
prepares from, instead of rebuilding the prompt a second time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:18:46 +02:00
Meta Vens
c9aa340db3 Record play agent fix batch 5 (bc1852d, 33fc9c6, 662a51f)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:35:47 +02:00
Meta Vens
662a51f647 Close four retirement test gaps and route the mirror-forgery test through replay (PA.f39-f40)
PA.f39: each surviving mutation gets a test that fails under it.
- M2: `AcquireKnowledge` now has a test with an `Evidenced` source, so a
  narrowing of the retired check to `source != Evidenced` is caught.
- M6: a mirror declared into a `Reach::Subjects` channel now has a test
  proving it stays in the audience, so `is_retired` matching
  `ExternallyControlled` is caught.
- M9: the Draft-only approval shape clause in `verify_state_shape` is now
  pinned directly with a forged `draft_approvals` entry outside
  `required_approvers`, rather than only exercised indirectly.
- Soul's probe S2: a placeless subject reachable only through a
  `Reach::Subjects` channel is now proven reachable while live and refused
  once retired, exercising `audience()`'s channel side, which PA.f33's
  Colocated-only `CanReach` test never reached.

`WorldJournal` gains a `#[cfg(test)]` `commits_for_test` accessor and
`verify_history` becomes `pub(super)` (matching `verify_state_shape`'s
existing visibility) so a Soul-style forgery test outside `journal.rs` can
route its falsification through the function that actually judges committed
history.

PA.f40: the "mirror acted" test at consumer.rs claimed the shape check
refused it on a controller-id mismatch. That clause was deleted in PA.f31,
and the test passed only by accident through the unrelated speech-claim
clause. Renamed and routed through `verify_history`: it forges the committed
effect's event scope onto the mirror, recomputes the commit digest, and
proves replaying the original command no longer reproduces that effect.

All four PA.f39 mutations verified as killed by direct mutation testing
against the committed code, then restored by reverse edit. Lib census: 549
before batch 5, 555 after bc1852d/33fc9c6 (PA.f36-f38), 559 after this commit
(PA.f39 adds 4; PA.f40 renames one test in place).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 08:34:52 +02:00
Meta Vens
089538ef42 Record Soul on play agent Cut 6: invariant 2 holds
A forged opportunity is recorded in a turn's binding (PA.f41). The
assembled Projector request and three lane paths are untested (PA.f42,
PA.f43). Rows with no minted_at never read as new, so the marker moves to
an acquired_at revision (PA.f44). The lane duplicates runner code
(PA.f46). The world brief is named in invariant 2 as world-public
(PA.f45).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 08:07:16 +02:00
Meta Vens
33fc9c671d Cover the dependency loop's retired-subject freeze (PA.f37)
The first PA.f37 freeze test only retires the farmer, who holds a routine and
an obligation but no dependency, so it never exercised `derive_motion`'s
dependency-loop retired check. Mutation testing against commit bc1852d
confirmed the gap: disabling that loop's `is_retired` skip passed the full
suite. This adds the other half — retiring the reeve, who holds the goal and
the Bind dependency, while the farmer stays live — which kills that mutation.

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 07:38:06 +02:00
Meta Vens
d392c088c3 Record Soul on play agent fix batches 3 and 4: all promises hold
PA.f36 to PA.f40 are to fix in batch 5. Self's rulings under the standing
go: retired subjects' commitments stop moving, a retired promisor is
refused, and a retired institution lends no authority.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:56:20 +02:00
Meta Vens
b849f4d566 Record Soul verdicts on play agent Cuts 4-5: P4.1 and P4.2 falsified
The journal's shape check judges past events against present assignments,
so a subject that acted cannot be retired and a used verb cannot be
revoked (PA.f31). The owner can retire the human in Draft to skip
approval (PA.f32). Retired subjects still hear and speak through subject
channels (PA.f33) and can gain or forget knowledge or hold office
(PA.f34). Self's design rulings under the standing go are recorded in each
finding.

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 05:18:49 +02:00
Meta Vens
c90c634c60 Record play agent Cuts 4 and 5 as landed (3725ba8, 732b1d3)
Three loosening mutations survived and are recorded as PA.f30, to fix.

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 04:46:43 +02:00
Meta Vens
7791bad845 Record Soul verdicts on play agent Cut 3
No promise was falsified. Genesis bypasses require_ruler (PA.f26), three
mutations survive the author-rule tests (PA.f27), and two promised tests
are missing (PA.f29); all three are to fix. PlayPort's convention sealing
(PA.f28) is defaulted and recorded under the standing go.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:27:31 +02:00
Meta Vens
b0cfd1e9bc Record Soul's recheck of the Cut 2 fixes: P2.2 now holds
PA.f23 (null response id quarantines the lane) and PA.f24 (no IPv6
loopback test) are to fix; PA.f25 is recorded.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 03:11:37 +02:00
Meta Vens
86a3a2f6b4 Add the Cut 2 verdict table to the play agent cut map
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:56 +02:00
Meta Vens
404db7e5be Record Soul verdicts on play agent Cut 2: P2.2 falsified
The port's HTTP client honours proxy environment variables and follows
redirects. Loopback-only requests left the machine, and a proxy URL's
userinfo was sent as proxy-authorization. PA.f16 to PA.f22 are triaged
to fix in one batch after Cut 3 lands; PA.f21 is recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:33:39 +02:00
Meta Vens
761121536a Correct the plan's claim that the SDK port never ran live
Road runs 8 to 12 went through the sidecar. The smoke that drove them
was deleted in the play agent's Cut 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 02:28:53 +02:00
Meta Vens
d8dce9db5e Fix docs stale after Cut 1's teardown (PA.f15)
Cut 1 (d69e9d4) deleted the tick driver and the Active elaboration sweep, and
the ignored live smoke that exercised them. These passages still described
that machine as live:

- notes/local-live-smoke.md was a runbook for the deleted smoke and its
  command now runs zero tests. Trimmed to what is still true: connector and
  Claude SDK sidecar bring-up, which the seed lane still depends on. Fixed
  the references to its deleted sections in notes/fresh-workspace-handoff.md.
- docs/architecture/ghostlight-dungeon-mvp.md's Verification contract named
  the live smoke as the road's one proof point; it now says none exists until
  the play agent's own gate.
- notes/ghostlight-implementation-plan.md's Interruption section named
  ControllerHttpResult::Interrupted and run_cover_tick as live; both are
  deleted, and reporting an interruption to the play agent is Cut 8's.
- docs/architecture/ghostlight-stock-lenses.md gets one added sentence: the
  concurrent-elaboration claim holds as a library capability, and Dungeon
  runs none of it since Cut 1. The verdict is unchanged.
- notes/fresh-workspace-handoff.md's concurrent-elaboration and
  tick-driver lines now say what Dungeon actually runs today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:27:59 +02:00
Meta Vens
4ca0ec9173 Pin P1.1/P1.2 with a source scan and cover submit_receipt's two kinds (PA.f11, PA.f12)
PA.f11: Cut 1 deleted the tick driver and the Active elaborator, but nothing
pinned their absence. Soul re-added a submit_clock loop and a spawned sweep
and both passed 46/46. Add
soul_no_forbidden_writer_name_appears_in_dungeons_own_source, on the
soul_no_credential_name_appears_in_the_runtimes_own_source precedent, banning
submit_clock(, .elaborator(, run_narrative(, run_operational(, run_cell( and
ElaborationRunner from every non-test .rs file under
crates/ghostlight-dungeon/src, not just runtime.rs.

PA.f12: the dropped no_proposal_projection_carries_the_canonical_world_commit
was the only test of submit_receipt's applied/already_applied kinds and
revision. Add
a_principal_command_resubmitted_under_its_own_key_returns_applied_then_already_applied,
which submits one world.advance_time command twice under the same
idempotency key through the real HTTP path and asserts the kinds and the
matching revision.

Both mutations were run and restored by hand against the committed code: a
submit_clock( comment and a .elaborator() comment each failed the new source
scan, and swapping submit_receipt's two "kind" strings failed the new
idempotency test. Dungeon bin census: 46 -> 48.

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 02:12:10 +02:00
Meta Vens
d15ff86155 Record Soul verdicts on play agent Cut 1 and triage its findings
All four promises hold. P1.1 and P1.2 are unguarded by any test (PA.f11),
and the only test of the receipt kinds was dropped (PA.f12). Both are to
fix, along with the stale docs (PA.f15). PA.f13 and PA.f14 go to Cut 8.
The negative grep now allows the one hit at M1.1's test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:59:03 +02:00
Meta Vens
273242ba3f Record play agent Cut 1 as landed at d69e9d4
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:49:16 +02:00
Meta Vens
d69e9d43cf Delete Dungeon's autonomous drivers and owner controls (play agent Cut 1)
Nothing in Dungeon now runs a Persona, an operational agent or an
elaborator on its own. Deleted from runtime.rs: the cover tick driver
(drive_cover_tick, drive_one_tick, run_cover_tick, submit_clock_tick,
CoverSummary, InterruptedCell, cover_panel, the clock-tick constants,
configured_cover_budget, configured_tick_interval), the Active
elaboration sweep (elaborate_world, run_elaboration_sweep,
ELABORATION_SWEEP_INTERVAL, configured_elaboration_ceiling,
AppState.elaboration_permits), and the owner's "Let X act" route
(ControllerActPayload, dispatch_controller, admit_controller_command,
ControllerHttpResult and its builders, controller_error_disposition).
Deleted from eve.rs: CoverPanel, CoverPanelTick, cover_card, the owner's
controller buttons and descriptor, and world.controller.act from
operation_schema.

AppState.controller_quarantined is deleted as well: its only writers were
dispatch_controller and run_cover_tick, so it could never be set again.

controller_permits and configured_controller_concurrency stay for Cut 8;
until then only runtime_readiness reads the pool. ControllerRunner stays
for seeding and custody_probe; world.speak, the story card and
world.advance_time stay until Cut 9.

Tests dropped with the behaviour they pinned: the nine the cut map lists,
plus no_proposal_projection_carries_the_canonical_world_commit, which
tested the deleted controller_submission. Their fixtures go with them;
the world fixture collapses to the Draft-only path its remaining callers
use. removed_legacy_operation_is_denied_before_dispatch now also names
world.controller.act.

Docs: the MVP doc, the ontology and the handoff drop the cover,
tick-interval and elaboration-ceiling variables and the sentences that
described the deleted drivers.

Library untouched. Built and tested on Windows only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:45:57 +02:00
Meta Vens
29f9ab3402 Respecify Cut 3 for Ruled facts and minted quantity (PA-Q15)
Cut 3 now adds a unit FactStanding::Ruled and a new ComponentOp::Mint
rather than reshaping Admit. It adds one post-resolution author check,
require_ruler, which refuses either one unless the caller is
System(Play). The evidence gate is unchanged for every author, Play
included. The work-row bump to v17 moves into Cut 3.

PA-Q10 and Q12 are folded in as ruled designs. PA-Q7's list becomes 25
tools, with mint in and admit out. table_view prints ids, holdings, and
each fact with its standing and who knows it.

New findings: PA.f9 (the shared catalog shows mint and ruled to the seed
and elaborator lanes; watched at the gate), and PA.f10 (minted quantity's
only provenance is the commit caller; a question for the outbound pass).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:25:56 +02:00
Meta Vens
37be2cef1a Record the play agent rulings: six forks, Ruled facts, minted loot
The operator accepted the map's recommendation on PA-Q1, Q3, Q7, Q10, Q12
and Q13 on 2026-09-22. The operator also ruled PA-Q15: the play authority's
facts stand as Ruled, and it mints quantity without evidence ("if the agent
can invent people, it can invent loot"). Only Play gains either;
elaborators, consumers and the seed keep the evidence gate.

The target now lists the library's fourth piece (the table vocabulary and
inference seams) and the affordance grants. Invariant 5 gains Ruled facts
and minting, and invariant 8 shows the kernel's refusal of the player's own
act. Cut 3 still specifies the superseded PlayCarriesEvidence refusal and
is being respecified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:20:52 +02:00
Meta Vens
cf81cbe034 Map the play agent cut (plan step 16)
Imagination's cut map for the play agent target. Probes compiled a scratch
consumer against the library, and the agent's tool catalog was measured.
The map answers target questions Q1-Q14. Q2, Q5, Q6, Q8 and Q9 are
defaulted, Q4 and Q14 are findings, and Q11 is ruled by the operator.
Q1, Q3, Q7, Q10, Q12 and Q13 await operator rulings.

Ten cuts follow, subtraction in Dungeon first, then the local port, the
kernel pieces (play authority, retirement, affordance grants), the Persona
lane, the table vocabulary, the play table, and the Eve surface. Findings
PA.f1-PA.f8; PA.f1 was already resolved at 5ff1d54.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 01:12:28 +02:00
Meta Vens
5ff1d54efa Point the state surfaces at the play agent gate
The map, handoff, plan and Session Zero doc now name plan step 16, the play
agent, as the playtest gate. L2-L4, D1-D3 and the L1 follow-ups are
recorded as deferred by ruling, not dropped.

A source check corrected the map. Inference routes per lane by model name
to the SDK sidecar or CodexConnector, and no local transport exists. The
sidecar is a private msgpack pipe behind InferencePort, not the CultNet
connector API the map claimed every backend shares.

The handoff's session_zero.begin item is retired: the name is refused, and
only tests reference it. One evidence record covers the pivot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:53:24 +02:00
Meta Vens
4e61f586bf Rule play agent Q11: the Projector renders the player's view
The operator ruled on 2026-09-22 that the player's perception goes through
the same Projector a Persona gets, which makes the Projector the player's
narration. The play agent writes none. The turn description and invariant 8
now say so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:53:24 +02:00
Meta Vens
cb0b604ae3 Write the play agent target (plan step 16)
The operator redirected the playtest gate on 2026-09-22. Ghostlight's
controller machinery suits offline simulation, not interactive role-play.
One operational agent now runs the table; the Projector and Persona passes
stay exactly as they are.

The target records the rulings (the play authority, actor acts through the
actor's affordances, deferral of L2-L4, D1-D3 and the L1 follow-ups), eight
invariants, the redefined playtest gate, and eleven open questions for the
cut map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 00:49:02 +02:00
Meta Vens
bd25d0fffe Sync the last surfaces after L1
The closing memory pass found four stale spots:
- The ontology still said the schemas are consumer.v3, contradicting
  consumer.v5 in the same paragraph. The sentence is gone.
- Evidence record 21 said each world carries a typed detail rule, which
  is L3 and not built. It now says a world will carry one.
- Evidence record 20 pointed at the design section. It now points at the
  landed L1 target.
- The system map's "Current acceptance body" describes the legacy release
  Yggdrasil still serves. It is now headed as pre-rebuild, so its titled
  worker text no longer reads as live.

Evidence is rewritten through compact-evidence, which archives the old
text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:10:02 +02:00
Meta Vens
576c8ac667 Close L1: postmortem, ledger, target and docs reconciled
The stock lenses pass is closed.
- The ghostlight library ships eight lenses.
- Worlds carry weights as world data, and the owner can replace them.
- Each elaborator session draws its lens replayably and records the lens and
  its instruction text.
- Sessions run concurrently under their own permit ceiling.
- In-flight work is found again by reading the store.
All eight target invariants hold.

The cut map records the second Cut 4 fix batch, Soul's final pass, Cut 5's
verdicts and the ledger's actuals. The actuals are +4,907 / −266 in crates
against about +1,300 estimated, the overrun overwhelmingly tests that caught
real defects.

Recorded as follow-ups: an Uncovered case the tests do not pin (L1.f15), and
whether consumers may supply their own lens sets (L1.f16, the operator's).

The target is rewritten as reconciled. The ontology loop no longer claims
seed requests or Vault evidence for the Active sweep, the handoff says
lens weights, and map.yaml names L2 as next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 20:08:25 +02:00
Meta Vens
b15ade4d08 Name world_create.v4 beside the seed producer in the MVP doc (L1 Cut 5)
The verification contract named creation only as step 8's `world.create` v2.
It now states the current payload, `world_create.v4`, with its required
fields including lens weights, and that Dungeon's create surface offers
uniform weights as its editable default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:56:30 +02:00
Meta Vens
04fd00416c Mark L1 implemented in the plan, handoff and map (L1 Cut 5)
Plan step 15 records L1 as landed at 835ea4d..872ba35 with its cut map, and
its heading names L2 next. Step 6 calls the elaborator's flavor a lens.

The handoff and state/map.yaml name the live schemas, consumer.v5 and
controller_work.v16, and world_create.v4 in the handoff; both say L1 is
implemented and what it gave the machine, and the handoff names L2 next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:56:20 +02:00
Meta Vens
39b2af4237 Describe the landed lenses and concurrent sweep in the ontology (L1 Cut 5)
"Elaborators" states the per-sweep claim set, the caller-owned ceiling and
Dungeon's two pools, and its loop takes a demand entry, resumes an in-flight
session answering it, and otherwise draws and records a lens.

"Elaboration lenses and detail rules" says which bullets L1 implemented and
which L2 to L4 still owe. The Lenses bullet describes the live mechanism:
weights required at creation, replaced by the owner through SetLensWeights in
any phase, a draw from world id and command id, lens and instruction text on
the checkpoint, LensWeightsNeverDraw and NoCanonicalChange refusals. The stock
set is closed. The cut list drops the sequential per-jurisdiction sweep.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:43:51 +02:00
Meta Vens
01312dd45d Record L1-Q10, the panic ruling, and the Cut 4 fix batch
The operator ruled L1-Q10 option A: the in-flight listing excludes rows
ready to submit, and the crash window goes under L1.f14. The operator
also agreed to the panic handling. A session-task panic becomes an error
returned after its siblings finish, and the driver keeps running.
Quarantine stays with L1.f1.

The fix batch landed at 6f35229..0764842. Soul's second pass confirmed the
new permit guard releases exactly once on every exit path, including
cancellation, and that a panicked session is adopted cleanly. It found the
adoption rule reachable through the sweep only by ready-to-submit boundary
rows.

Findings folded into the Cut 5 pass: pin that a retryable error does not
stop the sweep, pin that Uncovered work is swept alongside a deficit, and
delete a proven-dead digest comparison. Recorded for L1.f1: a panic during
a checkpoint write would wedge the lane with only debug-level logging.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:19:34 +02:00
Meta Vens
4b5013050f Prove pool independence for player controller turns (L1 Cut 4, c4.s1.f2)
a_speak_turn_and_a_sweep_draw_only_from_their_own_pools reads both pools
from inside the provider boundary, with the call held there by a gated
port. A world.controller.act turn through the HTTP route holds one
simulation permit and no elaboration permit, and still reaches the
provider with every elaboration permit held. A sweep holds no simulation
permit and at least one elaboration permit, and still reaches the
provider with every simulation permit held. The readings, not timing,
carry the proof; a timeout only turns a lane blocked on a pool into a
failure instead of a hang.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 19:06:52 +02:00
Meta Vens
21edecaff9 Record L1 Cut 4's landing, verdicts and findings
Cut 4 landed at 8538fab..3e35b3d. Soul's first pass confirmed:
- distinct claims and the permit ceiling;
- deficit adoption, with no adoption across jurisdiction, world or a
  NoPatch row;
- a listing read once per sweep;
- claims taken before any await, and sweeps that cannot overlap.

Going to a fix batch:
- Cut 3's adoption rule now rests on one test;
- speak-turn pool independence is untested;
- the sweep's Active-only gate is unpinned;
- the Uncovered jurisdiction is never swept in a test;
- a panic in one session task silently kills the elaboration driver
  until restart. That fix makes a task panic an error returned after its
  siblings finish, and leaves quarantine handling to L1.f1.

L1-Q10 asks the operator whether the in-flight listing excludes rows
ready to submit. Soul measured both sides: including them stalls every
committed deficit forever; excluding them costs one repeated round and one
orphan in a crash window. L1.f14 records that stored rows are never
retired.

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

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

This commit builds. Library 458 + 1 ignored.

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:44:04 +02:00
Meta Vens
6150bd7667 Split Dungeon's controller pool into simulation and elaboration (L1 Cut 4)
Ruling L1-Q4: neither lane may starve the other, and the elaboration pool is
a ceiling rather than a budget.

- AppState gains elaboration_permits beside controller_permits.
  controller_permits stays the simulation budget, which speak turns and
  cover cells draw from.
- configured_elaboration_ceiling() reads GHOSTLIGHT_ELABORATION_MAX_CONCURRENT
  at open, in the same shape as the simulation pool: default 2, minimum 1.
- The speak handler's comment now names both pools.
- The test fixture opens the second pool with TEST_ELABORATION_CEILING = 2.
- docs/architecture/ghostlight-dungeon-mvp.md names both variables and what
  each pool means.

This commit builds with one warning more than its parent:
elaboration_permits is never read. The next commit passes the pool to the
elaboration sweep, and the warning goes away there.

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 17:09:00 +02:00
Meta Vens
d9b71c49fd Record the Cut 3 test-only fix batch
The batch landed at f63dfd1, b3b5053 and 2c3b513 with no production
change.

- The flaky illegal-transition test failed 3 of 40 runs before and passed
  120 of 120 after.
- The call-site draw is now pinned to the session's own command id across
  eight multi-lens worlds.
- A resumed session is proven to keep its stored instruction text, which
  was the operator's purpose for Q7.

Both mutations that previously survived, S3 and S5, now fail. Cut 4's Soul
pass reruns them in place of a separate pass on a test-only batch.

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

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

Test-only; no production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:52:26 +02:00
Meta Vens
b3b5053dab Pin that a fresh session draws its lens from its own command id (L1 c3.s1.f2)
Every lens-observing controller test used a single-lens world, so the
call-site draw was forced and a draw from an unrelated command id survived.
The new test runs one refused round in each of eight fresh worlds under the
stock weights, where every lens can be drawn, and asserts the recorded lens
equals lens::draw over that world and the id the row is keyed by, with that
lens's text. It asserts its premise: the weights give every lens weight, and
in each world some other id draws another lens. An unrelated random id
agrees with the session's in all eight worlds about once in 8^8 runs.

Test-only; no production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:51:13 +02:00
Meta Vens
f63dfd12fc Rewrite the lens to one the fixture did not draw (L1 c3.s1.f1)
The illegal-transition pin rewrote the recorded lens to Numen, but its
fixture draws under uniform weights with a random world id, so about one
run in eight the lens was already Numen and assert_ne! failed. The rewrite
now picks Patina when the recorded lens is Numen and Numen otherwise, so
the lens rewrite always differs from the row.

Measured: at ef64008, 3 of 40 runs failed at controllers.rs:10230 with the
recorded lens Numen; after the change, 120 of 120 runs pass.

Test-only; no production code changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:47:00 +02:00
Meta Vens
ef6400884a Record L1 Cut 3's landing, verdicts and findings
Cut 3 landed at dfb3c52..2bf8164. Soul's first pass confirms that
boundary sessions now resume after unrelated commits, that a resumed
session keeps its recorded lens across a weight change, that admission is
the same under every lens, and that the catalog and session identity are
unchanged. It also observed all three illegal transitions (lens, text,
ancestry), and a stale adopted draft is still refused by the kernel.

Going to a test-only fix batch:
- the illegal-transition test is flaky, failing roughly one run in eight;
- nothing pins that the call-site draw reads the command id;
- nothing pins that a session resumes with its stored instruction text,
  which was the whole point of the Q7 ruling.

Recorded: stored text is not tied to the recorded lens (inside Q7's
accepted cost); a route cost change re-rolls a boundary session's lens and
orphans its row; the seed lane orphans rows the same way deficits do
(L1.f13). The ledger text no longer doubles "recorded".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:42:12 +02:00
Meta Vens
2bf81645ed Pin that adoption refuses a row under another world or jurisdiction (L1 Cut 3)
`a_row_for_the_same_answer_under_another_world_or_jurisdiction_is_not_adopted`:
a real refused session's row, with its session's world moved to another real
world, or its jurisdiction moved to another real place of the same world, and
then stored under this answer's derived id. The step is `Superseded`, no
inference runs, and the row is unchanged. The row as it was written is then
adopted and commits, so the test is not an inert harness.

The command id already implies world, jurisdiction and digest, so only a row
whose key and session disagree reaches this rule. The test builds exactly
that row.

Builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:13:37 +02:00
Meta Vens
4457fde138 Remove the dead-code expectations from the lens draw and text (L1 c1.s1.f3)
Cut 1 marked `Lens::instructions` and `lens::draw` with
`cfg_attr(not(test), expect(dead_code))` while only their tests read them.
`select_answer` reads both now, so each marker produced an unfulfilled lint
expectation warning. `reaches_for`, `leads_with` and the moved
`ELABORATION_INSTRUCTIONS` carried no markers. No `expect(dead_code)`
remains in `lens.rs`.

Builds. The library's warning count returns to 9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:07:47 +02:00
Meta Vens
18b1098c3f Record the lens and its text on the session; adopt rows by answer (L1 Cut 3)
A new elaborator session draws its lens and authors its instruction text
once, and the checkpoint row carries both from then on. A resume reads the
row's session instead of demanding the fresh derivation equal it.

Session:
- `ElaboratorSession` gains `lens: Lens` and `instructions: String`
  (`deny_unknown_fields` kept).
- `select_answer` derives the command id from world, jurisdiction and
  answer digest, draws `lens::draw(snapshot.lens_weights, world, id)`, and
  authors `lens.instructions()`. It returns the id with the session, and
  `step` uses that id instead of deriving it a second time.
- `session_command_id` takes the three identity fields instead of a session,
  because the lens is drawn from the id and so the id exists before the
  session does. The derivation bytes are unchanged: the Cut 0 id
  `56dbcdb2-9337-ac07-f322-37376b58cbcb` is pinned in
  `a_session_identity_is_derived_from_its_answer`.
- `ELABORATION_INSTRUCTIONS` moves into `lens.rs` as a private constant.
  `elaboration_request` takes the text, and `step`, `persist_repair` and
  `integrity_is_valid` pass `session.instructions`. Integrity binds the
  stored invocation to the stored text, not to code (L1-Q7 (a)).

Adoption rule (L1-Q9, boundary half): the whole-session supersession
equality in `step` is deleted. A found row is this answer's row when world,
jurisdiction, answer and answer digest match (`same_answer`). Its session,
including ancestry, lens and text, replaces the fresh one. A boundary session
is no longer `Superseded` forever after an unrelated commit.
`valid_elaboration_progression` is unchanged and still compares whole
sessions row to row; the doc comments on both rules say why they differ.

Store: `controller_work.v15` -> `v16`; v15 rows are refused at open, not
migrated. The world ontology doc names v16.

Tests (controllers): `a_boundary_session_resumes_after_an_unrelated_commit`
(at 1a402e1 it reported Superseded),
`a_resumed_session_keeps_its_recorded_lens_when_the_weights_change`,
`a_checkpoint_that_changes_the_lens_or_its_text_is_an_illegal_transition`,
`a_row_whose_text_and_invocation_disagree_is_refused_at_open`,
`a_controller_work_row_from_v15_is_refused_at_open`,
`soul_the_same_patch_admits_the_same_under_every_lens` (pinned to the
Cut 0 mismatch JSON), and `the_catalog_takes_no_lens`. The single-dead-end
fixture now delegates to `dead_end_mailbox(weights, dead_ends)`, which builds
the first dead end with the same handles and labels as before.

Builds, but with two "this lint expectation is unfulfilled" warnings from
the `expect(dead_code)` markers on `Lens::instructions` and `lens::draw`. The
next commit removes those markers (c1.s1.f3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 16:07:25 +02:00
Meta Vens
4334dd7394 Compare lens sets by effective weight in the no-op check (L1 c2.s2.f1)
`SetLensWeights` refused an identical set by comparing raw maps, but a
lens a map does not name weighs zero. `{charter:3}` to
`{patina:0, charter:3}` therefore committed, and changing back committed
again, though both sets draw identically.

Equality is now `LensWeights::weighs_the_same_as`, owned in `lens.rs`: every
stock lens's effective weight is equal. The reducer's arm calls it; the
stored spelling is whatever the last committed set said.

Test, through the public mailbox in both directions:
`a_lens_set_spelled_with_or_without_its_zero_weights_commits_nothing`. A
sparse world set refuses a one-zero padded twin and an all-eight padded
twin; a padded world set refuses two sparse twins; each refusal is
`NoCanonicalChange` with revision, digests, operator log and stored
spelling unchanged; real changes commit.

Builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:55:23 +02:00
Meta Vens
dfb3c52b01 Guard the lens draw's divisor directly (L1 c1.s1.f2)
The draw checked `draws()` over the map's values and then divided by a
sum over `Lens::ALL`. Those were two computations that happened to agree:
weakening the guard alone reached a modulo by zero. The draw now sums the
total first and refuses a zero total, as `select_band` does; `draws()`
stays the admission rule and no longer guards the draw.

Builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:53:04 +02:00
Meta Vens
1a402e1d45 Record the Cut 2 fix batch, Soul pass 2 and two operator rulings
The fix batch landed at afca403 and acb9b33: identical lens weights are
refused as no canonical change, and the forged all-zero row is now caught
only by the draw rule. Soul's second pass found that the no-op check
compares map spelling, so a set padded with a zero entry still commits.
That fix is folded into Cut 3, next to the divisor guard.

The Cut 2 spec's claim that identical weights commit is now marked as
history. The implementation plan names a NoEffect variant that the kernel
never had; it now describes the NoCanonicalChange refusal that exists.

Operator rulings: the browser create form (L1.f11) is fixed in Dungeon
pass D2. Draft approvals (L1.f12) are deferred until multi-player
membership returns, and lens weights are excluded from any future binding
because they do not affect what an approver sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:45:58 +02:00
Meta Vens
acb9b3379d Re-seal the forged lens weights rows so only the rule refuses (L1 c2.s1.f4)
a_forged_lens_weights_row_fails_replay re-sealed only the commit digest, so
its all-zero forgery was refused by the resulting-state-digest mismatch and
still passed with draws() weakened. Each forgery now re-seals the whole store:
the head takes the effect's weights, its state digest is recomputed, the
commit's resulting state digest and digest follow, and the head's last commit
digest names the re-sealed commit; every seal is asserted consistent.

A drawing control re-sealed the same way replays, so no digest is left to
answer. The caller forgeries assert an ownership refusal and the all-zero
forgery asserts a draw-rule refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:31:09 +02:00
Meta Vens
afca403fca Refuse identical lens weights as no change (L1 c2.s1.f1)
An owner's SetLensWeights equal to the world's current weights committed:
the revision advanced and the digests moved though nothing canonical changed.
`reduce` now refuses it with PatchRejected([NoCanonicalChange]) after
admission, so the live submit and replay share the rule and nothing commits.

The order is capability, owner, draw rule, then equality: a non-owner's
identical set stays Unauthorized, and an identical set that never draws stays
LensWeightsNeverDraw. The SetLensWeights doc stated the old rule and now
states this one.

Tests: the in-crate soul test's pin of the old commit becomes the refusal,
with the order pinned (non-owner identical is Unauthorized; an identical
all-zero set against a hand-built state is LensWeightsNeverDraw) and the
store reopened to the same head. external_admission gains
identical_lens_weights_from_the_owner_commit_nothing through the public
mailbox: revision, state digest, last commit digest and operator log length
unchanged, non-owner identical Unauthorized, a different set commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:31:09 +02:00
Meta Vens
c8dfdd1a35 Record L1 Cut 2's landing, verdicts and findings
Cut 2 landed at d06fd7e and 10be98c. Soul's first pass finds every promise
holding except the browser half of P2.6, which is unproven. The owner
check has a single source, and no ingress lets a caller supply its own
authority.

To fix before Cut 3: an identical SetLensWeights commits a no-op revision,
against the kernel's rule that a no-effect command commits nothing. The
forged all-zero journal-row test is caught by the digest, not the draw
rule.

Recorded for the operator: the browser create form never lifts its
bindings into the payload, for any field (L1.f11, relevant to the playtest
gate). Draft approvals are bound to nothing, so an approval survives later
weight and structure changes (L1.f12).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:26:07 +02:00
Meta Vens
10be98cce7 Name consumer.v5 and world_create.v4 in the world docs (L1 Cut 2)
The ontology names world_create.v4 with its required lens weights and the
consumer.v5 state and commit schemas; the consumer API names consumer.v5.
Docs only; builds as the previous commit does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:00:45 +02:00
Meta Vens
d06fd7ec2e Put lens weights in the world (plan step 15, L1 Cut 2)
Lens weights are world data (L1-Q2), replaced only by an owner command
(L1-Q3), and required at creation with no library default (L1-Q6).

Library:
- WorldState.lens_weights, written by genesis through
  ResolvedPatch.lens_weights (Some on the genesis lane only) and carried
  on CreateWorld and CreateWorldIntent (required).
- CommandBody::SetLensWeights and WorldEffect::LensWeightsReplaced: owner
  only, any phase; reduce decides and apply_effect re-decides owner and
  draw rule. A set that never draws is Mismatch::LensWeightsNeverDraw at
  genesis and in the command; verify_state_shape refuses a stored one.
- WorldSnapshot.lens_weights.
- State and commit schemas go to consumer.v5; v4 stores are refused.
- resolve_patch takes the weights; the action lane passes None.

Dungeon:
- CreatePayload.lens_weights required; world_create.v4 in the descriptor
  and operation_schema; the eighth create binding and a lens weights
  control defaulted to uniform_lens_weights(), which is Dungeon policy.
- The smoke harness states uniform weights. The stale-payload test now
  denies v3, a v4 payload without weights (as a payload error) and one
  without a brief; new tests deny an unknown lens and an all-zero set.

Tests: genesis writes and replays weights; an all-zero genesis is refused
and writes no file; only the owner replaces weights (non-owner, stranger,
elaborator, clock, consumer, stale revision, all-zero, hand-built effects);
weights enter state_digest and no scope digest; a history with a weight
change replays to the same digest; forged weight rows fail replay; a v4
store and an all-zero state row are refused; external non-owner refusal
and unknown-lens decode.

This commit builds (Windows only).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:00:45 +02:00
Meta Vens
3cb6a74bb0 Record L1 Cut 0 captures and Cut 1's landing and verdicts
Cut 1 landed at 835ea4d, and Soul's first pass records all five promises
as holding. External code can build an all-zero LensWeights through
new, Default or Deserialize but cannot reach the draw, because the lens
module and draws are private. So there is no crash path, which matches
L0's unforgeable-admission ruling.

Soul findings:
- duplicate keys are last-wins, like every BTreeMap field in the crate
  (recorded);
- the all-zero guard and the divisor are different computations, a
  hardening fix that goes to Cut 3;
- a leftover expect(dead_code) produces a warning, not a compile error,
  which corrects Hands' claim.

Cut 0's captures and the census baseline are now recorded, and so are
Cut 1's ledger actuals. The ledger no longer says the draw lives inside
the session identity, which the Q4 revision removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:40:45 +02:00
Meta Vens
835ea4d019 Add the stock lens module (plan step 15, L1 Cut 1)
crates/ghostlight/src/lens.rs owns the eight stock lenses, each lens's
"reaches for" text and leads_with list of existing PATCH_TOOLS names, the
per-lens instruction text, LensWeights (a transparent BTreeMap<Lens, u32>
with an empty Default and no uniform constructor), and draw: a digest over
LensPreimage { world_id, command_id }, first sixteen hex as u64, modulo the
weight total, cumulative walk over Lens::ALL, Invariant error when no
weight is nonzero. Same recipe as select_band; no rand.

lib.rs declares the module, exports Lens and LensWeights, and gives the
library test module stock_weights(). ELABORATION_INSTRUCTIONS stays in
elaboration.rs (Cut 3 moves it) and becomes pub(super) so the lens text can
read it. instructions() and draw() carry cfg_attr(not(test),
expect(dead_code)) until Cut 3 reads them, following controllers.rs:759.

Nothing outside its tests reads the module yet. PATCH_TOOLS, the catalog
functions and the sidecar fixture are untouched. Builds on Windows; the
Linux build is Idunn's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:26:21 +02:00
Meta Vens
ff766e8901 Rule L1-Q9: resume reads in-flight elaboration state
The operator ruled that an in-flight elaborator session is rediscovered by
reading state, not by re-deriving its key. Cut 4 adds one store method
listing in-flight elaboration rows, scanned once per sweep. The sweep
matches rows by world, jurisdiction and answer, ignoring ancestry, lens
and instruction text, and adopts the row and its id before any draw. The
command id stays the kernel's idempotency key and stops being the only way
to rediscover work.

New promises prove the fix, and each fails when adoption is removed: a
stranded deficit session is adopted with its refusal and without a
repeated round; a boundary session is repaired after an unrelated commit
and a clock tick; no orphan row accumulates over five ticks; real-shaped
non-matches are never adopted.

L1.f10 is resolved by Cut 3 for boundaries and Cut 4 for deficits.
Every question L1-Q1 through L1-Q9 now carries a ruling. Estimated total
+1,300 lines.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:11:42 +02:00
Meta Vens
d3b2c0e464 Keep the lens out of command identity; find the stranded repair loop
The operator revised L1-Q4: the lens is recorded on the session and stays
out of the command id, because the id is the idempotency key and must be
a fixed property of the demand entry it answers. That dissolves L1-Q8 and
its eight-candidate-id probing. L1-Q7 is ruled: per-lens instructions,
with their text persisted in the checkpoint.

The operator then asked whether resume relied on re-deriving an id
instead of reading state. It does, and a probe found that the elaborator's
repair loop has never worked across sweeps (L1.f10, pre-existing, high).
A deficit session's key moves on any commit, so the next sweep repeats
the inference without its refusal and orphans the old row. A boundary
session is found, but its stored ancestry makes it Superseded forever
after any commit, including a clock tick.

Cut 3 carries the equality fix that boundaries need. L1-Q9 asks whether
resume should read in-flight rows by what they answer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 14:04:56 +02:00
Meta Vens
b846c68a28 Map the stock lenses cuts (plan step 15, L1)
Six cuts against 817c9f4: captures, the lens module, lens weights as
world data with an owner command (consumer.v5), the lens on the session
and in the prompt (controller_work.v16), concurrent elaboration under split
permit pools, and doc reconciliation.

Status is derived rather than maintained. A question is open until it
carries a ruling with its authority, and a cut is closed when every
promise has a verdict.

The operator ruled on the seed, weights in the world, the owner weights
command, split pools, emphasis as prompt text, and explicit weights at
creation with world_create.v4. Q7 (where lens text lives) is open with a
probed recommendation. Q8 was defaulted and is under review.

Probes, all on Windows: two sessions on one answer collide today; a
lensed session survives reopen and resume under v16; storing instructions
in the checkpoint passes integrity through resume and repair; the Dungeon
test count differs by four Linux-only tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 13:24:25 +02:00
Meta Vens
817c9f45ea Write the stock lenses target (plan step 15, L1)
L1 gives elaboration in the ghostlight library a creator-controlled flavor.
The library ships eight stock lenses, a world carries lens weights as world
data, and each elaborator session draws one lens deterministically. Several
sessions may elaborate at once.

The target names eight invariants: lenses never decide admission, every
lens sees the whole catalog, the draw is replayable with no RNG, weights
are admitted honestly, concurrent sessions stay distinct, the drawn lens
is recorded and never redrawn on resume, stores refuse rather than
migrate, and no Dungeon concept enters the library.

It also records the constraints the substrate map found, and the open
questions Imagination must bring back. Status is derived from the cut map,
not maintained by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:45:03 +02:00
Meta Vens
ef04aa5e03 Stop listing the stock lens set twice; name it plainly in the handoff
map.yaml's next action listed the stock lens set as L1 and again inside
"the remaining library capabilities". The handoff still said the titled
elaborators "return as weighted lenses", which predates the ruling that
they ship as the library's stock lens set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:41:46 +02:00
Meta Vens
d89f2602c2 Sync the last surfaces that described the kernel before L0
The closing Mind pass found four places still describing the tree as it
was before the extraction:
- the handoff's controllers path;
- plan step 15's heading, which said not started;
- plan D1, which still said "detail profile";
- the system map's source owner and schema name, and its private
  world::WorldKernel.

Each now names the ghostlight crate and its sealed-by-admission boundary.
The boundary evidence record's first decision said the kernel's only
Dungeon import was a type that has since moved; it now points at the L0
record. The previous text is archived by compaction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:26:38 +02:00
Meta Vens
d86ed9f4a2 Close L0: postmortem, ledger, target and state reconciled
The kernel extraction is finished. The world kernel is the ghostlight
library crate; Dungeon consumes it across a boundary sealed by admission,
proven from outside the crate.

The cut map records the last two fix batches and Q1-10, defaulted: the
minter scanner stays a tripwire, with Clippy recorded as the fix. The
subtraction ledger missed its estimate, +210 net source lines against about
+65. The reasons: the scanner grew chasing evasions, the boundary needed
about 90 more public items than planned, and the rejection tests buy a
ruling made after the estimate.

The postmortem lists what Soul caught, the operator corrections, the
incidents, and the open follow-ups. The target states which invariants
hold and how. The plan, map.yaml and the handoff point at L1. The L0
evidence record is rewritten as its distilled lessons; the previous text is
archived.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:24:49 +02:00
Meta Vens
ab95d7897c Name the library's test subject human, not player
Players are a Dungeon concept. The library's own term for a
human-controlled subject is ControllerMode::Human, so its test code now
says so: the principal fixture player() becomes human_principal() (a
local `human` subject binding in action.rs would shadow a bare human()),
and the genesis subject handle, labels, principal address, variables and
one test name follow:

- "player" handle -> "human"; "The Player" -> "The Human";
  "Another Player" -> "Another Human";
  "player@example.test" -> "human@example.test"
- player_opportunity, after_player, genesis_player ->
  human_opportunity, after_human, genesis_human
- draft_activation_player_and_autonomous_actions_share_one_reducer ->
  draft_activation_human_and_autonomous_actions_share_one_reducer

Test code only: no production code, schema string, serde name, pinned
digest or fixture file carried any of these values. The library test
count stays 419 passed and 1 ignored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:19:04 +02:00
Meta Vens
b14507c894 Pin the scope digest comparison to exact case and label
The stale-digest admission test now submits, besides the one-digit
forgery, the current issued digest with one hex letter uppercased, its
bare 64-hex value, and the same hex under a blake3: label. Each forgery
asserts its own premise before submission, and each must be refused as
ScopeChanged with revision, state digest, last commit digest and
operator-log length unchanged.

This kills the case-insensitive, hex-only (rsplit) and sha256:-stripping
mutants of the comparison in the kernel, which the previous eight tests
admitted, while the length, 7-character prefix and all-but-last mutants
stay killed. The cases extend the existing stale-digest test; the
external admission test count stays eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:14:52 +02:00
Meta Vens
af9c95a5bf Name the consumer, not Dungeon's session type, in library docs
SeedPort's evidence is minted by the consumer authenticating a live
principal, and a quarantined controller leaves WorldMailbox and its
consumer authoritative. Neither comment needs to know the consumer is
Dungeon's AppSession.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:56:55 +02:00
Meta Vens
9e514ebf15 Close InferenceRequest::provider_model to the crate
Its only caller is the SDK inference router inside the library, and no
consumer names it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:56:55 +02:00
Meta Vens
bde05de4e3 Refuse a digest shaped exactly like the real one
Both existing scope-digest forgeries differ in length from a real
`sha256:` + 64-hex digest, so a length-only comparison in
`exact_opportunity` passed every external admission test.

The new test takes two genuinely issued controller opportunities, lets
one subject speak (genesis places every subject in the commons, so the
telling lands as knowledge on the other and moves its scope digest), then
submits the listener's now-stale opportunity. It asserts the stale and
current digests differ and share a length, and that the submission is
refused with ScopeChanged and commits nothing.

A stale SHA-256 digest differs from the current one almost everywhere, so
it cannot catch a comparison that skips the last character. The test also
submits the current digest with only its final hex digit changed.

Mutants on the comparison, each killed: length-only, first seven
characters only, all but the last character.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:56:55 +02:00
Meta Vens
89eb75b6a7 Record the Cut 1 fix batch and Soul's verdict
The fix batch landed at 7c1d1e9..2865ea7: external rejection tests,
the minter check by name, Eve removed from library text, and a narrower
public surface. Soul confirmed the rejection tests drive the real kernel
path from outside the crate, that the unchanged-state assertions are
sound, and that Hands was right about what holds three types public.

Soul also found the digest check is pinned only by forgeries of the wrong
length, so a stale same-length digest from an earlier revision is going to
a fix. The minter scanner is still evadable five ways; since every text
scanner in this migration has lost to a short evasion, Q1-10 asks whether
Clippy's disallowed-methods should replace it.

Recorded, not fixed: rustc on stable does not enforce compile_fail error
codes, the world_id check is redundant by construction, and the decline
path is not reachable from an external test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:47:08 +02:00
Meta Vens
2865ea738d Close the two snapshot rows Dungeon never reads
An item stays pub only when the compiler requires it for an API Dungeon
calls. WorldSnapshot's resources and routes fields were pub, which forced
ResourceSnapshot and RouteSnapshot pub; Dungeon reads neither field and only
the library's own controllers do. The fields and both types are now
pub(crate). The workspace builds with the same unique warning set.

Every other bare-pub identifier Dungeon never names was closed one at a time
under cargo check --workspace --all-targets. Each either fails to compile
because Dungeon or the admission tests use it through a public path, or adds
a private_interfaces warning from a public enum variant field or method that
Dungeon does use (KernelError, CommandBody, CausalBoundary, RefKind,
ControllerWorkCustody, NarrativeCheckpoint, WorldMailbox), or strands library
code as dead. Those stay pub.

ScopeDigest, WorkLane and ScopeComponents are in the second group: the
warnings come from KernelError::ScopeChanged, ControllerWorkCustody::Uncertain
and the NarrativeCheckpoint variants. NarrativeInterruption's
bound_scope_digest and fresh_scope_digest return &str, so they never forced
ScopeDigest open, and Dungeon calls both at runtime.rs:1286-1287 and
2022-2023, so they stay pub too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 11:13:56 +02:00
Meta Vens
7cbb9e37a6 Say what the library means, not who consumes it
Four error strings and four comments named Eve, which is a consumer's
interface and not a concept the kernel has. The errors now say what they
refuse — a command ID, an opportunity, persisted controller work — and the
comments name an operator interface or a consumer's payload.

Nothing pins these strings: the four `#[error(...)]` literals appear only at
their definitions, and no test reads a `Display` of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:59:31 +02:00
Meta Vens
fac90e6f4b Follow the minter by name, not by one literal
The old count read one non-recursive directory, split each file at one exact
`#[cfg(test)]\nmod tests {` marker, and counted one literal. Soul walked past
it with an alias; a qualified `<T>::new(`, a file in a subdirectory, and a
production item placed after the test module walked past it too.

The check now walks `src` recursively and removes what each `#[cfg(test)]`
attribute guards by its own extent — balanced braces for an item with a body,
the first `;` or `,` outside parentheses for one without — so a renamed test
module is stripped and nothing after it is hidden. Construction is recognised
by scanning what follows the type's name, which admits `<T>::new(` and any
whitespace, and the one site found must be this owner's file.

Following the type by name means production may not give it a second name: an
`as` import or a type alias is refused outright rather than counted. What the
check still cannot see is a call that never spells the type, such as a macro
expansion or a generic instantiated elsewhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:59:31 +02:00
Meta Vens
a8d3377072 Prove admission from outside the crate
Q1-9 rules the seal as unforgeable admission rather than unconstructible
values, so the proof belongs where a consumer stands. An integration test
compiles as its own crate against the public API alone; these seven mint
their forgeries through serde, which visibility does not gate, and assert on
the typed error rather than on any message.

Six rejections — a forged scope digest, the digest string the deleted
ScopeDigest::fixture used to produce, a forged controller ID, a minted
SubjectId, a minted AffordanceId, and a wholly deserialized opportunity —
each assert that revision, state digest, last commit digest and the operator
log's length are unchanged. One control commits through the same port with an
issued opportunity, so the rejections cannot be an inert harness.

No manifest change: serde_json, uuid, chrono and tokio are already library
dependencies, and Cargo gives test targets a package's dependencies as well
as its dev-dependencies. No new production dependency and no feature.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:50:35 +02:00
Meta Vens
7c1d1e96fa Rule Q1-9: sealing means unforgeable admission
External code may hold a syntactically valid ID, digest or opportunity,
because Dungeon's Eve payloads deserialize them. What must hold is that
the kernel rejects every one it did not issue, proven by rejection tests
written from outside the crate. The compile_fail set stays for the
genuinely private items, with its limit stated: it pins a path name and
cannot see constructibility.

The target's first invariant now says that, so the claim matches what the
boundary does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 10:42:25 +02:00
Meta Vens
af63b0cd13 Record Soul's verdict on the Cut 2 fix batch
The batch holds: whole-invocation pinning survives image swaps, line
swaps, re-indentation, continuation splits and appended flags; the order
checks catch a move into the reuse branch; the runbook is tied in both
directions; and a fail inside a command substitution does abort.

Soul then found the limit of the approach. An exact-line pin proves the
lines exist, not that they run: wrapping the block in `if false`, chaining
it behind `true ||`, or swallowing it in a heredoc keeps every pinned line
intact and the checker green. Abort on failure rests on an unpinned
`set -euo pipefail`, and a duplicated block passes unnoticed.

All three are recorded, not fixed. The script is a legacy actuator under a
do-not-invoke warning, retired at the deployment gate, and the authority
that really runs the tests is Ghostlight's Idunn recipe. Revisit only if
the gate keeps the script.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:57:01 +02:00
Meta Vens
8ddc1e86f3 Record the Cut 2 fix batch and its follow-ups
gamecult-ops 803f2c7 pins each of the four cargo steps as a whole
docker invocation, asserts the recipe order, and brings the runbook into
the wiring test. The four mutations that previously passed silently now
fail, and no pre-existing assertion was weakened.

The same defect class survives in three places outside this migration:
the web build container, the Codex Connector's cargo steps, and a fixed
18-line search window. All three are recorded as follow-ups.

Soul has not yet passed on the fix batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:47:28 +02:00
Meta Vens
e7d21013b9 Record Soul's Cut 2 verdict
The assertions Cut 2 added pin text, not invocations. Deleting the two
lines above the pinned cargo line turns it into trailing arguments on the
previous container: the kernel tests never run and the wiring test still
passes. Nothing pins the order of the four cargo steps, and the wiring
test never reads the runbook.

Those three are going to a fix batch. Two more are recorded, not fixed: a
redeploy of an already-sealed commit runs no cargo steps at all, which is
pre-existing deploy behaviour the operator owns, and the dated census docs
still show the old single-crate paths.

What held: the library step runs in the right branch with the same image
and mounts, its failure aborts the deploy, and the acceptance test path
resolves under the library crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:40:45 +02:00
Meta Vens
1b409b3121 Record Cut 2 and the two spec defects it exposed
Cut 2 landed at gamecult-ops 6aba281: the library test step now runs in
the deploy script, is documented in the runbook, and is asserted by the
wiring test. Without it a deploy would run 50 tests and skip the 420
kernel tests that moved to the library crate.

Two defects in this map's own spec, reported by Hands rather than worked
around:
- the assertion it specified was vacuous, because require_text is a
  substring match and the given string is a substring of the
  persona-projection line;
- the acceptance test path lives in a variable the spec did not list,
  pinned by the wiring test and repeated in the runbook prose.

Changing the acceptance binding means sealed acceptance witnesses from
before this commit will miss and regenerate.

Soul has not passed on Cut 2 yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:35:06 +02:00
Meta Vens
880c9656ad Record Cut 1 and Soul's verdict; sync the stale surfaces
Cut 1 landed at 108b691..f859d2c. Soul reran the builds, tests, census and
mutations itself and confirmed the behavior-unchanged claim; it found six
defects.

F1 is the one that matters: the seal is name-based. Public ID types,
ScopeDigest and DecisionOpportunity all derive Deserialize, so an external
crate can mint them from JSON and rebuild the fixture path ruling Q1-4
said must be unreachable. The reducer still rejects forged opportunities,
so kernel integrity holds while the invariant's wording does not. Q1-9 asks
the operator whether sealing means unforgeable admission or unconstructible
types.

F2 (bypassable minter count) and F3 (71 unused public items) are queued for
the fix batch. F4 names Eve in library error strings. F5 and F6 are
recorded, not fixed.

The map, map.yaml and the handoff now say the kernel is the ghostlight
crate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:27:09 +02:00
Meta Vens
f859d2c4e3 Give the world library a sealed public boundary
Completes the move begun in the previous commit, which does not build on its
own. `ghostlight` is now a library crate with an explicit public surface;
`ghostlight-dungeon` is a consumer that reaches the kernel through it and
cannot reach past it.

Deleted first: `mod world;`, the nine unused re-exports, the `#[cfg(test)]`
re-export block, `fixture_controller_opportunities`, the five inference
`fixture_*` items, Dungeon's own `VerifiedPrincipalEvidence`, and Dungeon's
direct `ghostlight-persona-projection` dependency.

The seal. `WorldKernel`, `WorldState`, `reduce`, `apply_effect`,
`CommandEnvelope`, `AuthenticatedCaller`, the journal writer, every ID
`issue()`, and `ScopeDigest::fixture` carry no `pub` of any kind and no
feature gate; the crate declares no `[features]`, so no build configuration
can lift them. Ten `compile_fail` doc-tests stand outside the crate and pin
each one to its error code.

The principal seam. The library owns `VerifiedPrincipalEvidence` with a public
`new`; `mailbox` still bounds it by `valid_until` at ingress. A Dungeon test
counts the minters in production source and fails at anything but one.

The port contract (Q1-7). `PreparedInference::prepare` (renamed from the free
`prepare_invocation`), `InferenceOutput::new`, and
`InferenceFault::{retryable, recovery_required, integrity_violation}` are now
the port's public constructors, so a consumer-implemented `InferencePort` can
answer without test-only wrappers. `TracingInferencePort` is an ungated
decorator.

The tick-ordering test (Q1-8) derives its cover from a real active world
through `derive_cover` and asserts two cells; its ordering and shared-tick
assertions are unchanged.

Behaviour is unchanged. The library's 420 unit tests are the base `world::`
set with the prefix stripped — the names diff empty. Dungeon keeps all 49 of
its names and gains two Soul tests the boundary requires. No schema string,
digest preimage, or store layout is touched.

Windows verification only; the Linux release is Idunn's build.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 02:06:07 +02:00
Meta Vens
108b691015 Move the world module to crates/ghostlight
Pure `git mv` of the thirteen files under
`crates/ghostlight-dungeon/src/world/` into `crates/ghostlight/src/`, with
`mod.rs` becoming `lib.rs`. No byte of any file changes; all thirteen are
detected as renames so history follows.

This commit does not build. The crate graph has no `ghostlight` package yet
and `ghostlight-dungeon` still declares `mod world;`. The next commit adds
the manifest, the public boundary, and the consumer rewrite; the two are
pushed together.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:32:21 +02:00
Meta Vens
6ddabcad33 Record the L0 rulings; close the fixture question
Rulings Q1-1 through Q1-6 are in the map's status header. The crate is
`ghostlight`. The Vault reader and the SDK port go in the library. The
library owns the principal evidence type. The persona projection crate
stays separate. The gamecult-ops cargo lines are edited, not retired,
and editing them is not an endorsement to run those helpers.

Ruling Q1-4 asked for the Dungeon tests that need sealed fixtures to move
into the library. Classification found none to move: all five are Dungeon
runtime tests. Two further rulings close it instead:
- Q1-7: the inference port's value constructors become the port's public
  contract and the five fixtures are deleted.
- Q1-8: the cover ordering test derives its cover from a real world, and
  the last fixture reaching a sealed ID constructor is deleted.

The sealed ID issuers and ScopeDigest::fixture stay private under every
configuration, with no feature gate anywhere.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 01:27:37 +02:00
Meta Vens
02bbf62eb1 Map the library extraction cuts
The Imagination pass maps plan step 15 L0 against d83534c:
- Cut 0: base captures (469 bin tests, and a world.cc written at base).
- Cut 1: a single Ghostlight commit that moves world/ into a library
  crate with the exact public boundary (compile_fail doc tests prove the
  sealed items) and a test-support feature for cross-boundary fixtures.
- Cut 2: the gamecult-ops cargo invocations.

The map was probed with scratch cargo checks on the Windows workstation,
so it proves Windows only. Six operator questions are open. No code has
moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 23:27:03 +02:00
Meta Vens
d83534cf38 Write the library extraction target; merge conflicting boundary records
Plan step 15 pass L0 extracts the world kernel from crates/ghostlight-dungeon
into its own library crate. The new target document lists the invariants
that must survive:
- sealed kernel visibility at the crate boundary;
- no change in behavior, schemas, or stores;
- no Dungeon concepts or dependencies in the library;
- a working release path;
- unaffected pins;
- bounded build fan-out.

It also lists the questions Imagination must bring back.

Two evidence records from 2026-09-15 disagreed about focus and whether the
extraction and title decisions were settled. They are now one boundary
record. The title correction now points at the ontology doc's lens section.
The compaction archived the previous records verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:57:01 +02:00
Meta Vens
e19c9d9bda Replace focus with typed detail rules; ship titles as stock lenses
A detail rule is a function from place to detail level. Each consumer
chooses its own level-of-detail rule. Dungeon uses ByDistance anchored on
important subjects, the player first. Delvehold starts from Uniform.

Rules are typed world data that the library evaluates, not consumer code.
The deficit a rule produces is an admission input, and journal replay
re-runs reduce, so a changed consumer function would break committed
history. WorldScaleIntent per-kind targets remain the ceiling a rule
distributes.

The eight titles move into the library doc as the stock lens set. The
kernel extraction is ordered as plan step 15 pass L0, ahead of every
library pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:55:18 +02:00
Meta Vens
3d5fe83708 Keep Session Zero in Dungeon; spec the library capabilities it consumes
Ghostlight Dungeon is a consumer of the Ghostlight library. Session Zero,
Vault choice, players, sliders and starting locations are Dungeon authority
and do not shape the library.

The ontology doc gains consumer-neutral library targets: elaboration
lenses with weights, focus-weighted demand with a consumer-supplied detail
profile, one evidence source bound per world, and elaborator sessions in
Draft. The Session Zero doc now carries only Dungeon policy that consumes
those capabilities.

The map records the boundary as a hard rule. The plan splits into library
passes L1-L4 and Dungeon passes D1-D3. Two decisions stay open: extracting
the kernel from crates/ghostlight-dungeon, and whether the eight titles ship
as a library stock lens set.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:46:54 +02:00
Meta Vens
038632d1dd Align remaining doc lines with titled lenses and Session Zero
The memory steward found four leftover lines. The plan and the ontology
doc now describe elaborators as lensed sessions, not eight fixed
instances. The handoff no longer says there is no Session Zero. The
ontology doc notes that Session Zero stops declaring the human at the
genesis commons.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:34:39 +02:00
Meta Vens
25ff91f08d Restore the titled elaborators as lenses and design Session Zero
The eight titles (Patina, Charter, Ledger, Hearth, Tangle, Veil, Ember,
Numen) were deleted with their titled verifier and quotas, and no operator
decision covered them. The ontology doc and evidence record 6 now say the
tribunal was the verifier and the quotas, not the titles. A new correction
record carries the history and the 2026-09-15 direction.

Session Zero is the player's path through a world's Draft phase. The player
chooses a Vault, describes the experience, and sets title weights. The seed
lane sketches the world and the player chooses a start. A swarm then draws a
title by weight for each session and elaborates by travel distance from the
player, before and after activation. It is plan step 15, and its fifth pass
closes the playtest gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:33:21 +02:00
Meta Vens
705d298f91 Make the local playtest the next gate; correct stale map claims
The playtest gate goes ahead of the outbound consumer batch. The handoff
defines it and lists the walls found in source. The map no longer claims
co-op for up to eight players on the rebuilt kernel. It points the economy
spec at GameCult/Njordr and says CultCache comes from CultLib, not a
vendored copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 22:10:52 +02:00
Meta Vens
a306feda4d Clarify connector constraint in map
The Claude SDK sidecar is the only live provider. Backends share one
standardized CultNet connector API with Epiphany; CodexConnector is isolated
and not live here; a generic OpenAI-compatible connector gates third-party use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 23:06:40 +02:00
Meta Vens
cc016ffb55 Name CultLib as the home of the TypeScript CultNet client
The contract doc still pointed at the standalone GameCult/cultnet-ts repo,
which is being removed. The client is the cultnet-ts package in CultLib.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:47:39 +02:00
Meta Vens
59bbe85875 Keep the world elaboration record and first-generation world fixtures in Ghostlight
The elaborator swarm's GitHub branches were removed after verified bundling,
but its output is still wanted: the operator will integrate ideas into the
lore vaults as time allows. The durable record now lives in the repository
that owns the obligation instead of a Codex task folder and deleted branches.

experiments/elaboration/ carries the reviewed additions packet (A2 and D1-D3
approved, A1 demoted, K1/K2/Z1-Z3 pending), the per-world reviews, the
2026-09-05 shortlist with its 20 full candidates, and the 92 per-pass idea
ledgers, with a README naming where the bundled vault additions are and how
to restore one. The four reviewer-accepted first-generation world fixtures
return from their deleted codex/world-* branches to examples/ and
prompts/image-generation/ beside the existing fixtures. The map and handoff
point at the new home.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:35:03 +02:00
Meta Vens
22937b71f0 Replace vendor/cultcache-py submodule with published PyPI dependency
CultLib's packages/cultcache-py is now published as cultcache-py==0.2.0 on
PyPI (MIT), so the standalone GameCult/cultcache-py fork that Ghostlight
vendored as a submodule is retired. Symbol-for-symbol and on-disk-format
verification against the pinned submodule commit 9b4bbb8 found no
incompatibility: every symbol tools/ghostlight_state_store.py imports
(CultCache, JsonLinesBackingStore, define_document_type) and every method it
calls kept its signature, and JsonLinesBackingStore's on-disk JSONL envelope
fields and sort order are unchanged, confirmed by a real round-trip against
Ghostlight's production state/ghostlight-state.cultcache.jsonl.

tools/ghostlight_state_store.py no longer pushes vendor/cultcache-py/src onto
sys.path; it imports cultcache_py directly. Since Ghostlight's session
bootstrap previously depended on the submodule needing no install step,
tools/requirements.txt now pins cultcache-py==0.2.0 and npm run
state:bootstrap (pip install -r tools/requirements.txt, idempotent) runs as a
pre-hook ahead of state:status and state:prepare-compaction; AGENTS.md's
Session Bootstrap protocol and Useful Commands section call it out explicitly
for the direct ghostlight_state.py CLI invocations that don't go through npm.

Verified for real: `python tools/ghostlight_state.py status` and `python
tools/ghostlight_prepare_compaction.py`, run from a venv with cultcache-py
installed from PyPI, against Ghostlight's actual production state file (22 ok
findings, 2 pre-existing unrelated warnings, exit 0 both times).

Idunn's deployment/idunn/recipe.toml never declared vendor/cultcache-py in
required_gitlinks (only vendor/eve) and its build/test/service steps never
touch Python state tooling, so no recipe change is needed for admission.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 16:08:19 +02:00
Meta Vens
9574bf53d7 Point the economy spec at GameCult/Njordr
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:29:10 +02:00
Meta Vens
78e54554b8 Njordr: discovery as a producer policy, retooling as a recipe, catalog generation plan
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:15:15 +02:00
Meta Vens
4d1040ece0 Njordr: sources are finite deposits, materialized as mining
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 17:58:46 +02:00
Meta Vens
cc85db847c Njordr: roles are filled by lots, properties may be curves over conditions
The heat spreader is a role; copper, a vapour-chamber part, or anything
else fills it by its properties alone. A property value may be a curve over
an operating condition, so a part that fails hot fills a cool recipe and
not a hot one. No optional roles, no alternate recipes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 17:44:08 +02:00
Meta Vens
b6c2ccd697 Njordr: demand as rates, derived intermediate demand, admission over provenance, producer policies
Player activity moves prices both up and down the recipe graph: consumption
is a lot sink so floods find a floor; producers' planned inputs are derived
demand so crashes propagate to inputs; markets admit or refuse lots by
provenance so a diverted shipment re-enters through a black market; corners
are bounded by the scoring rule and answered by supply. Seeding guarantees
every market is served or marked unserved as a fact.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 17:31:42 +02:00
Meta Vens
c077174264 Njordr: market maker settled, no Delvehold surface to cut against
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 17:19:49 +02:00
Meta Vens
e2aeb972bd Njordr owns the economy whole, Delvehold interior included
Greathold recipes, facilities, inventories, orders, and prices are Njordr
lots, recipes, markets, and quotes; Delvehold civic organs author policy as
demand rows and carrier policies. Unity embedding settled as the Rust engine
over a C ABI. The integration note's Delvehold-owner line is marked
superseded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:54:23 +02:00
Meta Vens
d415101fa1 Njordr: engine and shell, seeded single-player mode
The engine is a pure deterministic step function over a state document and
an exogenous event stream it cannot attribute; the shell is the daemon.
Single-player Aetheria hands over the engine state and an event script,
never precalculated prices, so player choices stay live against the story.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:45:24 +02:00
Meta Vens
ced6d89b98 Njordr: name the repository spelling
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:28:50 +02:00
Meta Vens
b1167cce67 Njordr: shipments, two consumer profiles, dimensions settled
Economic flow is materialized as shipments a player can meet. Aetheria's
interspersed profile adds materialization, interception against a revision,
and per-zone cadence; Delvehold keeps the boundary profile. One ledger, one
quote function; only the subscription grain differs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:28:26 +02:00
Meta Vens
6133461d8e Spec the GameCult economy daemon
Dimensions, materials, lots with provenance, recipes as roles, products as
property vectors; markets as Ghostlight mirrors; prices as a pure function
of committed state; both directions through the consumer contract. The
economy owner is the consumer that owns a world's market mirrors.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 15:04:28 +02:00
Meta Vens
d5cbc45a87 Map: name the span rule beside its schema pin
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:34:35 +02:00
Meta Vens
4406008b3c Record run 12: every cited span lands
Runbook row 12, evidence record, schema pins in map and handoff moved to
controller_work.v15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:34:20 +02:00
Meta Vens
855727f8a4 Take a turn's address as its spans in prose order
The Interpreter was citing correctly and the fold was discarding: an
utterance split by narration lost its second half and a turn's second
visible act became an ambiguity gap. Every span cited for a kind now joins
that kind's one statement in prose order; only an overlapping span is an
ambiguity. NarrativeCapture carries the spans, the invocation still carries
one utterance and one display. controller_work.v15.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:20:27 +02:00
Meta Vens
d0c047f375 Record run 11: the interrupted cell, logged per cell
Runbook row 11 and the interrupted-cell section, evidence record, map and
handoff advanced: next is the outbound consumer batch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:12:26 +02:00
Meta Vens
81a1f53c2e Report the interrupted cell: subject and both scope digests
A committed turn that re-lowered once carries its pair of scope digests on
the receipt; the cover tick logs it per cell and keeps it on the tick
summary, and the smoke harness prints one "interrupted cell" line per
re-lowered cell after the tick line. The operator feed line shows a display
beside or instead of speech.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:00:40 +02:00
Meta Vens
4ef5f71397 Record run 10: the first displays and the first live interrupted cell
Runbook row 10 and the interrupted-cell section; plan step 14 landed;
evidence record; map, handoff, and scratch reconciled (schemas v4/v14,
thirteen components, next action is the per-cell interrupted log line).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:34:09 +02:00
Meta Vens
0582321e2c Let a person be seen: nonverbal display by quote
An invocation may carry a display, the actor's own visible sentence, beside
or instead of its utterance. The kernel lowers it as speech's twin: an
AssertClaim and a Display whose room is re-derived at apply, landing
Seen { by } on everyone standing in the actor's place and on nobody else,
never over a channel. A speech-carrying entry given a display alone is an
address, not SpeechRequired; an empty display is EmptyDisplay; a placeless
actor has no room. The Interpreter gains a display tool quoting the visible
clause alone; the narrative capture carries both spans; the fold allows one
of each. Overheard rows and Projector knowledge rows say how a thing was
perceived: said by, seen doing, or known. The journal's event-shape rule
counts both lowerings. Kernel-only lowering: no patch tool, catalog stays
thirty. Schemas: world_state.consumer.v4, world_commit.consumer.v4,
controller_work.v14. Six tests pin the room, the silence, the refusals,
replay, the fold, and the interruption block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 13:21:53 +02:00
Meta Vens
29ecae7613 Give plan step 13 its own heading
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 12:59:45 +02:00
Meta Vens
5885eff5b6 Map step 14: nonverbal display by quote
A visible act becomes Seen { by } knowledge on the co-located, kernel-lowered
from the invocation beside speech, quoted by the Interpreter as the visible
clause alone. Intent stays private. Authority map, cut line, budgets,
verification, and the three operator decisions are in the plan; map,
handoff, and scratch point at it. Not built.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 12:51:33 +02:00
Meta Vens
b29e7e5b57 Record run 9: a world with a name and a premise
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 03:13:52 +02:00
Meta Vens
d4d7bc3c07 Give a world its brief: authored once, guidance on every lane
world_create.v3 carries a required brief, trimmed at ingress, stored on the
state and its snapshot, and projected as the guidance of the Projector,
Persona, Interpreter, operational, and grouped lanes and both authoring
prompts. The two narrative stages that rebuild prompts on resume carry it
(controller_work.v13). The smoke titles its world by the seed root label and
hands its brief to the world rather than to each seed session, so a vow can
no longer name a test fixture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 03:04:02 +02:00
Meta Vens
5e6c7e22fd Record runs 7 and 8: the first people who speak in their own voices
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:35:38 +02:00
Meta Vens
a9d6a1c980 Tell the model why a call was refused, describe the controller, reopen on the SDK lane
Run 7's seed authored six distinct people with memories, reads, and worded
promises, and every declare_subject carried an empty controller: the field
had no description and the tool result said only "recorded as a gap". The
controller variant now describes its two shapes, and a decode failure's
reason goes back to the model in the tool result. The SDK lowering renders a
later user turn into its transcript, so a refusal continuation reaches the
sidecar instead of faulting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:24:28 +02:00
Meta Vens
c31f25415a Let the seed author meaning: persona material, commitment statements, presence
Plan step 12. PersonaMaterial is the ontology's thirteenth row, built:
values, voice, memories, and reads keyed by subject, set whole by
set_persona_material, refused for padded text or a doubled read, no-op when
identical, outside every scope digest. A commitment carries a statement of
what is promised, required at authoring; an affordance-made promise states
the action it came through. The Projector context carries material by label,
each promise's statement, and who else stands in the same place; the
Persona's identity is label, voice, and values. Schemas move to consumer.v3.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 02:12:13 +02:00
Meta Vens
0a6a7216e3 Record runs 4 to 6 and map the seed authoring meaning as proposed step 12
The membrane traced end to end: the Projector's borrowed voice, the
contentless commitment, the unbuilt PersonaMaterial, and the missing
co-presence are recorded as findings; the harness no longer claims to fail
on silence, which is now the honest baseline. The map and handoff point at
the decision the operator owns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 01:31:20 +02:00
Meta Vens
19609ee06c Render absence as flatness, and due as time
The Projector and Persona prompts asked for what this person remembers,
wants, and fears, with no component to ground any of it, so every seeded
person spoke in the model's one borrowed voice. Both prompts now render only
what the context establishes, say plainly what is unknown, and treat the
word budget as a ceiling. A commitment's due reaches the Projector as
minutes from now, since a bare 1000 was read as a sum of money.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 01:17:40 +02:00
Meta Vens
f3d1904428 Let the smoke show the whole membrane
A test-only TracingInferencePort wraps any inference port and appends each
request's purpose, model, instructions, input turns, tool catalog, and
output to the file GHOSTLIGHT_SMOKE_TRACE names. The logs redact prose by
design and the store is a temp directory, so this is the one way to read
what the Projector, the Persona, and the Interpreter were each given and
what each returned on a road run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:41:27 +02:00
Meta Vens
4297b7c0c2 Project a seeded person's own life to its Persona, by label and never by id
The Projector context carried a name, a clock, and permission to speak, so a
seeded person reasoned correctly from nothing and said "I'm here". It now
carries the acting subject's own place, routes, holdings, dependencies,
authority shape, offices, redress, promises with their counterparties, and
pressure on self, resolved to labels. Another subject is named only where
this subject's own components bind them. Ids, keys, digests, revisions, and
every other subject's state stay out; the leak tests pin it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:27:11 +02:00
Meta Vens
52d148788c Record the first SDK road runs and point the Mind past the login gate
Run 3 proves speech cited by quote arrives whole with no quarantine. The
map, handoff, and runbook now say the SDK route is run; the remaining road
item is the live interrupted cell. One distilled evidence record carries
the day's belief-changing findings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 00:06:17 +02:00
Meta Vens
4f7513bcba Cite Persona prose by quoting it, and make a bad quote a gap rather than a quarantine
The Interpreter's speak and record_gap tools took byte offsets the model
counted itself; on the Claude lane three of eight utterances came through
cut mid-word and one empty span reached invocation as a non-canonical
utterance and quarantined its cell. Both tools now take `source_quote`; the
harness locates the quote in the preserved prose and derives the span. A
quote not in the prose, empty, or padded is a translation gap at capture.
The invariant is unchanged: only words in the prose become speech.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:57:17 +02:00
Meta Vens
936aa71b8d Continue the conversation on refusal, and give the claude lane its descriptions
The first SDK-backed seeded runs. Run 1 refused both seed sessions: the
sidecar's schema conversion dropped every field description, `due` was an
undescribed integer against a clock the model never saw, and the subject
tool did not say a grant is required. Run 2 seeded six people in one round
and ticked three times.

A refusal used to reopen the session with an empty draft and a prompt that
named sites from a draft the model could no longer see. It now continues
the same conversation: `Refusal { after_round, mismatches }` on both
authoring checkpoints (controller_work.v12), the refusal as the next user
turn, and the draft rebuilt by one DraftFold shared by evaluator and oracle
from the calls after it. The harness takes the connector only when its
address is set, so an all-claude run needs none.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 23:50:28 +02:00
Meta Vens
463bd8dc14 Name the Linux deploy target so agents stop compiling for Windows
Nothing an agent loads in this repo said Ghostlight Dungeon ships to Linux
on Yggdrasil, so a Windows workstation build looked like reasonable
verification. Record the target, the fact that Idunn builds the release in
pinned Linux containers, and what a local compile is and is not evidence
for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 12:23:24 +02:00
Meta Vens
04db2dafa7 Cut the handoff's chronology down to current authority and the next gate
Item 1 had grown to three hundred lines of per-pass SHAs, line numbers, and
test counts that git and the ledger own. The handoff now carries what is
true of the tree, the constraints, the ordered next moves, the operator's
open decisions, and the deployment gate. Gating lessons move to the
re-entry warnings; the map points there instead of repeating them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 15:55:19 +02:00
Meta Vens
18f2eb96a2 Distill the evidence ledger to twelve records through one compaction path
44 records to 12: supersession chains collapsed to their survivors,
per-commit receipts folded into the decision they served, standing
pre-rebuild decisions merged into one record map.yaml already owns. Every
removed record is archived verbatim. `compact-evidence` is now the one
durable deletion path; the status tool reads folded scalars, and the
compaction helper imports the map readers instead of carrying copies.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 15:03:55 +02:00
Meta Vens
e211075969 Compact the map's next action to working memory before compaction
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 14:55:29 +02:00
Meta Vens
1665620690 Close plan step 11: the SDK port with its follow-ups and lessons
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:38:50 +02:00
Meta Vens
d72c246b1f Reclaim the lend on every exit path, and cut three leftovers
`take_oracle` now runs above the caller-identity and expiry gates, so a
refused `infer` reclaims the lend instead of leaving a dead lane's
tool-result owner in the map under a request id nothing will call again.
The port's doc already claimed this; the test that recorded the
divergence now asserts the reclaim.

`fixture_prepared` was `prepare_invocation`'s body a third time, with
the same caller id and the same expiry as `fixture_prepared_inference`
beside it. Its call sites use that one instead.

`ElaboratorSession`, `ElaborationCheckpoint`, `SeedSession`, and
`SeedCheckpoint` go back to `pub(super)`: nothing outside `world` names
them, and `pub(crate)` was reach they never needed. `private_interfaces`
then reports them the way it already reports the grouped, narrative, and
operational checkpoints, which are private under the same `pub(crate)`
`ControllerWork`.

Runtime binds the SDK model prefix from `DEFAULT_SDK_MODEL_PREFIX`
rather than spelling `claude` twice more.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:34:19 +02:00
Meta Vens
a791d559d5 Make Rust the one argument validator on the SDK transport too
The sidecar registered a closed Zod schema, so the SDK judged arguments
before Rust ever saw them: an extra key was stripped, a missing required
property was refused with an MCP error, and the call never reached the
handler. The connector path decodes the same argument in Rust, where
`deny_unknown_fields` and required-field decoding turn a bad argument
into the gap the evaluator records.

`toolInputSchema` now builds a loose object whose every property
catches, so nothing is stripped and nothing is refused. The conversion
survives only to advertise: the in-process MCP server derives the schema
the model sees from this object, and that schema still carries every
property, type, bound, and `required` name the emitter produced. What is
deleted is the strictness that made it a second validator -- `.strict()`,
the closed-object requirement, and the every-property-is-required
requirement.

One case cannot be carried through as written: a value outside a
property's own type empties that property, so it reaches Rust as an
absent field rather than a wrong one. Rust still names the gap; the SDK
still does not answer the call.

The undispatched-tool_use protocol violation stays, because no argument
reaches it any more but a call never offered to the handler still does.

`resultFault` is the result-subtype mapping lifted out of `runQuery`, so
`error_max_turns` becoming an ordinary round and every other subtype
becoming its named fault are both asserted without a credential.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:34:09 +02:00
Meta Vens
3514d76667 Make the port's source-scanning test line-ending neutral
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:15:31 +02:00
Meta Vens
0cbbf77284 Record the Claude SDK inference port in the docs
Two transports behind one port, routed per lane by model prefix; the
sidecar as a child process; the oracle as the evaluator's own fold; the
stopgap's liabilities named with its exit condition; the operator's
credential steps in the smoke runbook.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 02:11:41 +02:00
Meta Vens
4f424a5cfa Close the session's unissued-call gate with a test
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:10:33 +02:00
Meta Vens
0fa411bd81 Falsify the SDK port: adversarial oracle equality, gates, and the Zod divergence
Tests only. Per lane, an adversarial call sequence — decode failure, duplicate
terminal, call after terminal, unknown tool name, oversized argument — is
answered by the oracle with exactly the strings the evaluator recomputes for the
same transcript, and the transcripts the oracle refuses are the transcripts the
evaluator refuses. Identity is pinned across every request builder and one
checkpoint variant. The wire's closed enums, the fault disposition table, the
lowering gate table, the dispatch-order gate, the receipt's contents, and the
routing refusal each get a test.

Two recorded findings, characterized and not fixed:

- The emitted grammar's top-level object is not strict in Zod, so a property the
  connector's `deny_unknown_fields` decoders record as a gap is silently
  stripped and the call succeeds; and a missing or mistyped required property is
  answered by the SDK itself, leaving a registered `tool_use` block with no
  dispatch, which `assembleEvents` raises as `protocol_violation` and Rust maps
  to an integrity violation. Both are proven against the real in-process MCP
  server with no credential and no network.
- A lend refused by the caller-identity or expiry gate is never reclaimed: those
  gates run before `take_oracle`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:10:33 +02:00
Meta Vens
2ff1d435d8 Add the Claude SDK sidecar: framing, the closed schema converter, the query
`sidecar/claude-sdk` is the repo's second TypeScript package, wired to the
root only through `npm --prefix`. It reads length-prefixed MessagePack
frames on stdin, runs one SDK query, forwards every tool call to Rust, and
returns Rust's answer to the model verbatim. It computes no tool result,
reads no credential, logs no prompt, argument, or result, and writes nothing
outside its own stdout.

The query is opened with a custom system prompt, `settingSources: []`,
`tools: []`, `strictMcpConfig`, `persistSession: false`, and `alwaysLoad` on
every tool, so no CLAUDE.md, settings file, output style, built-in tool, or
persisted transcript reaches a caller-derived prompt.

`schema.ts` converts exactly the grammar `tool_schema.rs` emits and throws on
anything else, so an emitter that outgrows it fails a test rather than
registering a looser tool. Two Rust tests publish the fixtures the sidecar
tests read — the frame bytes and every real `parameters_json` — so the halves
cannot drift apart silently.

Dependencies are exact-pinned: @anthropic-ai/claude-agent-sdk 0.3.261,
@msgpack/msgpack 3.1.3, zod 4.5.4, and dev typescript 5.9.3 with
@types/node 24.13.3. `engines.node` is the installed major.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:10:33 +02:00
Meta Vens
b150b5e38e Add the SDK inference port, its oracle, and the model-name routing rule
`SdkInferencePort` runs one Claude Agent SDK query per round through a Node
sidecar and returns it as one `InferenceOutput`. It prepares through the
same `prepare_invocation` the connector port uses, so there is one identity
scheme and every `integrity_is_valid` variant is unchanged.

The sidecar computes no tool result. Every call is answered by
`ToolResultOracle`, which is the lane's own evaluator fold replayed over the
calls made so far — the same function that will recompute the string at
re-derivation. A lane lends its oracle keyed by the prepared request id
immediately before `infer`; the connector port ignores the lend.

Rust owns the transcript lowering, the receipt digest, and the fault
disposition. The sidecar names a closed set of reasons and cannot assert an
integrity violation, a request identity, or a tool result.

`open_inference` routes each configured model: a `GHOSTLIGHT_SDK_MODEL_PREFIX`
model reaches the SDK port, anything else the connector, and a model no
built backend claims fails at open rather than at its first tick. Ghostlight
reads no credential for this transport.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:10:33 +02:00
Meta Vens
708d621c0c Collapse the two prepared-identity bodies and the two constructors
`prepare_invocation` is now the one place a `PreparedInference` is built:
the connector port's `prepare_request` and `fixture_prepared_inference`
were the same body twice, and a second transport would have made it three.

`ControllerRunner::open` takes its ports. `with_test_ports` existed only
to bypass the two transport-specific parameters `open` hard-coded, so
removing them deletes the `#[cfg(test)]` twin rather than adding a third
constructor. Runtime binds the transport and the controller-work owner
through `open_inference` and `open_controller_work`, and
`GHOSTLIGHT_CONTROLLER_CREDENTIAL` is no longer unconditionally required.

Each tool lane's per-call body moves whole into a named fold —
`interpreter_tool_result`, `operational_tool_result`,
`grouped_tool_result` — which the batch evaluator now calls. No result
string changes. `apply_tool_call` was already this shape.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 02:10:33 +02:00
Meta Vens
feb603b04b Record the SDK port design as decided
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:52:51 +02:00
Meta Vens
634e81f365 Open plan step 11, the Claude SDK inference port; record the SDK research
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:25:31 +02:00
Meta Vens
e93ed166f1 Record the overturned connector assumptions
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:14:11 +02:00
Meta Vens
fd4edc531b Record the Claude connector task opening
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:09:47 +02:00
Meta Vens
06236a4faa Close steps 9 and 10 with their follow-ups
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:04:08 +02:00
Meta Vens
651bfb2d34 Delete the unreachable SPEAK_KIND guard; test the driver's Interrupted arm
Fork D was dead code: affordance_grants is insert-only and a checkpoint
that reached ReadyToSubmit/NoProposal already had speech, so a fresh
opportunity on the same scope always carries it too. Delete the guard,
the overtaken(Some(..)) call it fed, and note why in `interrupted`.

Widen InferencePurpose, PreparedInference::purpose, and InferenceEvent
to pub(crate) (plus fixture_inference_events/fixture_recovery_required
test seams) so runtime::tests can script an inference port by purpose.
Add a driver-level end-to-end test: through run_cover_tick, the world's
operational agent speaks once between the narrative cell's Persona turn
and its first submit (a re-lowering) and once more between the
re-lowered submit and its own commit, spending the one re-lowering the
turn is owed. The second change lands on the driver's own Interrupted
arm rather than ControllerRunner::run_narrative directly, and the tick
commits exactly the operational agent's two acts.

WorldMailbox::submit_controller stays pub(super); the test reaches
speech through the already pub(crate) ControllerPort wrapper instead.

Soul's follow-up on step 10 (the witness pass): add a MidTurnCommit::
Witness cause and a controllers.rs test proving a Witness landing
between the actor's bound turn and its submit re-lowers with only the
anonymous "what this person knows changed" line and no Overheard block
- KnowledgeSource::Witnessed writes no spoken_at, so overheard_since
finds nothing.

Rebased onto origin/codex/ghostlight-dungeon-mvp (step 10 integrated,
5cd8853) after step 9's own follow-ups landed; no conflicts at this
branch's controllers.rs/runtime.rs seams.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:59:42 +02:00
Meta Vens
5cd88536ef Record step 10 landed; phrase the operation count as the pinned catalog count
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:44:36 +02:00
Meta Vens
12445955d5 Record the witness operation in the docs
One speakerless event over a place subtree: recipients derived at apply
time by the one reach owner, already-holders untouched, an empty subtree
refused at both layers, confinement through the operation's place, an
affordance slot so a world can author a beacon or a blast. Schemas at
consumer.v2; the operation count aligned to the catalog's pinned count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:42:38 +02:00
Meta Vens
70ea6b1323 Falsify the witness pass over the divergences it could hide
Seven tests, no production change. Two lanes, one selection: a
`Reach::Place` channel's `fan_out` and a witness over the same place
agree over every root and every speaker, so the two callers of
`under_place` and `unheld` are pinned by behaviour and not only by grep.
`under_place` covers a place itself, covers below it transitively, stops
at its edge, and never reaches a subject with no position — including
under the widest root in the world.

Two patches built so the resolver's candidate model and the apply arm
could disagree, and do not: a witness whose only recipient is a subject
the same patch declares and places, and a witness whose only recipient
is a subject the same patch forgot the fact. Each is refused at one
layer if the layers part; both land.

What a witness hands the narrative lane, pinned at the kernel because
the lane's fixtures are another pass's: exactly one scope component
moves, `knows`, which is the anonymous line, and the row it writes
carries no `spoken_at`, which is the only gate on an attributable
`Overheard` row.

The no-`Told` pin named its knowledge-touching kinds by example; an
exhaustive match now breaks the build when a `ComponentOpKind` is added
without saying whether it touches knowledge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:41:22 +02:00
Meta Vens
ecdeb9214a Witness: one speakerless event over a place subtree
An event a region saw cost one AcquireKnowledge per witness against a
128-operation patch cap, so a hemisphere did not fit in a patch. `Witness`
lands `Witnessed` knowledge of one fact on everyone standing anywhere under
one place.

The operation stores the place, never the recipients: the subtree is
re-derived at apply from live positions through `under_place`, and holders
are dropped through `unheld`, so a forged effect cannot assert a landing the
world does not have and a repeat cannot reset a knower's credence. It names
no speaker, so `Told` stays unrepresentable outside `Communicate`.

A witnessed event is only its reception, so a witness that reaches nobody is
refused — at resolve over the candidate graph, which now models the fan-out
including this patch's own relocations, and at apply over committed state.
Confinement is by the named place, which is not weaker than confining by the
recipients: a consumer and an uncovered jurisdiction refuse every witness
through the closure that already refuses every place.

Both store schemas move to consumer.v2 and prior stores are refused at open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:41:22 +02:00
Meta Vens
307d8a5520 One owner for "under a place", one for "already a knower"
`audience`'s `Reach::Place` arm and `fan_out`'s already-holds filter were
inlined bodies. They become `under_place` and `unheld`, so the subtree read
and the never-overwrite rule each have one owner before a second caller
exists. No behaviour change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:41:22 +02:00
Meta Vens
f6cd3b534c Record step 9 integrated and step 10 in flight
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:24:30 +02:00
Meta Vens
711f6ec4b0 Record the interruption pass in the docs
The narrative lane re-lowers once on a moved scope with the delta the
subject could perceive; the kernel is the sole detector for that lane;
an author is named only for speech actually heard; a turn is lowered at
most twice by the shape of its binding; SubjectSnapshot carries the
subject's whole components; schemas controller_work.v11 and
persona_turn_receipt.v3. Plan step 9 landed with its undone list.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 23:20:51 +02:00
Meta Vens
d20dc51cca Falsify the interruption pass over its untested causes
Soul review of plan step 9. Tests only; no production code changed.

Five additions, each aimed at a claim the pass makes and does not
check.

The leak invariant, over the causes the pass leaves untested: a
transfer out of a neighbour's custody, a route closed under the
actor's feet, and the actor's own grant revoked. The existing fixture
world has no route, no resource, and no jurisdiction, so its subjects
can only ever be interrupted through `knows` and `commitments`; a
world carrying that furniture is what makes the other component kinds
reachable at all. Each cause re-lowers once, reports its own anonymous
line, produces no overheard block, and carries no label, id, or value
belonging to anyone.

The membrane's exact boundary. A mid-turn commitment names its
counterparty by subject id, that value really is inside the persisted
`Interruption`, and no prompt byte carries it. That is the difference
between "the renderer takes values rather than a snapshot" and "the
renderer emits no value at all", and only the second is what the
Interpreter sees.

Fork B against the digest rather than against itself. The pinned
preimage fixes the serialization; this reproduces every live
opportunity's scope digest from the snapshot's own `components` field,
which is what "view and digest cannot drift" has to mean.

Fork A at the call sites it deleted. Both detail lanes resume a
persisted in-flight row after the world moved under it. The
operational lane still aborts at its detector and spends nothing; the
narrative lane spends one Interpreter round on a binding already known
to be stale, is refused at submit, re-lowers, and commits. The fork's
accepted cost is one extra round and it is bounded at one.

The receipt bump. A v2 receipt digest, rebuilt from the previous
version's own preimage rather than derived from the live function, does
not rehydrate under v3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:20:34 +02:00
Meta Vens
ad124eab0d Drop the interruption report's unread capture
Nothing reads it: the driver arm reports the subject, both digests, and
the gap detail, and the Eve receipt carries the prose and the gap. The
capture is still derived where the gap's source span comes from, and is
not stored.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:20:34 +02:00
Meta Vens
1a19fb2231 Prove the interruption over real committed state
Eight tests, each producing its interruption from a real commit that
lands on the world mid-turn rather than a hand-built digest: a
co-located neighbour's speech, and an owner patch that moves a component
naming no actor anywhere.

They hold the leak boundary at the delta surface, which no existing test
covered: the section names a speaker only for a telling the subject
actually received, carries no fact id, revision, digest or `spoken_at`,
and an out-of-reach actor's label and utterance reach no surface of the
turn. They hold the bound: four inferences for one re-lowering, and a
second refusal ends the turn with the overtaken gap having spent nothing
and submitted nothing. They hold the shape: a forged ancestry, a chained
re-lowering, and a row that disagrees with its own turn are each refused
at the gate that owns them, and the re-lowered row rebuilds its request
byte for byte at the derived round with no snapshot.

`the_scope_digest_is_unchanged_by_this_pass` pins the preimage digest so
a future field, order, or serde-attribute change fails there rather than
silently reissuing every bound proposal in every live world.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:20:33 +02:00
Meta Vens
1ef973a861 Interrupt the narrative turn instead of losing it
A scope digest that moved between a subject's prose and its commit is one
event with one detector — the kernel — and one handler, which renews the
turn's binding without re-running the Persona and lowers the same prose a
second time against an anonymous account of what moved.

Cut first. The narrative lane's three `ensure_scope_unchanged` calls are
gone: two detectors reporting different errors for one fact is what made
the pass need an entry point per stage. `exact_opportunity` is now the
sole authority on whether a scope moved, which is where the binding
already lives, and the operational lane keeps its early abort because it
holds nothing to preserve. `SubjectSnapshot` loses `holdings`,
`dependencies`, `incident_routes`, `authority`, and `controls` — each a
projection of a `ScopeComponents` field — and carries the components
whole instead.

The turn is re-recorded, never re-run. `PersonaTurnBinding` gains
`interrupted_from`, and a re-lowered turn's receipt commits to the
receipt it replaced by value; the prose and both inference receipts are
copied. One re-lowering is a property of the persisted shape rather than
a counter: a turn whose binding carries `interrupted_from` has no
successor arm.

What the Interpreter is shown is the typed component diff of the
subject's *own* components, named by meaning with no value, id, count or
actor, plus every statement told to it since its bound revision. The
renderer takes values rather than a snapshot, so a neighbour's state is
a compile error rather than a review note.

`controller_work.v11` and `persona_turn_receipt.v3` refuse prior rows
with no shim. No kernel arm changes: `ScopeComponents` gains
`Deserialize` and no field, so every scope digest is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 23:20:33 +02:00
Meta Vens
a62708fe33 Record steps 9 and 10 in flight; drop the two-call-sites claim
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:40:48 +02:00
Meta Vens
e2392ded21 Open plan step 10, witnessed events over a place subtree
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 22:37:56 +02:00
Meta Vens
a7c9b8e0d5 Scope the interruption promise to the narrative lane
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:48:04 +02:00
Meta Vens
b77f59ff9b Place the interruption clause where it reads
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:37:49 +02:00
Meta Vens
b22536fb11 Open plan step 9, interruption, and retire the re-submission decision
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 21:37:34 +02:00
Meta Vens
bcfd024b34 Record the seeded road run in the handoff and map
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 19:38:45 +02:00
Meta Vens
79206dfce7 Carry the road's rules from the seeded live smoke
The seeded smoke passed on the twelfth run: six persons authored into
Low Sere from the Kalsa vault in one round, the deficit to zero, nine
subjects and six boundaries at activation, eight cells per tick, the
seeded people speaking to each other. Each earlier failure became a rule
in the tree: strict function schemas (anyOf, typed tags) with an offline
totality test over both authoring lanes; a content-addressed provider
request identity so repair rounds do not collide with the connector's
replay record; the connector's expiry skew separated from the response
read timeout; the seed lane's own round budget with parallel tool calls
and a budget-end submit; canonical ids and the placement rule in the
brief; the commitment shape rule in its tool description; inference
faults and cell outcomes logged where the operator can read them; and a
harness that fails on an empty seed or a silent world.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 19:38:01 +02:00
Meta Vens
b7042f16df Record the seed producer and close its Soul follow-ups
Docs describe the seed lane as landed: scale intent at creation, the
phase-free qualification with the Draft refusal in one owner, the owner
port bound to the Draft revision, the Vault reader's containment and
caps, obligations as the source of boundaries, evidence lifetime at
ingress. The session-zero and compiler language leaves the eve-native,
multiplayer, Delvehold, and mvp docs; plan step 8 is landed; the smoke
note's run block targets Kalsa Public at Low Sere. Steward folded the
boundary and cut the stale drift warning.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 18:32:20 +02:00
Meta Vens
491b06e9b0 Land the six seed-lane Soul follow-ups
Vault: collect_notes canonicalizes every directory and note against the
vault's canonical root during the walk and refuses to descend into a
reparse-point directory entry, closing the junction escape. VaultError
no longer carries an absolute filesystem path: Root names no path and
Scope carries only the caller-supplied scope string. The link-fanout
receipt cap is now checked per receipt, not per link.

Mailbox: SeedPort::submit_seed takes its own snapshot at submission
time, refuses anything off Draft with KernelError::WrongPhase, and
binds expected_revision to that fresh snapshot instead of a caller-
supplied one — closing the window between a runner's initial Draft
check and a multi-round session's eventual submit. submit_principal
refuses evidence whose valid_until has passed, read at the mailbox
ingress boundary rather than in a reduce arm.

App session: VerifiedPrincipalEvidence carries valid_until, minted from
the session's own access_expires_at, so a long-lived SeedPort cannot
hold authority past the session that vouched for it.

Elaboration: SEED_INSTRUCTIONS now requires an obligation with a
counterparty from every authored subject, since a goal alone derives no
MissingStructure boundary; the goals-only reproducer stays as a live
test of that structural fact.

Controllers: updated the two require_answer/phase tests and the two
seed-lane spec tests whose expectations changed now that submit_seed
owns its own phase gate ahead of require_answer.

334 world / 379 crate baseline with 5 FALSIFIED reproducers ignored is
now 385 crate tests passing (2 unrelated live-infra ignores remain), no
FALSIFIED ignore left, fmt clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 18:31:31 +02:00
Meta Vens
697ed07081 Falsify the seed lane: five reproducers, eight bounds
Soul's pass over the seed producer. Tests only.

Five claims fail and are committed as ignored reproducers, each
naming its defect in the ignore reason:

- `collect_notes` follows a directory junction out of the vault
  root, and `strip_prefix` then dresses the escaped note in a
  vault-relative reference.
- `VaultError::Root` carries the server's absolute path into the
  owner's command receipt.
- `require_answer`'s `declares` reads declarations and evidence and
  not operations, so an operations-only patch passes the Active gate
  through `submit_seed`. The runner's phase check is the only gate,
  not the first of two.
- A `Goal` derives no boundary, so a world seeded to full strength
  out of goals alone activates with nothing for the elaborator.
- The mailbox never re-verifies principal evidence: a revoked
  session's evidence still commits as the owner.

Eight bounds hold and are asserted: an oversized note is never read
and the reader never writes, the receipt cap survives the link
fanout, `require_answer` still owns both phase rules, a session that
never submits stops at the shared round budget and its row stays a
fixed point, one derived id commits once, two landed sessions hold
two ids, `valid_seed_progression` refuses every backward transition,
and the custody probe counts a seed row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:56:13 +02:00
Meta Vens
e73ef5c8f8 Prove the seed lane, and make an unadvertised operation impossible to ship
Eighteen checks. The ones that matter most: an operation the panel emits but
`operation_schema` does not name now fails a test rather than dying silently at
validation — that test fails on the parent commit because of
`world.advance_time`, which is why the schema gap was cut first. A v1 create
payload is refused at validation and a v2 payload missing its scale target is a
payload error; neither defaults, and neither creates a world.

The lane: one session commits one Draft patch whose subjects have controllers,
grants, positions, and goals, and a port built on anyone else's evidence is
`Unauthorized` — the owner is the author, and that is the reducer's rule and
not a convention. The deficit falls 6, 4, 2, 0 across a sweep and the fourth
step spends no inference; a commit that does not lower its row returns
`NoProgress` and stops rather than spinning. An Active world refuses the runner
before inference and refuses the port afterwards with `AnswerRequired`. A
citation the Vault did not return is dropped by `filter_evidence` and the
canonical fact naming it is refused; the same fact citing a retrieved reference
commits. A rejection reopens one derived command id with the kernel's complete
mismatch set and a fresh runner resumes it. A v9 row is refused at open beside
the pinned v8 and v7 refusals, and cross-lane writes conflict.

End to end: a two-root world is created, seeded to zero deficit, approved,
activated, elaborated against the boundaries the seed manufactured, ticked, and
replayed from its journal to the same state digest.

The Vault reader is tested with no kernel at all: references are vault-relative
paths, all three wikilink forms resolve to one note, an ambiguous stem takes the
shortest path, and `..`, an absolute scope, and a symlink out are refused at
open.

The ignored live smoke now seeds from a real Vault before it ticks. Its log is
still the deliverable, and it gains the per-round deficit and the subjects that
qualified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:32:00 +02:00
Meta Vens
681f7063b1 Cut the phase clause and the fixed genesis, then land the Draft seed lane
The deletion line first. `world.advance_time` was emitted by the panel and
handled by `execute_world` but absent from `operation_schema`, so every
invocation died at validation; it is named now. `qualifies` loses its
`phase == Active` clause: `require_answer` already owns the Draft-answer
refusal, so the clause protected nothing and only pinned a Draft world's
deficit at the authored intent forever. `WorldScaleIntentRef::default()` and
the comment claiming world ingress authors no scale target are gone, along
with `world_create.v1` and `controller_work.v9`; `build_prompt`'s inlined
evidence/mismatch/tools tail collapses into one `prompt_body` owner.

Then the lane. `world_create.v2` carries a scale target and its jurisdiction
roots, which genesis declares beside the commons so `resolve_patch` can
resolve them. `SeedPort` reads a snapshot and commits one unanswered Draft
patch as `CallerId::Principal(owner)` — it has no approve, no activate, no
clock, no operator log, because those are not methods on the type. `SeedRunner`
is `ElaborationRunner`'s eleven stages with four substitutions: the phase gate,
the row selection, the identity namespace, and the submit port. It shares the
round loop, the tool application, the evidence filter, the cap check, and the
repair prompt verbatim. `world/vault.rs` is a read-only markdown reader over a
configured root: no network, no git, no vault name in core, and a spoiler tier
is a directory and therefore a query scope.

Termination is derived: the Draft deficit falls as seed patches land, so the
runner keeps no counter and a commit that does not lower its row is a fixed
point rather than a retry. `ApproveDraft` and `ActivateWorld` remain the only
path to Active.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 17:00:41 +02:00
Meta Vens
70600d8015 Open plan step 8, the seed producer
The plan carries the objective, current mechanism, and cut line; the
map's mission, boundaries, and subgoals speak the live admission
vocabulary; the session-zero document is teardown evidence; the draft
answer variant is marked representable but uninhabited. Steward folded
the task opening into the handoff and map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 16:29:04 +02:00
Meta Vens
f9a1c3ea1e Record the first live smoke in the mvp doc and handoff
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:45:37 +02:00
Meta Vens
5e52943734 Add the local live smoke harness and record the first road run
An ignored runtime test drives the production tick driver, cover,
elaboration sweep, and clock against a real CodexConnector on a genesis
world and logs what the operator would see; the fixture now takes an
optional live controller. Runbook in notes/local-live-smoke.md; the
first run (three ticks, Persona spoke each tick, elaboration clean) is
recorded as evidence.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 15:42:46 +02:00
Meta Vens
0a26286412 Clear the scratch subgoal after the series close-out
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:33:17 +02:00
Meta Vens
842b5af4ee Record pass 10 and close step 6 of the plan
The consumer-api doc is the live contract for the landed ingress; the
ontology's Current mechanism runs through pass 10 and its follow-ups;
the mvp doc names the live authority answer; the plan's step 6 is
complete with the outbound consumer response as the next seam; the map
and handoff carry the series-complete state, the world-v3 generation,
and the decisions the operator owns; one evidence record distils the
ten passes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:31:40 +02:00
Meta Vens
8173c9cb77 Close the pass-10 Soul follow-ups: id-bump, fatal credentials, redaction, evidence cap, named refusals
- Bump controller_work to v9 so a checkpoint from before the
  session_command_id derivation change refuses to open, and document why
  no migration exists.
- Make a malformed GHOSTLIGHT_CONSUMER_CREDENTIALS file fail startup
  instead of warning into an empty registry.
- Redact ConsumerPatchDocument's secret from its Debug impl.
- Route the elaboration lane's draft through the same check_patch_caps
  the consumer ingress uses, closing the unenforced MAX_PATCH_EVIDENCE gap.
- Give the three item-cap refusals their own ConsumerRefusal variants
  instead of flattening them into TooLarge.
- Delete journal_replay_refuses_a_forged_consumer_row, now covered by
  soul_a_forged_decision_event_on_a_mirror_is_refused_at_replay.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 04:29:29 +02:00
Meta Vens
a1fd64513f Falsify the consumer ingress at the gates the pass named
Soul, pass 10. Nine tests, no production change.

Two claimed proofs were not proofs. `journal_replay_refuses_a_forged_consumer_row`
guards its whole first assertion behind `if let Some(event) = state.events.first()`
and the mirror fixture commits no decision, so that half never ran; it now runs
under a committed decision. The deficit half of the boundary test lands on
`AnswerNotDerived` because its fixture names no scale intent, so the answer rule
refuses before the ground rule is consulted; a scale-intent fixture derives a real
deficit and proves `Unauthorized`.

`session_command_id` moved to `CommandId::derived` and the two spellings hash
different byte streams. The new test pins the fact: a session checkpointed before
this pass resumes under a different command id.

The rest: the byte cap ahead of serde, every ingress refusal shape against
snapshot equality and replay, the mirror re-scoped onto a real committed decision
at `apply_effect`, a mixed owner/elaborator/consumer history replayed to the same
world, the pass-9 state schema string refused by name, derived-key determinism and
part unambiguity, and the registry's error and Debug paths carrying no secret.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 04:09:24 +02:00
Meta Vens
b4d1943585 Prove the consumer ingress at every gate it names
Twenty-three tests: the three Delvehold negative proofs (an ordinary
strategic wave cannot select an externally controlled mirror through
opportunities, adjacency, the cover, or a hand-built opportunity; a malformed,
oversize, stale, or structurally broken batch commits nothing; a consumer's
transfer into a Ghostlight subject is refused on the unbound endpoint while
the owner's identical transfer commits), the inbound-bytes-to-receipt run with
its journal replay, idempotent resubmission and key conflict, the capability
pairing, the confinement rows, the phase rule in both directions, the shared
decode bound, the transport gates on the new route, and replay equality with a
prior-schema store refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 03:53:37 +02:00
Meta Vens
e0c6f9cb22 Widen the patch author to a confined external consumer
Cut first: the elaboration lane's two draft caps and its own sha256-to-uuid
command-id spelling, `jurisdiction_covers` and `confine_to_jurisdiction` as
jurisdiction-only names and signatures, the flat empty-affordance invariant,
and the two total controller accessors that were keeping a fictional
controller so a reader could stay infallible.

Then widen. `PatchGround` names what a confined author may write: a place
subtree, or every subject bound to one consumer, derived from
`controller_assignments` at decision time and never carried on the wire.
`SystemCapability::Consumer` is admitted for `AdmitPatch` and nothing else, in
the same closed pairing `reduce` decides and `apply_effect` re-decides.
`ControllerAssignment::ExternallyControlled` is a subject the world can see,
relate to, and reason about but never puppet: no controller id, no mode, no
affordance, no opportunity, no adjacency, and a forged history in which it
acted fails replay.

`patch::decode_patch` is the one decode owner for a `WorldPatch` from a byte
source, with one byte cap applied to the frame before `rmp_serde` sees it and
three item caps checked before `resolve_patch`; the elaboration lane reads the
same two item constants it used to own privately.

`world/consumer.rs` owns the ingress and no world truth: one typed document
over one loopback route, canonical MessagePack in and out, a registry that
holds only SHA-256 digests, one derived command key, one port method, one
atomic verdict. The state and commit schemas bump with the caller tag; prior
stores are refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 03:25:45 +02:00
Meta Vens
1203aa3286 Record pass 9 in the ontology and mvp docs and handoff
Current mechanism describes the budgeted connected cover, the tick
driver as the single clock owner, and the grouped-cell membrane rule;
the scale section carries the ceil(N/R) bound, the two reasons the cap
yields, and the contention finding with its open operator decision; the
two stale claims about AdmitPatch phase and EvidenceRef are corrected;
the consumer-api drift note names the decided transport; controller
model variables are documented. Steward folded passes 1 through 9 and
the pass 10 design state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:24:11 +02:00
Meta Vens
962f7110e3 Close the intra-tick quarantine race: recheck after permit acquisition
Follow-up to the pass-9 follow-ups: deviation 2 flagged that
run_cover_tick's per-cell quarantine check only ran once, before a cell
contended for its permit, so a sibling already parked behind the permit
pool when another cell's fault raised the flag would still run once the
permit freed up. Add a second check right after permits.acquire(): with
the pool sized to one, the second cell cannot be granted the permit
until the first has fully returned, and for a faulting cell that means
the flag is already set by then, so the post-acquire check is
deterministic rather than a race. The top-of-function guard against a
later tick's cells stays as-is.

Renamed and extended
quarantine_raised_mid_tick_stops_every_later_tick_from_reaching_the_port
to quarantine_raised_mid_tick_stops_the_sibling_cell_and_every_later_tick:
same cross-tick assertion as before, plus permit pool 1 and an exact
(not >=1) count of one call in the first tick, proving the sibling cell
never reaches the port once the first cell's fault quarantines the
organ. Ran the new test 8x in isolation with no flakes.

world:: 281 passed, full crate 320 passed (unchanged counts — this
extends an existing test rather than adding one). cargo fmt -p
ghostlight-dungeon -- --check is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:23:50 +02:00
Meta Vens
7aba2b4191 Cover the tick driver's permit gate, quarantine edge, and ordering
Pass 9 follow-ups 1-3 from Soul's review:

- Widen ControllerRunner::with_test_ports, InferencePort, and
  ControllerWorkStore to pub(crate) (still cfg(test)), plus the small
  cfg(test) seam needed to implement them outside world::controllers
  (fixture_prepared_inference, fixture_inference_output,
  InferenceFault::fixture_integrity_violation, and cfg(test) pub(crate)
  re-exports from world::mod). Two new runtime spec tests drive a real
  ControllerRunner over a counting/quarantining InferencePort and an
  in-memory ControllerWorkStore, proving the controller-permit semaphore
  never admits more concurrent run_cell calls than its pool, and that an
  integrity-violating fault quarantines the organ so no later tick's
  cells ever reach the port again.

- Extract drive_one_tick out of drive_cover_tick's loop body, narrowed
  the same way submit_clock_tick narrows the clock port: run_cover and
  submit_clock are opaque closures, never run_cover_tick or
  WorldMailbox::submit_clock by name. A new test drives it with a fake
  cover-runner over a real three-cell Cover (via a new cfg(test)
  fixture_controller_opportunities in world::mod) and a fake clock
  submitter, proving the clock is always submitted after every cell has
  run and that every cell in the tick carries the cover's one tick index.

- Delete cover.rs's merges_are_deterministic_in_subject_order: its name
  overclaimed determinism the test never checked. Its coverage-survives-
  a-permutation assertion was already proven by
  soul_b_the_cover_is_pure_and_the_attention_order_is_a_real_input; its
  other assertion (cell count is stable under permutation) was not, so
  that one assertion moved into soul_b's test instead of surviving under
  a misleading name.

Deviations from the literal brief, both load-bearing:

- Ruling 1 asked for "a cover of several cells" with
  GHOSTLIGHT_CONTROLLER_MAX_CONCURRENT = 2. The production world-create
  intent caps a genesis world at exactly two controller-bearing subjects
  (one narrative persona, one operational agent), so the real cover this
  test can derive tops out at two cells. Reusing that production ceiling
  meant not widening the kernel's private command types (CreateWorld,
  CallerId, CommandEnvelope) past what this pass already opens, which
  seemed like the more coherent boundary to hold. The permit pool is set
  to one instead of two so the cap is still load-bearing: with two cells
  and a pool of two, the assertion would be vacuously true.

- Ruling 1's second scenario wanted "the remaining cells are skipped"
  within one tick. Tracing run_cover_tick's per-cell task shows the
  quarantine check runs once, before a cell ever contends for its
  permit; every cell in one JoinSet is spawned and gets that check
  before any of them can reach the port and set the flag, so no cell of
  the *same* tick is ever actually skipped by it — only a cell of a
  *later* tick is, via run_cover_tick's own top-of-function guard. The
  test proves that real mechanism (a second tick makes zero further
  calls) rather than an intra-tick race the driver does not resolve,
  which would have been flaky either way it landed.

world:: 281 passed (baseline 282, minus the one deleted test).
Full crate 320 passed (baseline 318, minus one deleted plus three new).
cargo fmt -p ghostlight-dungeon -- --check is clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 03:20:19 +02:00
Meta Vens
8aebfb6811 Falsify pass 9's cover, resume, and replay claims
Tests only. Ten additions across the two organs pass 9 landed.

world/cover.rs, pure over `derive_cover` with no kernel:
- the rotation guarantee at the design profile, not at a toy size: 2,400
  subjects, B=240, C=24, U=36 gives R=110 and the window closes in exactly
  ceil(2400/110)=22 ticks, and not in 21;
- urgency reads the attention order while the window reads ascending id
  order, proved on a fixture whose two rankings are reversed against each
  other, so no third ranking can explain the result;
- the cover is pure for identical inputs, and the attention order is a real
  input rather than an incidental one;
- every cell in one cover carries that cover's tick index;
- derived cell and constituent keys are collision-free across a full sweep
  of ticks;
- the cap yields when component shapes cannot pack inside the budget even
  though token capacity would allow it, and the flag tracks the partition
  rather than the subject count.

world/controllers.rs:
- an uncoupled grouped cell commits every constituent, which is what makes
  the coupled cell's refusal a fact about coupling instead of a per-cell
  quota or a moved world revision;
- the refused constituent's scope digest really did change;
- the other grouped checkpoint resumes: a retryable fault parks the row at
  AgentInFlight, and the resumed run re-derives the same constituent ids,
  infers once, and commits once;
- a grouped tick replays from genesis to an identical state digest and an
  identical event sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:48:38 +02:00
Meta Vens
f013c1d22f Trim the cover's unread surfaces and report the tick
Cut the accessors, fields, and re-exports the pass added without a live
consumer: `Cell::id`, `Cell::tick`, `Cell::resolution`, the grouped
capture's receipt vector, and the `CoverBudgetError` re-export. The
resolution a row records is derived from the row's own shape, and the
capture's receipts were a second copy of what its outputs already carry.

In exchange the tick driver now reads what it runs. One debug line per
coarse cell names the cell, its resolution, how many turns it offered,
how many committed, and how many gaps it produced. The gap between
offered and committed is the number that says a batch buys one
inference and never one admission rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:32:14 +02:00
Meta Vens
71d8c536b0 Give the world a tick
Step 6 pass 9: the budgeted connected cover. Every active subject is in
the cover every tick; the budget decides at what resolution, never
whether. A singleton cell keeps the detail path and its membrane; a
grouped cell is one inference over N labeled, never-unioned constituent
views, each lowered into an ordinary one-opportunity submission verified
by its own opportunity. The kernel never sees a cell.

`world/cover.rs` owns the partition and nothing else: pure over
(world, now, opportunities, agency graph, budget), disposable, dropped at
end of tick. The fairness guarantee is arithmetic over the clock and a
total subject order rather than a second persisted memory. Adjacency is a
kernel-side projection wider than any subject view, reachable from the
tick driver and from no prompt builder.

Cut before adding: the runner's exclusive mutex and its three lock sites,
`controller_slot.take()` as the quarantine mechanism, `select`'s equation
between representation and controller mode, and pass 7's standalone
`advance_world_clock` task, which `drive_cover_tick` absorbs so one owner
holds the tick's cadence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 02:23:00 +02:00
Meta Vens
7dbbc81a3b Record pass 8 in the ontology and consumer-api docs and handoff
Current mechanism describes the two AdmitPatch authors, jurisdictional
confinement at answer and write, the tool surface as one schema spelling
per type, and the evidence-bounded repair loop; Invariant 11 and
Verification 16 carry the expansion rule; the consumer-api drift note
names pass 10's four debts. Steward folded passes 1 through 8 and the
in-flight pass 9.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:56:17 +02:00
Meta Vens
b5443d55ea Close pass 8's five Soul follow-ups
- variant_content now spells a payload-less branch, so an effect slot's
  bounds schema offers `none` alongside `quantity` and `cost` and a world
  author can declare an unbounded slot. The falsifying test is inverted
  and renamed.
- composite_example is now a projection of composite_schema, walked
  generically (first branch of every sum, minimum of every bounded
  integer, a fixed canonical string, an empty list where allowed)
  instead of a hand-written third spelling. The round-trip test checks
  every schema-derived example against serde directly, so the standalone
  tie test pinning the one known disagreement is redundant and deleted.
- The mod.rs test claiming to refuse a forged elaborator row never forged
  anything; journal.rs already carries a real version that rewrites the
  recorded caller and re-seals the commit digest. Deleted the duplicate
  with the lying name.
- Added every_declaration()/every_component_op(), each built from an
  exhaustive match over their enum that fails to compile on a new
  variant, and a test proving every exemplar is accepted by exactly one
  PATCH_TOOLS entry with counts matching the catalog (7, 28, 37) — a new
  variant can no longer ship by mapping onto an existing tool.
- Removed the dead-code-only assert_declarations_exhaustive/
  assert_operations_exhaustive helpers and their unfulfilled
  #[expect(dead_code)]; their logic now lives in the exemplar builders
  above and is actually exercised.

world:: 250 passed (was 252; two tests deleted as redundant/duplicate,
one test replaced net-neutral). cargo fmt clean. No new warnings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 01:55:41 +02:00
Meta Vens
3ed3868939 Falsify pass 8's boundary elaboration lane
Soul's tests only. Fifteen checks against the landed shapes: the
confinement predicate past its declaration lane, the fifth body the
capability rule refuses, disjoint boundaries committing independently
with a spent answer refused, an unsatisfied answer through the
elaborator lane, canonical provenance surviving the ungating of
`EvidenceRef::new`, a forged elaborator commit row re-sealed against its
own chain digest so replay's authority half must answer, the emitted
tool schema against the decoder branch by branch, the round-trip
examples pinned as a second spelling, the unbounded effect slot the
catalog cannot express, the elaboration runner's resume-and-repair round
trip, a clean jurisdiction spending no inference, `controller_work.v7`
refusing `v6`, and the custody discriminator's lane.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 01:35:02 +02:00
Meta Vens
2252370af3 Let the world grow its own structure
Pass 8: the in-kernel elaborator loop, the Elaborator caller, and the
derived patch-tool catalog.

`SystemCapability` becomes internally tagged and gains
`Elaborator { jurisdiction }`. `require_patch_author` decides who may
`AdmitPatch` and returns the caller's confinement;
`confine_to_jurisdiction` decides what that caller may write. Both are
called from `reduce`'s arm and re-called from `apply_effect`'s, so replay
re-decides authority for free. `require_owner` leaves the `AdmitPatch`
path and survives for `ApproveDraft` and `ActivateWorld`.

Boundaries are covered transitively through `covers_place`; deficits are
covered by exact key equality, because a subject under nested roots counts
toward both roots' targets and a parent answering a child's row would
reduce two targets with one subject.

`patch.rs` gains `PATCH_TOOLS` and its two generators: the tool surface is
the variants a non-genesis patch may carry, with every always-refused
choice removed rather than exposed. `world/tool_schema.rs` is the one
emitter for every model-facing schema in the tree; the interpreter and
action catalogs move onto it and their `json!` literals are deleted.

`world/elaboration.rs` owns the loop: it selects a derived answer,
retrieves evidence, accumulates a draft across tool calls, submits through
`WorldMailbox::submit_elaboration`, and repairs the same draft from the
complete mismatch set. The draft is re-derived from persisted inference
outputs; only `last_mismatches` is stored, because it came from the kernel
rather than the model. Session identity is derived from the answer's
digest, so a crashed loop resumes the same store row.

Schemas bump to `elaboration.v1` and `controller_work.v7`; a prior store
is refused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 01:10:45 +02:00
Meta Vens
106e7efc3c Record pass 7 in the ontology and mvp docs and handoff
Current mechanism describes the clock, commitments, pressure, causal
boundaries, scale deficit, and the structural answer rule; the Commitment
and Pressure rows and the twenty-seven operation count follow the cut;
the mvp doc no longer names a readiness partition. Steward folded passes
1 through 7 and the in-flight pass 8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 00:38:55 +02:00
Meta Vens
5874fc232a Close pass 7's four Soul follow-ups
- verify_state_shape now enforces the 1..=MAX_ROUTE_COST span bound on a
  routine's period, the same bound TickMinutes::new enforces, closing the
  serde-transparent forgery gap. Inverted and renamed the falsification test
  to soul_a_zero_period_is_refused_by_the_shape_check.
- Extracted advance_world_clock's per-tick body into submit_clock_tick, a
  narrow port over submit_clock, and added a test that captures the
  submitted span without driving tokio's timer.
- Removed the dead top-level PreconditionRef/PressureSourceRef imports in
  world/mod.rs; clock_tests keeps its own.
- Renamed JurisdictionKey::Unplaced to Uncovered and fixed the scale deficit
  to count a positioned subject under no jurisdiction root in that row
  instead of dropping it, with a test asserting the row counts sum to the
  subject count.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon world:: -> 217 passed
cargo test -p ghostlight-dungeon --bin ghostlight-dungeon -> 251 passed
cargo fmt -p ghostlight-dungeon -- --check -> clean
2026-09-05 00:37:55 +02:00
Meta Vens
1852ddd896 Soul pass 7: bind the boundary digest to its own structure
A tick leaves an UnelaboratedDestination's digest where it is, and
closing the route it names moves it while the predicate still holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:23:00 +02:00
Meta Vens
5771ce6c49 Soul pass 7: falsify the clock, answers, pressure, and the store shape
Ten falsification tests over pass 7's claims: the attention order's id
tiebreak under every input permutation, the Active answer ruling
(component-only commits, evidence must answer, the gate precedes the
resolver, a satisfied answer clears its boundary), pressure saturation
and underflow, a clock-only tick moving no scope digest, scale intent as
genesis-only, discharge of a foreign or discharged key, one routine roll
per command, derive_motion purity plus tick replay identity, the replay
gate over a forged motion, and the TickMinutes store gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:21:58 +02:00
Meta Vens
2a8cec6b0a Falsify the clock, commitments, pressure, boundaries, and scale
Twenty-two tests over the pass-7 surface: the tick's three stages and its
re-derivation at apply and at replay, the clock capability's single admitted
body, every commitment shape check as one complete sorted set, the attention
order's totality and its refusal to filter, boundary derivation and the
answer-must-be-satisfied rule, and the scale deficit that only admitted
subjects reduce.

Two defects the tests found and this commit fixes: PressureSource mixed struct
and newtype variants under an internal tag, which cannot serialize a scalar
newtype, so it is adjacently tagged; and Motion carried an is_empty no caller
outside a test used.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 00:07:49 +02:00
Meta Vens
d7fba90c71 Give the world a clock, commitments, pressure, boundaries, and scale
Ontology widening pass 7. The world acquires a fictional clock advanced by
exactly one command body; promises acquire a due date; an unmet promise becomes
stored pressure; pressure orders whose turn it is without deciding whether a
turn exists; and the places the world has not grown into become derivable
rather than noticed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:54:50 +02:00
Meta Vens
76999ded14 Record pass 6 in the ontology doc and handoff
Current mechanism describes facts, channels, knowledge, speech fan-out
over the declared reach, the two homes of a statement, and the
controller port; the Channel row and Precondition block follow the cut.
Steward folded passes 1 through 6 and the in-flight pass 7.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:51:42 +02:00
Meta Vens
54e9d39113 Close the six pass-6 Soul follow-ups
- audience() no longer folds a channel's controller into its own
  audience: a controller may still can_broadcast() from outside its
  reach, but it gains no fan-out knowledge and is not a CanReach target
  unless it actually stands inside the declared reach.
- Correct the "one home" doc claims: the statement lives in
  facts[fact].statement (the readable home) and is duplicated inside
  the committed AssertClaim effect as a replay witness only; no
  projection reads the event copy.
- ControllerRunner now holds a narrow ControllerPort instead of a whole
  WorldMailbox, forwarding exactly the five requests a controller lane
  makes. operator_log has no method on that type, so it is a compile-time
  boundary rather than a pub(crate) convention.
- Document why resolve_patch's candidate knowledge map safely skips
  modeling Communicate's fan-out (AuthoredSource has no Told variant),
  and pin that invariant with a test on the one ComponentOpKind lowering
  site that could otherwise drift.
- Add a test proving a stale controller_work.v4 row is refused at open.
- Length-prefix derive_id's discriminator so Some("") and None (and any
  other handle/discriminator split) cannot collide; derived IDs change
  as a result, which the v5 schema bump already requires callers to
  accept.

Baseline 178 world / 213 crate; now 181 world / 216 crate, cargo fmt
clean.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 23:50:47 +02:00
Meta Vens
cb6a126025 Soul pass 6: falsify knowledge, channels, and speech fan-out
Nine test-only additions in world::soul_knowledge_tests. Two pin behaviour
that differs from the specified shape rather than endorsing it:

- a channel's controller is folded into `audience` itself, so it receives
  every telling on its own channel even when standing outside the declared
  reach;
- the utterance is stored twice, in `facts[fact].statement` and again in the
  committed event's own `AssertClaim` effect.

The rest confirm the gates: every speech precondition rejects at `exercise`
with the whole state byte-identical, a forged wider audience or appended
telling dies in `apply_effect`, an unheard utterance moves no scope digest
including the speaker's, the same-patch telling-then-forget asymmetry is
fail-closed only, an orphan claim is a Draft shape unreachable in Active,
replay is exact across all six new arms twice over, and `operator_log` is
unscoped by construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:19:10 +02:00
Meta Vens
b8ff18282e Give the world Knowledge, Channels, and Fact standing
Speech stops being a broadcast event everyone can read and becomes a typed
claim that some subjects hold. Three partitions carry it: `facts` (one row per
`EntityKind::Fact`, write-once), `channels` (one row per `EntityKind::Channel`),
and `knowledge`, which is the sole owner of who-heard-what.

`state.events` is demoted from perception surface to causal record. The events
log records what was done; the knowledge partition decides what a subject may
act on and what its projection may contain. `WorldSnapshot.events` is deleted
outright, so `controllers.rs` structurally cannot read an unscoped log; the
human operator's story feed moves to `WorldKernel::operator_log`, reached
through its own mailbox request and passed to `eve::authenticated_surface`
beside the snapshot.

Two pure functions state reach and nothing else does: `audience` (who is in
earshot) and `fan_out` (who gains knowledge from one telling). Co-location is
the actor's exact place; a channel's `Reach::Place` is the subtree, through the
same `covers_place` walk a jurisdiction uses. A telling never overwrites a
holder and `Believed` is fixed by the kernel, so no speaker owns another
subject's credence.

The kernel Speak entry gains its real shape: one `CanBroadcast` precondition, so
an unplaced subject fails with `NoAudience` rather than being heard by everyone.
A `carries_speech` entry must name exactly one audience, and `exercise` prepends
two kernel-owned operations no slot or band can influence: `AssertClaim` mints
the fact, `Communicate` fans it out. That makes `action::exercise` the second
`derive_id` call site; the "only `admit_resolved` allocates" rule is rewritten
rather than amended.

The kernel does not compare two statements. There is no dedup, no interning, no
contradiction check, and no similarity. Deception is a `Claimed` fact standing
beside its asserter's own knowledge of a different one, and the meaning of that
gap belongs to a reader.

`STATE_SCHEMA`/`COMMIT_SCHEMA` become `.knowledge.v1` and the controller work
schema moves to v5; a pre-pass store is refused with no migration path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 23:02:53 +02:00
Meta Vens
43e8042d8c Record pass 5 in the ontology doc and handoff
Current mechanism describes authority, selection, and redress with the one
covering predicate, the admission-only nesting rule, and the shared
grant/revoke envelope; the selection row and operation count follow the
landed cut. Steward folded passes 1 through 5 and the in-flight pass 6
into the handoff and map.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:22:08 +02:00
Meta Vens
65ee2be41a Soul pass-5 follow-ups: overlap admission, revoke envelope, deploy coverage
Adds an admission-time check in GrantAuthority resolution that rejects a
Subject grant nested inside a same-kind PlaceSubtree grant the holder
already has, and the reverse, using the live covering predicate. This is
admission-only: targets_overlap and verify_state_shape stay structural and
position-independent, and a later Relocate that creates the same nesting
is never re-derived as a violation.

Extends the action lane's check_delegation to gate RevokeAuthority slots
by the same monotonicity envelope it already applies to GrantAuthority:
an actor may not strip authority it does not itself hold. Adds a fixture
"revoke" affordance to exercise both the covering and non-covering cases.

Adds an end-to-end test for the fixture's "deploy" affordance (Relocate
lowering) plus its precondition's negative case.

Updates the two existing tests whose assumptions depended on the old gap
(a Subject grant silently nesting inside a PlaceSubtree grant) to assert
the new rejection instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:21:22 +02:00
Meta Vens
dbe176d58b Soul: falsify the pass-5 civic gates
Seven tests, no production change. Each one asks whether a claim fails at
the gate the spec names rather than somewhere downstream that happens to
catch it.

`DelegationNotMonotone` now has a test that walks `submit`, not the
`draw_once` bench, and asserts the refused act moved no revision, no
grant, and no event. The `Restricted` rule is exercised at
`apply_operations` with a forged `ResolvedOp` that skipped the resolver,
which is the only lane the existing test did not cover. The rebind
requirement is asserted at the layer an operator sees it: a revocation on
the granter refuses the incumbent's already-bound proposal with
`ScopeChanged`, at the current revision, so nothing but the stale scope
digest can be doing the refusing. `reduce` is shown pure across every new
civic arm and equal to what the commit stored.

Two tests pin decisions the spec did not make. Structural overlap cannot
be decayed by a `Relocate`, and the store survives one; but it also does
not see a `Subject` grant sitting inside a `PlaceSubtree` grant of the
same kind, so one subject can hold two sources of one permission and
revoking the wider one leaves the act standing through the narrower. A
destination declared in the same patch as the restricted route into it is
covered by no grant, so a genesis patch cannot declare a room and walk
anyone into it in one batch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:03:15 +02:00
Meta Vens
fa3d95dca9 Give the world an Authority, Selection, and Redress subgraph
A subject stops being able to do anything to anyone it can name, and starts
needing a jurisdiction that covers the target: held directly, or lent to it
by an institution through an office it occupies.

Three partitions on `WorldState`, written only by `apply_operation` and
validated only by `resolve_patch`: `authority` maps a subject to grants of a
world-declared kind over a subject or a place subtree, `selection` maps an
institution to its offices and their incumbents, and `redress` maps a
grievance kind to a forum and its standing. Eight `ComponentOp` variants
reach them through the existing `AdmitPatch` lane, and four
payload-carrying `ComponentOpKind` variants reach them from an affordance's
effect slots.

One covering predicate, `covers`, answers every jurisdictional question:
`Precondition::Authorized`, `Precondition::HasStanding`, the delegation
monotonicity rule, `route_admits`, and the snapshot's redress projection.
`AccessKind::Restricted` now names the authority kind that opens the route,
so a restricted route admits exactly the subject holding that kind over its
destination — checked identically by `resolve_patch`'s `Relocate` arm, by
`apply_operation`'s, and by `Reachable`'s edge admission.

An office's delegated grants are copied into its incumbent's
`ScopeComponents`, so one scope digest covers the grant, the office, and the
institution's jurisdiction: revocation, reconstitution, installation, and
vacancy are all rebinds. What the actor reaches stays out, so a target that
walks out of a jurisdiction fails at `Authorized` instead.

`OverlappingJurisdiction` makes it impossible for one subject to hold two
grants of one kind over overlapping ground, so nothing ever arbitrates
between two sources of one permission; overlap between subjects is layered
government and stays legal. The reserved `actor` role binds the acting
subject, which is what makes a levy's payee unforgeable.

Six world-authored fixture entries — levy, delegate, deploy, sanction,
appoint, petition — are the worked examples. No decree and no contract:
their effect is `Commitment`, and without it either lowers to speech in an
institution's coat.

Schema moves to `ghostlight.world_state.authority.v1`; a previous store is
refused with no migration adapter.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:03:15 +02:00
Meta Vens
40e4efcc18 Make the affordance grant structural in exercise
`action::exercise` read `invocation.affordance` and looked the entry up
itself, with the grant check standing beside it in each caller. Pass 5 adds
a third caller, so the check becomes the type: `require_granted` now returns
a `GrantedAffordance` carrying the verified id and its catalog entry, and
`exercise` takes that instead of the raw id. Nothing in the pipeline reads
`invocation.affordance`, and no caller can reach it ungated.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 22:03:15 +02:00
Meta Vens
d589ea0578 Record pass 4 in the ontology doc and handoff
Current mechanism now describes passes 1 through 4; plan invariant 10
follows the scope-digest rule; steward folded the pipeline state into
the handoff and map. Emergency compaction scratch deleted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:59:26 +02:00
Meta Vens
0f21a4900c Soul: falsify pass-4 bands, the shared catalog id, and journal-side forgery
Four test-only additions, plus one two-slot fixture entry:

- `soul_only_the_selected_bands_effects_are_committed` closes verification
  contract 10's discriminating case. The existing tests prove an empty band
  commits nothing and a one-slot entry lowers what it declares; neither
  separates a slot the proposer offered from a slot the draw admitted. The new
  `carry_split` entry declares two slots on disjoint bands, both proposed
  within ceiling, and each band is reached by searching for a command id
  before the envelope is built rather than by retrying commits.
- `soul_a_forged_band_or_effect_in_the_journal_dies_at_replay` names the layer.
  `verify_state_shape` bounds a band index and refuses effects under an empty
  band; it does not re-derive the draw. The forgery is re-digested into a
  contiguous chain and dies at `apply_committed_command`'s effect equality.
- `soul_a_shared_catalog_id_is_gated_by_the_scope_grant` pins that an
  affordance id names a catalog entry every grantee shares, so grant
  membership per scope is what gates: the same `convene` id commits for its
  grantee and is denied for a controller-driven scope that does not hold it.
- `soul_a_stored_role_spec_over_an_optional_subject_kind_round_trips` proves
  the adjacently tagged `RefKind` survives the `to_vec_named` path that a
  stored `RoleSpec { kind: Subject(None) }` writes through.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:40:41 +02:00
Meta Vens
f9cd0177e7 Affordances become world-authored catalog entries with preconditions, ceilings, and bands
An action stops being an enum the kernel understands and becomes a lookup into
world-authored data: preconditions over live state, magnitudes under declared
ceilings, one deterministic band, lowered into the same ResolvedOps the patch
path already applies.

Cut first, with no compatibility path: DecisionAction and its kind(),
AffordanceKind, the AffordanceGrant struct and the (scope, kind) uniqueness
scan, affordance_slug, validated_invocation and its three call sites,
KernelError::EmptySpeech and ::AffordanceMismatch, the seven-clause equality
chain in apply_effect's DecisionExercised arm, the grant-kind comparison in
replay, speak_invocation's parallel constructor, and all three hand-written
claims about what a subject may do (the available_tools prose, the
operational_tools schemas, and the typed view's speak permission literal).

WorldState gains affordance_catalog, the sole store of what an affordance is,
written only by admit_resolved from a resolved patch and validated only by
resolve_patch. affordance_grants collapses to a set per scope, so a duplicate
grant is unrepresentable rather than checked and derive_opportunities is a map
lookup. Declaration::Affordance authors an entry; a subject grants entries by
reference, so one genesis patch declares a catalog and grants from it
atomically. Speak is the kernel-built entry under an ordinary draft handle:
zero preconditions, zero slots, one empty band, one utterance.

world/action.rs owns the pipeline behind one entry point, exercise(), called
from both reduce and apply_effect so a forged effect is re-derived by the
function that produced the honest one. It binds roles, evaluates Present,
Reachable, and Holds, checks every slot against its ceiling, draws one band
through the existing digest() owner over a named BandPreimage, lowers the
selected band's slots through the bindings, and admits them through the same
resolver. Effects ride on the event and apply through apply_operations, so an
affordance's Transfer meets the same conservation equation as a declaration.
ActionMismatch is a sibling of Mismatch, complete, sorted, and never
serialized; it surfaces as KernelError::ActionRejected.

Binding and admission are two checks, not one. Binding is scope-digest
equality, and the digest now reads the granted entries' definitions. Admission
runs at commit against live state, so a route closing mid-flight is
TargetUnreachable rather than ScopeChanged, and a proposal is not invalidated
by distant traffic.

The OperationalAgent tool catalog, its prose signature line, and the typed
view's permission block are three projections of the granted entries, naming
affordance kinds and role parameters and never an affordance id.

State, commit, and controller-work schemas bump; a pre-pass store is refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:40:41 +02:00
Meta Vens
0ca51cf90a Extract apply_operations as the one owner of operation application and conservation
The seed/apply/accumulate/check_ledger sequence was inlined in the tail of
admit_resolved, which made declaration admission the only lane able to prove
custody conservation. Lift it verbatim into apply_operations, taking the
operations and the evidence list; admit_resolved calls it after its declaration
loops. No behaviour change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:40:41 +02:00
Meta Vens
138ff69034 Bank compaction scratch; point the handoff at the Epiphany workflow lessons
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:40:02 +02:00
Meta Vens
94a87a8733 Hand the elaborator swarm to a successor; this session focuses on the kernel
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:10:49 +02:00
Meta Vens
9f0e417774 Name pass 10 (consumer ingress) and record the design pipeline for re-entry
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:05:18 +02:00
Meta Vens
bec8004bd9 Record pass 3 as landed on every steering surface
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:14:46 +02:00
Meta Vens
6a4f42a0bb Boundary doc: Ghostlight owns a conserved narrative ledger, Delvehold owns the economy
Pass 3 implemented Custody and conservation per the ontology; the Delvehold
consumer profile still assigned both to Delvehold. Transform at one-to-one
with no unit or rate field makes prices and recipes unrepresentable here,
which is the structural form of the boundary the profile wanted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:06:43 +02:00
Meta Vens
d2805fe70d Soul: falsify pass-3 conservation, the apply-side ledger, overflow, and counterparty scope
Six test-only additions, no source change.

- soul_a_mixed_patch_conserves_every_resource_it_touches: the ledger equation as
  arithmetic over committed totals across transfer + transform + consume in one
  patch, plus the admit-only opening balance. Absence stays absent.
- soul_a_forged_sequence_dies_at_the_per_operation_check_not_the_ledger: answers
  the reachability question for the apply-side check_ledger. A forged
  PatchAdmitted carrying two transfers whose second exceeds what the first left
  dies at apply_operation's own precondition, one operation early. A conserving
  forged sequence applies. from == to and from_resource == into_resource are
  refused by their arms. A forged Admit still needs its receipt.
- soul_a_holding_cannot_overflow_silently: QuantityOverflow had no landed test.
  Two admissions summing past u64::MAX are the complete verdict; nothing mints.
- soul_an_incoming_transfer_changes_only_the_counterpartys_scope: the receiver's
  bound proposal is refused with ScopeChanged; an unrelated subject's commits.
- soul_replay_is_exact_after_every_custody_operation: replay covered only
  transfer. Transform, consume, and both bind targets now ride the same restart.
- soul_a_dependency_on_an_absent_or_wrong_kind_target_is_corrupt: the dependency
  shape clauses refuse a wrong-kind target, an empty target set, and an orphan
  holder key.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon world::
  97 passed, 0 failed, 1 ignored.
cargo test -p ghostlight-dungeon: 132 passed + 1, 0 failed, 1 ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:03:59 +02:00
Meta Vens
48de96b0a4 ghostlight-dungeon: cut the duplicated incident-route scan, add Custody, Dependency, conservation, and evidenced admission
The cut first. `controllers.rs::typed_incident_routes` recomputed by hand the
same incident-route set `scope_digest` computed in `mod.rs`, two scans in two
modules held together by a comment. Both are gone. `mod.rs::scope_components`
is now the single owner of "what components does this subject's verification
read"; `scope_digest` and `snapshot` both consume it, and the controller view
reads `SubjectSnapshot.incident_routes` and resolves IDs against the snapshot.
`ScopePreimage`'s `position` and `routes` fields fold into one `components`
field. Three candidate mismatch names are refused before they exist:
`CustodyNotHeld` (absence is zero, so it is the boundary case of
`InsufficientCustody`), `DependencyTargetKind` (a target of the wrong kind is
`WrongKind`), and `DependencyDuplicate` / `DependencyNotBound` (an operation
that changes nothing is `NoOperationEffect`, which pass 2 already owns).

The addition.

- `WorldState` gains `holdings: BTreeMap<SubjectId, BTreeMap<EntityId,
  Quantity>>` and `dependencies: BTreeMap<SubjectId, BTreeSet<
  DependencyTarget>>`: subject-keyed partitions following `positions`, not
  `EdgeRecord` variants. `EdgeRecord`, `EdgeKind`, `ResolvedRoute`, and the
  seven accessors are untouched. Absence is zero at both levels, so no stored
  `Quantity(0)`, no empty inner map, and a duplicate holding is unrepresentable
  because a map key is unique.
- `Quantity(u64)`: unsigned, no unit, no maximum. Subtraction is `checked_sub`
  and fails as `InsufficientCustody`; addition is `checked_add` and fails as
  `QuantityOverflow`. No saturating arithmetic.
- `ComponentOp` and `ResolvedOp` gain `Transfer`, `Transform`, `Consume`,
  `Admit`, `Bind`, `Release`. `Transform` is 1:1 by construction: one `qty`
  spent and gained, so a ratio has nowhere to be written. No `ResolvedOp`
  carries a resulting balance; every arm re-derives its precondition from the
  committed partitions at apply time.
- `Mismatch` gains `InsufficientCustody`, `ZeroQuantity`, `QuantityOverflow`,
  `AdmitWithoutEvidence`, `SelfDependency`, and `CustodyNotConserved`, appended
  after the pass-2 variants so single-item assertions keep their ordering.
- `patch::check_ledger` is the one statement of conservation:
  `after == before + admitted + gained - consumed - spent`, over `u128`
  accumulators. `resolve_patch` seeds a fourth candidate map beside containers,
  routes, and positions, records each admitted operation's terms, and verdicts
  the ledger after the operation loop and before the emptiness gate.
  `admit_resolved` re-derives the same equation over the committed partitions,
  so a forged `PatchAdmitted` that reaches `apply_effect` without passing the
  resolver dies with `Invariant("custody does not conserve")`. `Transfer`
  contributes to no term: the equation proves it.
- Evidenced admission. `EvidenceRef` gains a constructor and crosses the
  facade. An `Admit` is valid when its ref is canonical text and the identical
  ref appears in the same patch's `evidence` list; otherwise
  `AdmitWithoutEvidence`. The kernel checks no store and no digest — retrieval
  and receipt authenticity belong to the retrieval organ — but the ref rides
  inside the digested, hash-chained commit, so it cannot be altered or dropped
  afterwards. There is no holdings declaration field: `Admit` with evidence is
  the only creation path, in every lane including genesis. Evidence is
  Draft-only, so `Admit` is Draft-only in this pass, with no second gate.
- Replay shape. Every `holdings` key names a canonical subject, every inner key
  a `Resource` entity at a nonzero quantity, no inner map empty; every
  `dependencies` key names a canonical subject with a non-empty set of
  canonical, distinct targets.
- Snapshot and typed view. `WorldSnapshot` gains `resources`;
  `SubjectSnapshot` gains `holdings`, `dependencies`, and `incident_routes`,
  all the acting subject's own. `typed_view` gains a `holdings` block with
  labels resolved only for what this subject holds, and a `dependencies` block
  carrying target kind and ID with no label and no target state. The
  hand-written `available_tools` string is unchanged; the derived tool catalog
  is a later pass.
- Schema bump to `ghostlight.world_state.custody.v1` /
  `ghostlight.world_commit.custody.v1` and the matching journal rows. A
  pre-pass store fails `recover` with an unadmitted row type. No migration
  adapter.

Deviations from the Imagination spec, each decided here:

- `resolve_place` generalizes into `resolve_entity(expected_kind)` rather than
  gaining a `resolve_resource` sibling. Two eight-line wrappers around one body
  bought nothing; the four place call sites pass the kind.
- Spec test 1 asks for a forged `Transfer` whose debit and credit differ.
  `ResolvedOp::Transfer` carries one `qty`, so that is unrepresentable — which
  is the outcome the shape was chosen for. The test proves the reachable
  forgery (a transfer the holder cannot cover leaves `holdings` byte-identical)
  and falsifies `check_ledger` directly beside it.
- Spec test 14 asserts against `verify_state_shape` rather than
  `verify_history`. Mutating a committed holding also breaks replay equality,
  so routing through `verify_history` would pass even with the shape clauses
  deleted.
- Spec test 11's visible half lives in `controllers.rs`, where
  `SelectedDecision` is, extending the existing typed-view non-leakage test;
  its kernel half is the world-level test of the same name.
- `EvidenceRef::new` is `#[cfg(test)]`. No `AdmitPatch` ingress exists yet, so
  every caller that builds a patch today is a test; the gate comes off in the
  same commit as the first production patch author. The type itself crosses the
  facade, as `ComponentOp::Admit` requires.

No external-owner field, no unit, rate, or recipe field anywhere, no new crate
or dependency, no compatibility shim.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon world::
  91 passed, 0 failed, 1 ignored (75 before this commit).
cargo test -p ghostlight-dungeon: 126 passed + 1, 0 failed, 1 ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:03:59 +02:00
Meta Vens
db8eb0b2a7 Pass-2 follow-ups: dead presence check, RefKind derives, stamped world_id, stale imports and comment
Four cuts Soul named on pass 2, none of them behaviour changes.

- runtime.rs: delete the `source_version` presence check in
  `admit_controller_command`. It called `.ok_or_else(...)?` and discarded the
  value, so it validated nothing. The `expected_revision = source_version - 1`
  computation on the approve/activate path is untouched; that one still needs a
  caller-supplied binding.
- world/patch.rs: drop `Serialize`/`Deserialize` from `RefKind`.
  `RefKind::Subject(Option<SubjectKind>)` is an internally tagged newtype over a
  non-map, which serde refuses at runtime, so the derives were unexercised
  weight. `RefKind` is never persisted: it appears only in `Mismatch` (not
  serializable), the resolution index, and the three kind constants. Soul's
  `soul_ref_kind_is_not_a_serializable_shape` cannot compile as a negative
  without the derives it falsifies, so it is deleted; the doc comment on
  `RefKind` now carries the rule it was proving.
- world/mailbox.rs: document that `submit_stamped` stamps `world_id` as well as
  `revision`, and why — the mailbox owns exactly one world, so there is no other
  world a controller runner could mean. The consequence is that
  `WorldKernel::submit`'s `WorldMismatch` check is unreachable on this path;
  `soul_a_stamped_submission_still_fails_closed_on_a_foreign_scope` stays as the
  proof that scope still fails closed.
- world/mod.rs: `ComponentOp` and `RouteDeclaration` are test-only, so their
  import moves behind `#[cfg(test)]`.
  ghostlight-persona-projection: `opportunity_digest` no longer claims to bind
  "world state as one value"; pass 2 made it bind the scope digest.

cargo test -p ghostlight-dungeon --bin ghostlight-dungeon world::
  75 passed (76 minus the deleted RefKind test), 1 ignored.
cargo test -p ghostlight-dungeon: 110 passed + 1, 1 ignored.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 19:03:59 +02:00
Meta Vens
53f35afc31 Record pass 2 as landed on every steering surface
Handoff, map, scratch, and the ontology doc's current-mechanism section now
describe e99af63. The elaborator witness is corrected to 26 finished slot
passes. The worktree the steward saw at 8cad6b9 is the pass-3 Hands tree
before its reset to the tip, not an orphan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:15:30 +02:00
Meta Vens
e99af63f9a Soul: falsify the pass-2 scope binding, topology admission, and replay
Ten test-only additions, no production source touched.

- mailbox: the end-to-end proof the kernel-level test does not give — a
  controller turn bound at revision N commits through the mailbox after an
  unrelated commit at N+1, and a stamped body still fails closed on a
  foreign scope.
- world: the scope preimage excludes revision, phase, a place declared
  elsewhere, a non-incident route, and another subject's Position, across
  real commits; a forged affordance list cannot widen authority.
- patch: RouteSelfLoop, UnplacedSubject, and NoOperationEffect are each
  reachable; a forged relocate effect dies at apply time on all three
  preconditions; RefKind is not a serializable shape.
- journal: forged store rows carrying a self-loop, an out-of-range cost, a
  non-place endpoint, or a stray position are refused by recover, and a
  pre-topology row type is refused outright.
- controllers: the typed view carries only the actor's own place and the
  routes incident to it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:10:34 +02:00
Meta Vens
24addfb21e ghostlight-dungeon: cut authority_scope and whole-world binding, add topology and scope digests
Cut: SubjectDeclaration.authority_scope and SubjectState.authority_scope with
their four production readers; DecisionOpportunity.state_digest and every reader
of it, including the kernel's whole-world gate, ControllerRunner::select's
struct-equality filter, PersonaTurnBinding.state_digest, the two runtime.rs
surface-freshness comparisons, and the caller-supplied envelope revision for
opportunity-bearing commands; EdgeKind's Relation, Commitment, and Pressure arms.

Add: a positions partition and Position, EntityRecord.container with containment
acyclicity over the complete candidate graph, Route written into the existing
edges partition with AccessKind, Cost, and an open flag, the four inhabited
ComponentOp variants, and resolve_patch as the one owner of declarations,
references, topology admission, and operation preconditions, closing the complete
mismatch set before the first ID mints. A proposal now binds the digest of the
components its verification reads -- controller assignment, grants, Position, and
incident routes -- so an unchanged scope commits at a later revision and a changed
one is refused with KernelError::ScopeChanged. Schema bumps to topology.v1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 18:10:34 +02:00
Meta Vens
489913f171 Scheduling paragraph states scope-digest binding, not stale-discard
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:50:59 +02:00
Meta Vens
0f010de431 Record the four first-generation fixtures and the pass-2 worktree
Handoff and map carry the four world-branch tips and fixture commits, the
retired single-loop launcher, the live 32-slot scheduler, ontology tip
5e53beb with its follow-ups landed, and pass 2 in flight.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:22:45 +02:00
Meta Vens
5e53beb353 ghostlight-dungeon: one schema owner, genesis mismatch not journal fault, named ref fields
- Promote world::STATE_SCHEMA/COMMIT_SCHEMA to pub(crate) and add
  state_schema_compatibility_tag(), derived from STATE_SCHEMA, so mesh.rs and
  idunn_health.rs stop hand-copying "ghostlight.world_state.foundation.v0" and
  "foundation-v0" as second literals that had already drifted from the v1
  kernel schema.
- A CreateWorld patch that declares only entities now fails inside
  resolve_declarations with Mismatch::NoDecisionSubject instead of surviving
  admission and dying in journal::verify_state_shape as a
  CorruptJournal(requires_owner_restart) fault. Collapse the two hardcoded
  admits_human literals in prepare_creation and WorldState::genesis onto the
  same admits_human predicate the AdmitPatch lane uses (reshaped to take
  revision/subjects directly, since the genesis lane resolves before any
  WorldState exists).
- Mismatch::UnresolvedDraft and the draft-branch WrongKind now name their
  DraftHandle field referent; Mismatch::UnknownCanonical and the
  canonical-branch WrongKind now name theirs referrer. WrongKind carries both
  referrer: DraftHandle and referent: Ref<EntityId> so every construction site
  states which end each field names instead of overloading one `handle`
  field with two different meanings.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:13:10 +02:00
Meta Vens
f447f585ca Record pass 1 as landed on every steering surface
Handoff, map, scratch, and the ontology doc's current-mechanism section now
describe HEAD: patch.rs owns identity and closed resolution, AdmitPatch is
the Draft ingress, schemas are v1/v2, and the three Soul follow-ups are named
with their exact lines. Evidence records the sandbox lesson that supersedes
the clone-plus-network one: .git is read-only inside the writable root, so a
supervisor outside the sandbox commits and pushes per slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:05:48 +02:00
Meta Vens
4d95bb8cbd Ignore agent worktrees 2026-09-04 17:02:53 +02:00
Meta Vens
9ddc21f048 Soul: prove the store and apply_effect halves of closed resolution
Two falsification tests the pass-1 suite did not carry.

a_rejected_patch_leaves_no_row_and_no_id_in_the_store checks the durable
store, not the in-memory head: after the Run 115 rejection it scans the
.cc bytes for the three IDs the patch would have derived, then reopens
the world and re-asserts the snapshot, commit count, and empty entity
partition.

apply_effect_rejects_a_forged_stale_unauthorized_or_active_patch_effect
drives the PatchAdmitted arm directly. journal::a_forged_patch_effect_
does_not_apply dies earlier, at apply_committed_command's reduce
equality ("committed effect is not the deterministic reduction of its
command"), so nothing exercised admit_resolved's own re-validation. This
does: a jurisdiction naming an absent place, the honest effect replayed,
a non-owner caller, and the Active phase each return Invariant with the
candidate state untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 17:00:45 +02:00
Meta Vens
d74d6ad3d7 Close patch resolution before any canonical ID allocates
Cut: DraftSubjectHandle, validate_handle, NewDecisionSubject,
GenesisSubjectBinding, canonicalize_subjects, issue_genesis,
normalize_label, assignment_matches_new, the 116-line zip-and-compare
body of WorldState::genesis, the non-test SubjectId/ControllerId/
AffordanceId::issue bodies, and the KernelError variants NoSubjects,
EmptySubjectHandle, EmptySubjectLabel, DuplicateSubjectHandle, and
NoAffordances.

Add world/patch.rs: typed EntityId/EdgeId namespaces beside SubjectId,
Ref<Id> with draft handles, WorldPatch declarations, uninhabited
ComponentOp and PatchAnswer, and resolve_declarations as the one owner
of handle indexing, kind checking, reference resolution, and allocation
for both admission lanes. It returns the complete deterministic
Vec<Mismatch> and allocates only after that set is empty, so a rejected
patch mints nothing. IDs derive from sha256 over namespace, world ID,
command ID, and handle, which is what lets journal replay recompute
reduce() and require effect equality.

CommandBody::AdmitPatch and WorldEffect::PatchAdmitted join the reducer;
WorldCreated now carries the same ResolvedPatch payload, and both lanes
mutate through one admit_resolved. WorldState gains entities and edges
partitions, SubjectState gains authority_scope, and the state and commit
schema strings move to foundation.v1 and foundation.v2, which refuses a
pre-pass store rather than migrating it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:47:15 +02:00
Meta Vens
807ba24a89 Eight elaborators; the earlier line was already nine passes 2026-09-04 16:23:26 +02:00
Meta Vens
09a48ba191 Nine passes, not eight, in the handoff gate 2026-09-04 16:23:15 +02:00
Meta Vens
1e18c4f8e0 Record the pass-1 fan-out and the live test baseline
Handoff and map say pass 1 is in progress through faculty workers with maps
in the session scratchpad and nothing landed in crates; the baseline is
Speak-only with 43 world tests and 83 crate-wide at d87cad8.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:23:06 +02:00
Meta Vens
d87cad84c6 Record the clone-not-worktree lesson for Codex fan-outs
Linked worktrees put .git outside the Codex sandbox's writable roots; the
four world workers all died on index.lock. Handoff, map, and one evidence
record now carry the clone-plus-network rule and the relaunch witnesses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:19:17 +02:00
Meta Vens
5599c164f3 Carry the scale directive into every steering surface
Handoff, map hard boundaries and scale checkpoint, and plan invariant 8 now
all state both truths: structural validity never waits on a count, and the
seed's WorldScaleIntent with its cover-budget choke is the operator's design
the elaborators pursue. The handoff carries the dated operator directive so a
future session cannot re-cut it as fixture noise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:15:58 +02:00
Meta Vens
4ae1d6181d Restore the scale target as design, not load fixture
The operator's design is detail first, then a ten-percent choke: generate
goal-bearing actors at every level, then budget cells so attention is scarce
and selection is forced. The mvp doc's teardown-era sentence calling the
2,400-subject profile "test data, not a production elaboration target"
contradicted that and is amended. The ontology now carries an authored
WorldScaleIntent with a structurally counted deficit that elaborators pursue
after boundaries, a budgeted connected cover every tick with singleton detail
cells and grouped coarse cells, partitioned views and per-constituent
attribution in groups, and debt rotation. Gestalt as canonical identity,
arena knowledge union, and semantic qualification stay deleted. Plan step six
is nine passes; two proofs added; evidence records the correction.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:14:44 +02:00
Meta Vens
362c7539cc Name the scale mechanisms the gestalt cut kept
The multiresolution demotion undersold what survives. Coarse-by-construction
populations, deterministic clocks, debt-ordered scheduling, and batched
runner-owned representation with per-constituent attribution all carry many
subjects on few inferences without a second identity layer. Routine
commitments auto-fulfill on Time.advance so unattended ordinary life does not
default into spurious pressure. Two proofs added.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:08:01 +02:00
Meta Vens
dd23d7fcec Retire the revision-bound rule from active steering
The steward found stale-means-discard still asserted as the opportunity rule
in map.yaml live_architecture and plan invariant 11. Both now state
scope-digest binding as the adopted rule and name foundation.v0 as still
revision-bound until plan step 6 pass 2. Handoff and next_action carry the
v2 shape and the live worker witnesses.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:03:42 +02:00
Meta Vens
34ce8c8a63 Unify elaboration and action under one deterministic verifier
Ontology v2. Affordances become world-authored data over a closed
twenty-nine-operation kernel set: preconditions read the actor's own typed
components, effect slots bound what an invocation may propose, and kernel
entropy selects the outcome band so no model ever decides success. That
deletes the legacy wave-level outcome resolver along with the reconciler.
Proposals bind to the scope digest of the components their verification
reads instead of the whole state digest, so eight elaborator loops and many
subjects stop serializing on one revision. Elaborator tools are a projection
of the operation set and cannot drift from the reducer.

Demotes the multiresolution agency doc to teardown evidence, keeping only
resumable sessions, compact drafts, and the complete mismatch set. Plan step
six now lists eight passes with the affordance pipeline fourth.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 16:01:27 +02:00
Meta Vens
dc474be660 Correct re-entry steering after the fan-out
The steward found the handoff pointing at legacy Persona runners that do not
exist and a next gate that no longer matched the operator's orders; both
surfaces now carry the elaboration-ontology order, the world fan-out layout,
and the deferred deployment gate. The BFL script defaulted to a key path on
the lost E: drive. The consumer API doc is marked as drift pending rewrite
against the ontology.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:58:55 +02:00
Meta Vens
9c662bda23 Design the elaboration ontology
Run 115 died on a dangling reference, and the vocabulary is why. Legacy
transition.rs took caller-supplied SubjectRef{kind,id:String}, admitted
entities in two phases, and permitted mutations one at a time in a flat
batch, so a plausible-looking id was indistinguishable from a referent until
reduction. Reconciliation existed to repair that class and exhausted after
four steps. Depth lived in free strings, so the previous attempt to fix
shallowness grew model tribunals instead of structure.

The closed vocabulary: reducer-issued typed ID namespaces, draft-handle
references resolved inside one closed WorldPatch before any ID allocates,
twelve components that each constrain a decision, four derived CausalBoundary
kinds, and one AdmitPatch command shared by seed admission and boundary
elaboration. Twenty component kinds become twelve, eighteen mutation variants
become one primitive, five qualification and verification types become none,
and waves and quotas become derived boundaries.

Demotes the transition algebra doc to teardown evidence and slots the stage
into the implementation plan ahead of the deferred deployment cutover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:39:17 +02:00
Meta Vens
99dc8b0ef9 Record the swarm triage
Every repo is clean and pushed. Two findings matter for the deployment gate:
Odin's recipe and the gamecult-ops operator bindings still declared gitlinks for
submodules the consolidation removed, so Idunn would have refused to admit those
targets. Both corrected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:17:04 +02:00
Meta Vens
cbb8a5c95a Odin consumes CultLib
27cc815 lands the repoint. The in-flight CultMesh work came with it because the
old CultMesh calls a CultNet method CultLib does not have, so it could not build
against CultLib at all -- established by reverting and watching it fail, not
assumed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 14:56:04 +02:00
Meta Vens
d324da18bb CultLib main carries the consolidation
Landed by fast-forward at 8cb3b72. All four consumer pins verified as ancestors
of origin/main, so the revisions Ghostlight, Epiphany, CodexConnector and Odin
depend on resolve from a fresh clone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 13:02:22 +02:00
Meta Vens
de8c5d327f Record the publishing decision for the non-Rust consumers
The TypeScript and Python forks existed because nothing was publishable, not
because the code diverged. CultLib now has a publish workflow, and the licensing
that would have blocked it is uniform.

Ghostlight's own repoint has a catch worth remembering: the state tooling puts
the vendored source on sys.path so it runs from a clone with no install step,
and session bootstrap depends on that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:42:08 +02:00
Meta Vens
3f66a83869 Record the swarm-wide core-stack consolidation
Epiphany and CodexConnector now consume CultLib and carry no submodules, so
GameCult/cultcache-rs and GameCult/cultnet-rs have no readers left anywhere.

The survey's main finding is that the forks held far less than their number
suggested. Four of them held nothing CultLib lacks. What they mostly held was
reach: things CultLib had but did not expose. Each had simply pinned a different
branch of the standalone repos, which is the explosion that made separate repos
untenable in the first place.

The two remaining non-Rust forks are blocked on distribution, not divergence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:27:22 +02:00
Meta Vens
37dfb25929 Record the Odin repoint and the closed cultnet-rs gate
Odin no longer reads GameCult/cultnet-rs. Its CultNet submodule and CultCache
copy are gone; both come from CultLib, and vendor/cultmesh-rs is the only fork
left, itself consuming CultLib so one crate version spans the graph.

The forks were not pure duplication. A whole-file symbol comparison -- not a
diff of recent changes, which is what misled me the first time -- found four
capabilities CultLib lacked, all now ported with tests.

The Odin side is verified but uncommitted. Its one-line edit to
vendor/cultmesh-rs/src/lib.rs cannot be separated from 433 lines of in-flight
CultMesh work and an untracked module, so committing the repoint would mean
committing that feature on the operator's behalf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 04:03:44 +02:00
Meta Vens
c80c323022 Re-anchor contract citations on CultLib 85f7024
Every live CultNet contract citation named `f4ab237`, which is not an ancestor
of CultLib main and survives only on the retiring standalone repo's branch.
They now name CultLib `85f7024`, which is both CultLib's current main head and a
permanent ancestor of it, so the citation survives every future advance --
something a branch SHA in a repo queued for deletion could never do.

Verified rather than assumed. At `85f7024`, packages/cultnet-rs carries
IDUNN_LIFECYCLE_BRAKE_* distinct from IDUNN_DEPLOYMENT_BRAKE_*,
IdunnProcessWriteLeaseRecord, GameCultRuntimeCapability, OdinTopologyDisagreement
with disagreements() and into_undisputed_present separating presence from
readiness, a generation-bound IdunnRuntimeActivationRecord with its own
activation-proof signing purpose, and IdunnExpectedRoute refusing non-rudp://
endpoints for RUDP routes. All six cited contracts hold.

Two of the eight citations were instructions, not descriptions, and a mechanical
swap would have been wrong. map.yaml's next action and the handoff's next gate
both said to advance Odin to CultNet `f4ab237` -- advancing it deeper into the
repo we intend to delete. Both now say to repoint Odin off its vendored CultNet
fork onto CultLib `85f7024`, as Ghostlight did at `d0510ac`.

Evidence records keep their pre-consolidation SHAs. They are dated statements of
what was believed, and the new record is the key for reading them.

This closes the citation half of the cultnet-rs retirement gate. Odin's vendored
CultNet gitlink is now the last reader of that repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:51:44 +02:00
Meta Vens
a338567230 Record the landed drift and the cultnet-rs retirement gate
Evidence for 751b5c4 and the provenance finding behind it.

While citing revisions I checked whether they resolve. CultNet `f4ab237` and its
predecessor `e542225` are not ancestors of CultLib main. Their content is on
CultLib `85f7024` -- verified by symbol, and Ghostlight compiles against it --
but the SHAs are not, because the consolidation ported commits forward instead
of merging them. They survive only on GameCult/cultnet-rs branch
codex/idunn-runtime-authority-contract.

That changes the retirement gate. Deleting GameCult/cultnet-rs was previously
gated on its content living somewhere else, which is now satisfied. It is also
gated on citation: every contract reference in this map, the handoff, both notes
and the evidence ledger points into that repo. Either re-anchor them on CultLib
`85f7024` or keep the branch.

Odin `65cf2b2` and `d527d72` are likewise off Odin main but sit on a pushed
branch, so they resolve from a clone and need nothing.

The vendor-retirement record carried a hand-written timestamp ten hours in the
future, which sorted it after work that actually came later. Corrected through
the store to 7e73504's commit time; the export follows. All 27 records intact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:28:04 +02:00
Meta Vens
322cc116b0 Land the Idunn bootstrap rules and retire two superseded revisions
The system map and implementation plan carried uncommitted prose describing
Idunn's independent bootstrap, the Odin-outage freeze rule, and the nine-phase
durable transaction. That content is good and is landed here.

It cited CultNet `e542225` and Odin `d527d72`, which map.yaml and the handoff had
already advanced past to `f4ab237` and `65cf2b2`. A bare SHA bump would have been
wrong: both successors own strictly more. `f4ab237` adds routed RUDP incarnation
admission, and `65cf2b2` already lands the durable three-record transaction and
admitted-generation engine that this prose still described as the remaining cut,
along with the systemd isolation, Ready-provider selection, and protected
activation delivery it called uncommitted. The claims are corrected, not just
renumbered.

map.yaml carried the same superseded pair on its Idunn foundation line while its
contract line named the successors, so the canonical map disagreed with itself.

Evidence records keep their original revisions. That ledger says what was
believed when it was written, and editing it would falsify the history it exists
to preserve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:26:08 +02:00
Meta Vens
751b5c4150 Take runtime identity, bind, and state root from Idunn
The daemon declared its own GHOSTLIGHT_RUNTIME_ID, GHOSTLIGHT_DUNGEON_BIND,
and GHOSTLIGHT_DUNGEON_ROOT. Under the managed contract Idunn owns all three:
the runtime id arrives inside the activation bundle, the bind is a candidate
socket Idunn hands down, and the state root is an admitted workload path.
A service that also names them from its own environment is a second opinion
about facts it does not own.

runtime.rs already resolved the runtime id from the admission and kept
GHOSTLIGHT_RUNTIME_ID only as the unmanaged fallback. This is the caller side
catching up: main.rs accepts --state-root and passes it through, and the recipe
declares the state_root argument binding plus the three GAMECULT_IDUNN_*
variables while dropping the three the daemon no longer reads.

The binding is required, not cosmetic. Idunn refuses a stateful workload with
no state_root argument binding and refuses one whose binding disagrees with
the admitted root, so the recipe could not have deployed without it.

The acceptance harness kept its own GHOSTLIGHT_RUNTIME_ID; it is renamed to
GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID so a test-supplied identity cannot be mistaken
for the managed one.

The mesh surface test compared the published record against a freshly built
surface, including updatedAtUtc, which is a live timestamp. It now asserts the
structural fields, parses the timestamp, and compares surface and commands.

cargo test --workspace: 89 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:24:08 +02:00
Meta Vens
7e73504819 Record the vendored fork retirement
Ghostlight consumes the core stack from CultLib 85f7024 rather than holding
its own generation of the wire contracts outside the parity harness.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:13:55 +02:00
Meta Vens
d0510ac85b Consume the core stack from CultLib instead of forking it
vendor/README.md declared cultcache-rs, cultnet-rs and cultmesh-rs "a locally
owned compatible protocol generation". That charter is the defect. A consumer
holding its own generation of the wire stack sits outside the only harness that
checks cross-runtime agreement, and the fork behaved exactly as that predicts:
runtime_authority_contracts.rs drifted to e542225 while this repo's own
authority map named f4ab237, and the Rust CultMesh implementation existed
nowhere else in GameCult.

All three now come from CultLib pinned at 85f7024, and 19,604 lines of forked
substrate are deleted. Nothing was lost on the way out. The vendored cultnet
tree was compared file by file against CultLib: ten of twelve matched, lib.rs
differed only in module declarations, and transport.rs carried one guard that
existed nowhere else -- a framed length checked against the advertised maximum
before allocating. That guard is now CultLib 85f7024 with a test.

The migration this was expected to need does not exist. RuntimePresenceCorrelation
changing from enum to struct is Odin's side of the contract and no file here
touches it; with line endings normalised the two contract copies differ by 132
lines, and the only production change that reaches a provider is
IdunnExpectedRoute requiring rudp:// schemes for rudp transport, which this
service does not use. The code compiles unchanged.

89 tests pass across the workspace against the pinned rev.

vendor/ still holds the cultcache-py and eve submodules. cultcache-py is the
same pattern -- a runtime-specific repo CultLib also carries as
packages/cultcache-py -- but it backs the state tooling rather than the
daemon, so it is left for its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:13:24 +02:00
Meta Vens
5d852b76b0 Warn that evidence.jsonl edits are silently reverted
The existing rule said evidence.jsonl is an export surface rather than the
direct-write API, which is true but understates the failure. A hand edit does
not fail loudly; it survives until the next add-evidence regenerates the file
from the CultCache evidence document and restores whatever was removed.

An agent compacting the ledger would see a clean result, commit it, and have
it silently undone by the next append. Say so where the rule already lives,
since AGENTS.md is read before anyone reaches the store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 00:28:50 +02:00
Meta Vens
79911a9ecc Record core stack ownership and the vendored fork
CultLib owns CultCache, CultNet and CultMesh for every runtime because wire
parity conformance lives only there. Ghostlight's vendor/README.md charters a
locally owned protocol generation, which is duplicate authority over the wire
stack held outside the harness that checks it -- and is why
runtime_authority_contracts.rs forked on whether Present may coexist with a
disagreement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:45:59 +02:00
Meta Vens
ca7d7366e9 Wire AGENTS.md into Claude Code memory
Claude Code reads CLAUDE.md, not AGENTS.md, so this repo's doctrine was
invisible to it. This one-line import makes the existing AGENTS.md load
without duplicating or renaming it. Codex is unaffected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 22:22:51 +02:00
Meta Vens
138b459ff8 Compact the Mind and purge the workspace
The evidence ledger had drifted from distilled to accumulated at 258 records.
235 were per-release and per-run receipts, self-consuming falsification chains,
and May fixture doctrine already owned by docs/architecture and map
hard_boundaries. They move to state/evidence.archive.jsonl.

evidence.jsonl is a regenerated export, not a writer: _export_evidence rewrites
it from the CultCache evidence document on every append. Compacting the export
alone would resurrect every archived record on the next add-evidence, so the
deletion goes through the store and the export follows.

Twenty-three records remain: the still-binding contracts (CultCache wire
format, agent_state.v0, cognition ceiling, image-geometry authority, sandboxed
coordinator, VoidBot appraisal, multiplayer extension, the Delvehold boundary
set, newspaper-as-Persona) and the whole rebuild era from Run 108 forward. A
multiplayer note recorded twice under different timestamp encodings is reduced
to one.

map.yaml and the handoff carried stale frontiers naming 13d5136 and a0b16b9.
Both now name 6a79cb0 and record what 9256648 and 6a79cb0 landed.

The worktree sheds 87MB of untracked debris: three Git bundles whose commits
are all ancestors of HEAD, ~60 legacy live-fire run scripts and sftp batches,
the forbidden redeploy helpers, downloaded run61-63 acceptance copies whose
authoritative roots remain on Yggdrasil and whose frozen copy stays digest-bound
in output/tex, a redb file created by a stray --help argument, and 71MB of tmp/
legacy campaign caches and newspaper renders. .gitignore now refuses all of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 21:56:19 +02:00
Meta Vens
6a79cb093a Republish Warming while awaiting a write lease 2026-09-03 10:39:04 +02:00
Meta Vens
92566480bb Expose managed Ghostlight route presence 2026-09-03 09:25:32 +02:00
Meta Vens
a0b16b927b Bind Ghostlight runtime presence to Idunn authority 2026-09-03 06:36:58 +02:00
Meta Vens
1569d3f141 Declare Ghostlight Idunn deployment recipe 2026-09-02 21:43:26 +02:00
Meta Vens
36760710db Correct Idunn launch and source authority map 2026-09-02 21:31:52 +02:00
Meta Vens
8d42a959ec Record landed Idunn authority foundation 2026-09-02 21:15:38 +02:00
Meta Vens
d959057527 Retire stale source-cut steering 2026-09-02 16:23:40 +02:00
Meta Vens
bed213cfff Persist the Idunn deployment authority rebuild 2026-09-02 16:11:39 +02:00
Meta Vens
13d5136b69 Unify Ghostlight persistence under one CultCache 2026-09-02 15:00:48 +02:00
Meta Vens
06093b00fc Derive projector fixture event identity 2026-09-02 13:52:14 +02:00
Meta Vens
27d72b819f Keep narrative projection free of authority metadata 2026-09-02 13:51:27 +02:00
Meta Vens
6bb6869810 Rebuild Ghostlight around explicit world authority 2026-09-02 07:21:11 +02:00
Meta Vens
f9f019b970 Advance rebuild cutover state 2026-09-01 19:50:52 +02:00
Meta Vens
66ed2ec167 Complete sealed world authority loop 2026-09-01 19:37:13 +02:00
Meta Vens
12ee9f4622 Remove dead legacy kernel ingresses 2026-09-01 19:32:33 +02:00
Meta Vens
3856c4ad71 Advance rebuild plan to private reducer 2026-09-01 18:27:24 +02:00
Meta Vens
b998e6a998 Mark sealed world boundary landed 2026-09-01 18:11:25 +02:00
Meta Vens
875c3e9d02 Record total interpretation contract 2026-09-01 16:40:48 +02:00
Meta Vens
d49b907e83 Make Persona interpretation structurally total 2026-09-01 16:37:41 +02:00
Meta Vens
0a57e85f0e Advance rebuild beyond the sealed owner 2026-09-01 16:35:15 +02:00
Meta Vens
b53251f94f Seal the replacement world owner 2026-09-01 16:18:15 +02:00
Meta Vens
d5837af663 Record public teardown archive 2026-09-01 15:55:13 +02:00
Meta Vens
566aa80490 Demote obsolete public pipeline map 2026-09-01 15:09:55 +02:00
Meta Vens
a34ddf9524 Seal rebuild authority before ontology 2026-09-01 15:07:58 +02:00
Meta Vens
5fe22dab76 Refresh rebuild steering state 2026-09-01 14:38:01 +02:00
Meta Vens
a144c33661 Make cognition modes explicit and interpretation total 2026-09-01 14:26:47 +02:00
Meta Vens
64f50de596 Delete obsolete pipeline smokes 2026-09-01 14:22:07 +02:00
Meta Vens
4aebe17a24 Adopt whole-machine authority rebuild 2026-09-01 14:18:42 +02:00
Meta Vens
8964a92cb4 Route destination drafting to balanced model 2026-09-01 13:27:03 +02:00
Meta Vens
4d330d921e Bound destination generation context 2026-09-01 12:53:27 +02:00
Meta Vens
fc46c3ba61 Bound destination compile latency 2026-09-01 12:06:47 +02:00
Meta Vens
0a830347dc Bound foundation compile latency 2026-09-01 11:08:39 +02:00
Meta Vens
661cfbad9f Rebuild qualified world elaboration 2026-09-01 09:54:04 +02:00
Meta Vens
7174703f6f Record Delvehold world-shape audit 2026-09-01 05:14:25 +02:00
Meta Vens
37b1362860 Add focused campaign world explorer 2026-09-01 04:53:55 +02:00
Meta Vens
b81a9779f1 Make strategic target projection deterministic 2026-09-01 03:56:17 +02:00
Meta Vens
67421ada42 Bound strategic cell agent context 2026-09-01 03:34:25 +02:00
Meta Vens
b61e762c98 Page Nemesis causal attention context 2026-09-01 02:40:45 +02:00
Meta Vens
8dd3147066 Keep compaction journals structurally compact 2026-09-01 02:04:56 +02:00
Meta Vens
8cdbd2e3a0 Expose exact compaction preflight failures 2026-09-01 01:57:59 +02:00
Meta Vens
75d9235352 Supersede duplicate parallel identities 2026-09-01 01:41:15 +02:00
Meta Vens
799be9914b Align elaborator compaction bounds 2026-09-01 01:28:57 +02:00
Meta Vens
f3d5951ac3 Shrink individuation to model-owned fields 2026-09-01 01:10:13 +02:00
Meta Vens
dd7fb98e73 Keep public names out of identity authority 2026-09-01 00:53:08 +02:00
Meta Vens
fa01658686 Expose exact individuation repair findings 2026-09-01 00:37:06 +02:00
Meta Vens
3f7211b9df Derive elaboration realms from topology 2026-09-01 00:16:29 +02:00
Meta Vens
31125a2307 Preserve fission realm scope across elaboration 2026-08-31 23:57:59 +02:00
Meta Vens
44c89e39df Preserve named actors across population fission 2026-08-31 23:32:06 +02:00
Meta Vens
0fd1559781 Reconcile parallel fission child identifiers 2026-08-31 23:07:42 +02:00
Meta Vens
5582f61061 Compact elaborator sessions concurrently 2026-08-31 22:52:28 +02:00
Meta Vens
b4cc3a29aa Bound fission relation lineage lookup 2026-08-31 22:40:16 +02:00
Meta Vens
ca64b9cf1b Bind titled resume to historical commit proof 2026-08-31 22:25:14 +02:00
Meta Vens
92d6742546 Follow fission lineage when resuming foundations 2026-08-31 22:20:20 +02:00
Meta Vens
36bcc842cf Reconcile resumed campaigns before validation 2026-08-31 22:12:01 +02:00
Meta Vens
8a81ea2c1d Reconcile civic bindings across gestalt fission 2026-08-31 22:04:19 +02:00
Meta Vens
74ea14db56 Record Run 80 fission custody evidence 2026-08-31 21:28:49 +02:00
Meta Vens
e7ceb2c280 Require exact fission custody assignments 2026-08-31 21:28:00 +02:00
Meta Vens
e6076f0343 Report complete fission validation findings 2026-08-31 21:17:43 +02:00
Meta Vens
a01773743a Materialize complexity commands from compact drafts 2026-08-31 21:06:08 +02:00
Meta Vens
7ce721d483 Bind complexity proposals to frozen assignments 2026-08-31 20:32:01 +02:00
Meta Vens
97e00be867 Wrap complexity actions for provider schemas 2026-08-31 20:21:58 +02:00
Meta Vens
fbbaf453b8 Add resumable world complexity elaboration 2026-08-31 20:10:26 +02:00
Meta Vens
df66ec989c Define iterative world complexity pressure 2026-08-31 19:14:26 +02:00
Meta Vens
228e02dca5 Record public architecture reference completion 2026-08-31 17:26:27 +02:00
Meta Vens
15ea0f0c5e Publish comprehensive architecture reference 2026-08-31 17:21:25 +02:00
Meta Vens
86fe6ed89d Bind newsroom conflicts to opposing moves 2026-08-31 15:49:06 +02:00
Meta Vens
b6787d909b Bind newsroom conflicts to cited parties 2026-08-31 14:35:43 +02:00
Meta Vens
b827d897b9 Keep legacy newspaper receipts byte stable 2026-08-31 12:25:51 +02:00
Meta Vens
ec45952b84 Preserve legacy newspaper audit rendering 2026-08-31 10:06:26 +02:00
Meta Vens
84b31e57a4 Give assignment editors a weekly ledger directory 2026-08-31 09:46:49 +02:00
Meta Vens
69c811a266 Reject invalid optional individuations before wave assembly 2026-08-31 05:06:58 +02:00
Meta Vens
39589e7fc7 Admit authorized branch-local world regions 2026-08-31 04:10:06 +02:00
Meta Vens
e878a7a731 Bootstrap full worlds through admitted region expansions 2026-08-31 04:02:10 +02:00
Meta Vens
842e9cf576 Freeze Delvehold full-world acceptance run 2026-08-31 03:35:11 +02:00
Meta Vens
eec5168d2e Separate newspaper house style from issue flow 2026-08-30 19:34:37 +02:00
Meta Vens
f7a02bc6c1 Build seeded TeX newspaper press 2026-08-30 18:30:56 +02:00
Meta Vens
1912216dd4 Retire sporadic reporter cache acceptance 2026-08-30 14:36:23 +02:00
Meta Vens
a52d57b21a Make reporter context cacheable across the newsroom 2026-08-30 14:08:54 +02:00
Meta Vens
cc8a321835 Keep reporter schemas cache-stable 2026-08-30 13:41:28 +02:00
Meta Vens
bc2ca39ab5 Give the Canopy Ledger distinct recurring voices 2026-08-30 12:55:15 +02:00
Meta Vens
b1fe2fa4f1 Take the Canopy Ledger to press 2026-08-30 12:00:36 +02:00
Meta Vens
17b309fb18 Advance Mind past accepted newsroom 2026-08-30 08:14:45 +02:00
Meta Vens
8f3c33b2d1 Preserve bounded agent cache prefixes 2026-08-30 08:12:18 +02:00
Meta Vens
139b8a69bc Bound newsroom context and resume accepted filings 2026-08-30 07:42:06 +02:00
Meta Vens
8add243ccc Allow complete copy desk objections 2026-08-30 06:47:40 +02:00
Meta Vens
26d1d878c2 Treat legacy newsroom checkpoints as inert history 2026-08-30 06:28:50 +02:00
Meta Vens
c0a001c55c Remove legacy newsroom reconciliation authority 2026-08-30 06:16:01 +02:00
Meta Vens
aaa9cf469f Allow final-only newspaper recovery 2026-08-30 05:37:07 +02:00
Meta Vens
5927cffecd Record v9 newsroom acceptance protocol 2026-08-30 05:34:38 +02:00
Meta Vens
901cfcd587 Build cacheable embodied newsroom 2026-08-29 23:48:30 +02:00
Meta Vens
98f9a9b765 Record printed Canopy Ledger run 2026-08-29 21:50:50 +02:00
Meta Vens
161ff2f190 Close newspaper once at deadline 2026-08-29 21:12:18 +02:00
Meta Vens
8014da65c3 Fix Night Editor handoff sequence 2026-08-29 19:40:19 +02:00
Meta Vens
1d0eed6051 Record Night Editor implementation checkpoint 2026-08-29 19:40:00 +02:00
Meta Vens
f8bf73d8c4 Add publication Night Editor review loop 2026-08-29 19:39:07 +02:00
Meta Vens
40186e6ccb Record the publication rewrite desk 2026-08-29 18:15:36 +02:00
Meta Vens
0c1400985c Give rejected copy back to the newsroom 2026-08-29 18:12:29 +02:00
Meta Vens
d3baf61985 Treat broad ledger as a reporting window 2026-08-29 17:01:17 +02:00
Meta Vens
ba6465702f Record deliberative newsroom checkpoint 2026-08-29 16:58:56 +02:00
Meta Vens
c34c4b1f94 Make newsroom judgment deliberative 2026-08-29 16:57:32 +02:00
Meta Vens
829751d156 Record the query-backed newsroom cut 2026-08-29 16:09:26 +02:00
Meta Vens
c454d13ecd Let the newsroom query the public ledger 2026-08-29 16:08:46 +02:00
Meta Vens
01581b8576 Record the resumable newsroom correction 2026-08-29 14:22:31 +02:00
Meta Vens
2ac74abf12 Let the newsroom correct its own news hole 2026-08-29 14:21:51 +02:00
Meta Vens
3288d9ef78 Record the newsroom archive boundary 2026-08-29 14:04:31 +02:00
Meta Vens
bd323664b7 Let the newsroom retrieve its own archive 2026-08-29 13:58:41 +02:00
Meta Vens
d42caca278 Record the newsroom v2 deployment boundary 2026-08-29 13:13:48 +02:00
Meta Vens
6a045bc332 Give the newsroom a real news hole 2026-08-29 13:11:45 +02:00
Meta Vens
420034320c Preserve accepted newspaper history across schemas 2026-08-29 12:30:52 +02:00
Meta Vens
5c07d49edb Give the Canopy Ledger narrative judgment 2026-08-29 12:04:10 +02:00
Meta Vens
6c10a216fe Record run 51 newspaper review 2026-08-29 11:06:04 +02:00
Meta Vens
ccc51dfdf9 Own empty newspaper repair seams 2026-08-29 10:43:56 +02:00
Meta Vens
8b1db89557 Normalize nested newspaper findings 2026-08-29 10:27:24 +02:00
Meta Vens
db48befa71 Persist resumable newspaper reconciliation 2026-08-29 09:42:54 +02:00
Meta Vens
dfe0926041 Budget grounding reconciliation by agent actions 2026-08-29 08:25:46 +02:00
Meta Vens
1d898489da Let grounding agents react once to copy desk 2026-08-29 08:01:28 +02:00
Meta Vens
35f56f0e5e Bind grounding repairs to typed findings 2026-08-29 07:39:03 +02:00
Meta Vens
4916638e7f Model publications as editorial Personas 2026-08-29 07:16:21 +02:00
Meta Vens
fb8f467067 Scope resumed newspaper recovery explicitly 2026-08-29 07:14:19 +02:00
Meta Vens
8a971daab8 Resume missing newspapers without replaying world waves 2026-08-29 06:56:37 +02:00
Meta Vens
19a66a49a7 Apply grounding repairs atomically by text target 2026-08-29 06:42:53 +02:00
Meta Vens
46810c31b1 Keep materialized Nemesis responders in actor cells 2026-08-29 05:40:32 +02:00
Meta Vens
92c7647014 Complete strict effect verifier schemas 2026-08-29 05:00:15 +02:00
Meta Vens
cf22dcf9a7 Allow longer strategic smoke continuations 2026-08-29 04:43:57 +02:00
Meta Vens
b040a35061 Add Nemesis causal attention and bounded grounding repair 2026-08-29 04:37:58 +02:00
Meta Vens
61620a351e Record run 39 acceptance failures 2026-08-29 02:10:49 +02:00
Meta Vens
3a69306f30 Materialize world clock consequences 2026-08-29 00:41:44 +02:00
Meta Vens
596e506792 Record run 37 acceptance checkpoint 2026-08-28 22:32:16 +02:00
Meta Vens
deaa37eee3 Let agent tools own legal action schemas 2026-08-28 22:29:15 +02:00
Meta Vens
acd544bae6 Resume strategic waves from typed cell checkpoints 2026-08-28 21:35:33 +02:00
Meta Vens
e14156228c Make Interpreter commands structurally exact 2026-08-28 20:58:07 +02:00
Meta Vens
d1729d8be5 Generalize ERP routing to causal horizon 2026-08-28 19:58:18 +02:00
Meta Vens
ed4d67ca62 Record intent-sensitive ERP budget policy 2026-08-28 19:43:52 +02:00
Meta Vens
2260665984 Compile Interpreter actions for strict providers 2026-08-28 19:31:30 +02:00
Meta Vens
c581893589 Compile reconciliation actions for strict providers 2026-08-28 19:08:50 +02:00
Meta Vens
d6a0b2ce7d Bind elaboration resume to mutation proofs 2026-08-28 18:57:47 +02:00
Meta Vens
0dab60066c Resume failed elaboration cells from durable checkpoints 2026-08-28 18:04:54 +02:00
Meta Vens
af749de610 Retry timed out model transports once 2026-08-28 17:18:35 +02:00
Meta Vens
ec153a9e87 Lower compound literals for Codex schemas 2026-08-28 16:31:04 +02:00
Meta Vens
49a0292464 Make destination reconciliation incremental 2026-08-28 16:00:55 +02:00
Meta Vens
19ceb45f29 Record exact assignment schema boundary 2026-08-28 14:44:39 +02:00
Meta Vens
007e63b337 Constrain titled agent action schemas 2026-08-28 14:36:34 +02:00
Meta Vens
cb4b118e05 Record autonomous run 28 boundary 2026-08-28 13:40:10 +02:00
Meta Vens
d71d35bb89 Validate declared civic governors 2026-08-28 13:32:16 +02:00
Meta Vens
48a894bc7d Record run 27 agent finding boundary 2026-08-28 13:00:05 +02:00
Meta Vens
d6cfda2385 Return exact titled assignment corrections 2026-08-28 12:56:33 +02:00
Meta Vens
5260ab9e96 Record run 26 persistence boundary 2026-08-28 12:28:05 +02:00
Meta Vens
8fe7a5563c Unify immutable model receipt persistence 2026-08-28 12:26:31 +02:00
Meta Vens
8cd022887e Record run 25 titled verifier failure 2026-08-28 11:49:29 +02:00
Meta Vens
975ee46139 Bind titled verification to its owned civic context 2026-08-28 11:48:02 +02:00
Meta Vens
a0bad62f6a Record run 24 projection failure 2026-08-28 11:14:46 +02:00
Meta Vens
5f559a6e67 Project exact civic fact IDs to verifier 2026-08-28 11:13:21 +02:00
Meta Vens
8469ac2223 Checkpoint provider elaboration acceptance boundary 2026-08-28 10:40:28 +02:00
Meta Vens
0654ba9070 Exercise titled elaborators through provider agents 2026-08-28 10:32:38 +02:00
Meta Vens
987d276ae0 Record titled elaboration boundary 2026-08-28 09:54:32 +02:00
Meta Vens
1c0058c175 Compile titled elaboration into world state 2026-08-28 09:47:47 +02:00
Meta Vens
1c403a9629 Advance agentic elaboration checkpoint 2026-08-28 03:21:12 +02:00
Meta Vens
71c274b0b8 Make interpretation and elaboration agentic 2026-08-28 03:19:27 +02:00
Meta Vens
c757e5a09d Separate destination evidence from agent causality 2026-08-28 01:42:47 +02:00
Meta Vens
2534737296 Record agent harness verification boundary 2026-08-28 01:14:19 +02:00
Meta Vens
842e265f29 Add provider-neutral model agent harness 2026-08-28 01:11:18 +02:00
Meta Vens
3ffcb0a6be Record run 20 failure and run 21 dispatch 2026-08-27 23:51:23 +02:00
Meta Vens
965c9faadc Centralize civic reconciliation correction 2026-08-27 23:48:17 +02:00
Meta Vens
3c2852b7ba Record autonomous Elven run 20 dispatch 2026-08-27 23:00:54 +02:00
Meta Vens
2875e45b3a Freeze institution identity during civic reconciliation 2026-08-27 22:49:29 +02:00
Meta Vens
3bd1279e2a Reconcile strategic outcomes without replaying waves 2026-08-27 22:14:42 +02:00
Meta Vens
8ee836f86b Expose exact civic profile repair findings 2026-08-27 20:29:13 +02:00
Meta Vens
4d960188cd Centralize bounded destination reconciliation 2026-08-27 20:13:52 +02:00
Meta Vens
6e9e7043d9 Constrain civic manifest schema discriminator 2026-08-27 19:35:28 +02:00
Meta Vens
2427f24010 Use balanced civic reconciliation for rejected destinations 2026-08-27 19:05:56 +02:00
Meta Vens
ecd3d6c304 Recheck rejected civic verifier verdicts 2026-08-27 17:27:35 +02:00
Meta Vens
0cb4445ef0 Route strategic compiler through logical model classes 2026-08-27 17:05:58 +02:00
Meta Vens
78f3d67c17 Give capable connector stages their provider window 2026-08-27 16:53:07 +02:00
Meta Vens
d78c2060ba Reject provider-incompatible ref siblings 2026-08-27 16:29:54 +02:00
Meta Vens
31eae440bb Keep the player boundary out of world cast 2026-08-27 15:47:26 +02:00
Meta Vens
f678732b39 Report exact civic compiler closure defects 2026-08-27 15:20:38 +02:00
Meta Vens
cfbbe30de2 Bind strategic elaboration to a civic budget 2026-08-27 15:01:26 +02:00
Meta Vens
9218569dfa Elaborate sparse worlds before strategic waves 2026-08-27 14:47:13 +02:00
Meta Vens
b55588530c Retry strategic cells without replaying siblings 2026-08-27 13:45:37 +02:00
Meta Vens
eac179fce8 Compile sparse worlds for strategic newspaper smokes 2026-08-27 12:43:10 +02:00
Meta Vens
02fcc7380f Ground newsroom claims in asserted public facts 2026-08-27 11:38:18 +02:00
Meta Vens
a585398319 Project public events into a clean newsroom desk 2026-08-27 10:46:43 +02:00
Meta Vens
2f7c0903ff Keep newspaper rejection outside world authority 2026-08-27 09:29:47 +02:00
Meta Vens
b1f0d7bd77 Keep newsroom clocks out of editorial copy 2026-08-27 09:08:42 +02:00
Meta Vens
3a6b639940 Let the copy desk distinguish fact from voice 2026-08-27 08:39:03 +02:00
Meta Vens
5b99ce0c7f Let the copy desk kill bad articles 2026-08-27 08:16:29 +02:00
Meta Vens
2b0dd9e625 Preserve failed newspaper receipts in smoke 2026-08-27 07:53:46 +02:00
Meta Vens
1fcc2ff4cd Preserve attempted action uncertainty 2026-08-27 07:30:10 +02:00
Meta Vens
2d6d441e92 Give grounded copy two repairs 2026-08-27 07:14:23 +02:00
Meta Vens
e33018eed3 Type copy desk findings 2026-08-27 07:00:54 +02:00
Meta Vens
92328b89c1 State the editorial source partition 2026-08-27 06:53:42 +02:00
Meta Vens
e84872e512 Build a grounded editorial newspaper 2026-08-27 06:37:43 +02:00
Meta Vens
5f8a1cc65a Record sustained Rainless newspaper acceptance 2026-08-27 05:26:45 +02:00
Meta Vens
5e00a7bbc3 Remove hidden institution posture ceiling 2026-08-27 05:01:57 +02:00
Meta Vens
b98ca9abc3 Align internal gestalt coordination authority 2026-08-27 04:41:47 +02:00
Meta Vens
dc5787e8fd Bound canonical institution posture 2026-08-27 04:17:49 +02:00
Meta Vens
8f6ef233fd Keep semantic repair inside the failing cell 2026-08-27 03:40:44 +02:00
Meta Vens
0e9ae792fb Allow repeated scoped strategic activities 2026-08-27 03:24:09 +02:00
Meta Vens
07f8283d76 Print the inciting public scandal 2026-08-27 02:45:27 +02:00
Meta Vens
e011854be2 Make strategic newspapers worth reading 2026-08-27 02:38:08 +02:00
Meta Vens
2fc3f81068 Keep canonical headlines in world state 2026-08-27 02:22:02 +02:00
Meta Vens
80ad2593ff Run strategic politics across sustained waves 2026-08-27 02:13:11 +02:00
Meta Vens
a7c4b3a18a Record Codex-backed world newspaper witness 2026-08-27 00:34:10 +02:00
Meta Vens
8940efca0f Carry canonical place identity into cell verification 2026-08-27 00:09:06 +02:00
Meta Vens
41f921cc47 Route strategic smoke through runtime provider 2026-08-26 23:36:33 +02:00
Meta Vens
a266933ed9 Compile sparse worlds into living politics 2026-08-26 23:03:43 +02:00
Meta Vens
f189fc855a Prove strategic emergence at hundreds-cell scale 2026-08-26 20:09:49 +02:00
Meta Vens
63e73c6c9e Admit externally sovereign Gestalt subjects 2026-08-26 19:13:50 +02:00
Meta Vens
0898087353 Add generic Ghostlight world consumer boundary 2026-08-26 19:08:13 +02:00
Meta Vens
1d860e2ae2 Reframe Delvehold as Ghostlight API consumer 2026-08-26 17:55:26 +02:00
Meta Vens
a4080d4dcf Project inherited member knowledge into transitions 2026-08-26 17:38:12 +02:00
Meta Vens
d97107df35 Separate RUDP reliable and lossy sequence domains 2026-08-26 17:17:32 +02:00
Meta Vens
e692e2f5eb Project Persona self-authored history 2026-08-26 16:16:57 +02:00
Meta Vens
3238b7f08d Reject uncommitted recipient knowledge 2026-08-26 15:37:24 +02:00
Meta Vens
2ae539a073 Require concrete canonical observations 2026-08-26 15:24:05 +02:00
Meta Vens
f2799f46ec Preserve destination identity before expansion 2026-08-26 15:07:55 +02:00
Meta Vens
3f69ff0a9c Fix place custody transition witness 2026-08-26 14:18:32 +02:00
Meta Vens
2a649c7584 Preserve custody for installed strategic work 2026-08-26 14:16:57 +02:00
Meta Vens
f72d766f0e Narrate canonical travel and Persona arrivals 2026-08-26 13:59:11 +02:00
Meta Vens
aea56c0118 Clarify compiler branch elaboration authority 2026-08-26 13:43:33 +02:00
Meta Vens
f582180212 Prune dead strategic schema definitions 2026-08-26 13:34:52 +02:00
Meta Vens
35fa5175f0 Preserve mixed-audience strategic speech 2026-08-26 13:21:05 +02:00
Meta Vens
b46aba526e Align composite strategic event witness 2026-08-26 13:12:19 +02:00
Meta Vens
84b1b0755f Align strategic event witnesses 2026-08-26 13:08:56 +02:00
Meta Vens
3bfd2cdb29 Bind strategic agency before inference 2026-08-26 13:04:09 +02:00
Meta Vens
b37bb0534b Update strategic scheduler fixture contract 2026-08-26 12:35:33 +02:00
Meta Vens
de5061e497 Bind one strategic decision per subject 2026-08-26 12:32:59 +02:00
Meta Vens
9fcec5dc70 Retain strategic actor intent 2026-08-26 12:14:24 +02:00
Meta Vens
ed2948db08 Align Session Zero with branch elaboration 2026-08-26 11:30:08 +02:00
Meta Vens
3fdd7b598e Update outcome contract witness 2026-08-26 11:17:20 +02:00
Meta Vens
f649d09a26 Project strategic knowledge communication 2026-08-26 11:15:38 +02:00
Meta Vens
81147ab795 Verify every material strategic outcome 2026-08-26 10:51:03 +02:00
Meta Vens
d4b9ced9e6 Separate strategic means from restraint 2026-08-26 10:35:05 +02:00
Meta Vens
e17b9d01f0 Normalize inactive outcomes and trace native delivery 2026-08-26 10:23:08 +02:00
Meta Vens
67d20fabad Project complete strategic actions to verifier 2026-08-26 10:03:30 +02:00
Meta Vens
348c22425e Scope strategic coordination verification by owner 2026-08-26 09:48:28 +02:00
Meta Vens
a835e27571 Bind fixture waves to projected detail focus 2026-08-26 09:21:06 +02:00
Meta Vens
a776e55e2d Require exact strategic cell decisions 2026-08-26 09:17:58 +02:00
Meta Vens
ac49bb8de7 Update Ghostlight live rehydration state 2026-08-26 08:01:49 +02:00
Meta Vens
fdb6352e0d Separate branch elaboration from compiler gaps 2026-08-26 07:47:26 +02:00
Meta Vens
38d6fab96f Fix Gestalt migration test mutation 2026-08-26 07:24:33 +02:00
Meta Vens
f1fee54bed Preserve canonical Gestalt member identity 2026-08-26 07:16:14 +02:00
Meta Vens
dab3612a4c Bind coordination targets to strategic owner kind 2026-08-26 06:51:55 +02:00
Meta Vens
0cce177341 Keep semantic corrections in verifier authority 2026-08-26 06:42:58 +02:00
Meta Vens
a26266f8a2 Let semantic correction repair meaning 2026-08-26 06:39:25 +02:00
Meta Vens
cc7f2e8efa Bind connector concurrency to connector port 2026-08-26 06:29:43 +02:00
Meta Vens
e5345ede4b Queue connector work at admitted capacity 2026-08-26 06:27:35 +02:00
Meta Vens
a506f6bae1 Retry transient model transport failures 2026-08-26 06:08:21 +02:00
Meta Vens
7ea969fa84 Update strategic ordering contract witness 2026-08-26 05:52:01 +02:00
Meta Vens
b252e0506c Preserve strategic outcome source for ordering 2026-08-26 05:49:40 +02:00
Meta Vens
f2c51a10fc Derive strategic course order from locations 2026-08-26 05:48:20 +02:00
Meta Vens
3e86fb95b2 Align strategic text limits with Eve schema 2026-08-26 05:15:27 +02:00
Meta Vens
3e74d15df0 Make evidence coverage corrections exact 2026-08-26 04:38:33 +02:00
Meta Vens
bea2196496 Repair global agency extraction retries 2026-08-26 04:32:50 +02:00
Meta Vens
62fa904d5a Plan bounded retrieval from approved contracts 2026-08-26 04:21:36 +02:00
Meta Vens
63e560103f Fix approved contract serialization 2026-08-26 04:08:58 +02:00
Meta Vens
9991c8cd86 Retrieve approved canon horizons before compilation 2026-08-26 04:05:10 +02:00
Meta Vens
cfdcbd902b Deduplicate strategic wave receipt evidence 2026-08-26 03:18:58 +02:00
Meta Vens
bebb1e6ca4 Document DELVE/HOLD forced ontology boundary 2026-08-26 03:07:36 +02:00
Meta Vens
05a0ac2dda Expose exact attributed schema failures 2026-08-26 02:51:01 +02:00
Meta Vens
75c6064593 Prove stable strategic lane semantics 2026-08-26 02:34:03 +02:00
Meta Vens
4cc75cb306 Stabilize strategic effect lane schemas 2026-08-26 02:30:54 +02:00
Meta Vens
2501021afe Separate strategic contact from publication 2026-08-26 02:19:11 +02:00
Meta Vens
ac6363b4fb Make keyed retry witness structurally valid 2026-08-26 02:05:44 +02:00
Meta Vens
e17149c513 Fix keyed retry schema witness 2026-08-26 02:01:22 +02:00
Meta Vens
d6fe594870 Bind correction retries to keyed activity sets 2026-08-26 01:55:09 +02:00
Meta Vens
12d7503a46 Respect unordered strategic activity sets 2026-08-26 01:46:49 +02:00
Meta Vens
21fa2c0054 Project current state into counter replacements 2026-08-26 01:33:58 +02:00
Meta Vens
adb35faa6c Align strict activity return schema 2026-08-26 01:31:11 +02:00
Meta Vens
4db7f579d5 Key strategic means by activity 2026-08-26 01:25:02 +02:00
Meta Vens
ecc775ff66 Make duplicate strategic means unrepresentable 2026-08-26 01:05:44 +02:00
Meta Vens
877a685ee1 Retain member authority across compound means 2026-08-26 00:55:03 +02:00
Meta Vens
f343f2376e Compose strategic activity means 2026-08-26 00:53:03 +02:00
Meta Vens
557adb8896 Admit cohesive internal coordination 2026-08-26 00:31:21 +02:00
Meta Vens
606ca2b95b Exercise duplicate action validation below the cell budget 2026-08-26 00:19:11 +02:00
Meta Vens
4cffb6a652 Place duplicate action guidance in cell correction 2026-08-26 00:16:12 +02:00
Meta Vens
f8f2236609 Compose one strategic choice per subject 2026-08-26 00:14:26 +02:00
Meta Vens
7b1d511410 Verify strategic outcomes per selected action 2026-08-26 00:07:29 +02:00
Meta Vens
97f470004b Remove impossible cell activity choices 2026-08-25 23:59:10 +02:00
Meta Vens
f967d860ee Retry omitted Persona decisions at their source 2026-08-25 23:51:48 +02:00
Meta Vens
589d23fe43 Fix destination compiler type import 2026-08-25 23:33:04 +02:00
Meta Vens
837cf512c1 Admit playable destination populations 2026-08-25 23:25:18 +02:00
Meta Vens
13c386f6d1 Localize model authority slices 2026-08-25 22:34:17 +02:00
Meta Vens
8e48f7bb7b Distinguish material compiler gaps from playable texture 2026-08-25 21:56:55 +02:00
Meta Vens
27da7acf14 Reuse inline schema for shared Session Zero decisions 2026-08-25 21:20:04 +02:00
Meta Vens
ada87b6a21 Make Session Zero synthesis and relationship proposals compositional 2026-08-25 21:17:32 +02:00
Meta Vens
0f527bb1ad Preserve approved counterpart state in private compilation 2026-08-25 21:02:15 +02:00
Meta Vens
06df9a903a Include cohesive Gestalts in foreground reaction waves 2026-08-25 20:45:15 +02:00
Meta Vens
1eba8d8238 Compile approved Session Zero contracts as typed context 2026-08-25 20:16:17 +02:00
Meta Vens
4ee494c4f2 Keep relationship patch entries required 2026-08-25 19:57:47 +02:00
Meta Vens
eba0735a93 Make Session Zero relationship patches strict-schema representable 2026-08-25 19:54:58 +02:00
Meta Vens
c5dafbb6ef Expose host-owned Session Zero archival 2026-08-25 19:28:46 +02:00
Meta Vens
7c42517ad8 Update Vault schema catalog witness 2026-08-25 18:30:26 +02:00
Meta Vens
eb1f119286 Bind world compilation to typed lore Vaults 2026-08-25 18:26:28 +02:00
Meta Vens
984aecf02a Exercise export beneath public mount 2026-08-25 18:03:58 +02:00
Meta Vens
93c3a09cd0 Expose exact campaign exports through Eve 2026-08-25 18:01:04 +02:00
Meta Vens
64f183e753 Keep addressed Gestalt members in the originating turn 2026-08-25 17:26:53 +02:00
Meta Vens
cabf209c10 Stabilize campaign inspection receipts 2026-08-25 16:40:47 +02:00
Meta Vens
3f30ce9daf Record hosted campaign inspector witness 2026-08-25 16:39:22 +02:00
Meta Vens
0b4ea3e242 Keep outcome provenance in its typed lane 2026-08-25 16:19:46 +02:00
Meta Vens
a098515a92 Update strategic correction witness 2026-08-25 16:12:07 +02:00
Meta Vens
7137b64b0d Clarify strategic inspection effects 2026-08-25 16:10:19 +02:00
Meta Vens
dff41b255e Keep emergent Persona identities distinct 2026-08-25 15:41:42 +02:00
Meta Vens
63d3b45910 Preserve failed deployment ancestry 2026-08-25 15:06:01 +02:00
Meta Vens
32ff501b2e Correct actor Persona semantics once 2026-08-25 15:02:17 +02:00
Meta Vens
fd191b8a16 Correct actor Persona semantics once 2026-08-25 14:51:09 +02:00
Meta Vens
740ecf0b52 Admit bounded foreground observations 2026-08-25 14:06:01 +02:00
Meta Vens
f1aa14618e Keep existing campaigns reachable from Session Zero 2026-08-25 13:04:58 +02:00
Meta Vens
2177165faa Record live branch-elaboration acceptance 2026-08-25 12:39:19 +02:00
Meta Vens
e89db8bcad Compile playable branch truth within canon constraints 2026-08-25 12:11:48 +02:00
Meta Vens
5083972c87 Record grounded Mars compiler acceptance 2026-08-25 05:35:05 +02:00
Meta Vens
bd4758894c Degrade unsupported remote doctrines to evidence gaps 2026-08-25 05:23:29 +02:00
Meta Vens
f0cb79ecfb Correct nullable fission schema witness 2026-08-25 05:09:22 +02:00
Meta Vens
18e528388d Remove dynamic maps from strict model outputs 2026-08-25 05:03:00 +02:00
Meta Vens
6147948512 Preserve compiler routes across strict model schemas 2026-08-25 04:50:42 +02:00
Meta Vens
0695b19b42 Ground topology correction in explicit routes 2026-08-25 04:30:01 +02:00
Meta Vens
486f34f3ea Rejoin opening-topology release lineage 2026-08-25 03:26:50 +02:00
Meta Vens
a45242a752 Require traversable opening topology 2026-08-25 03:11:18 +02:00
Meta Vens
6628a904c0 Require traversable opening topology 2026-08-25 03:09:26 +02:00
Meta Vens
13f3a12453 Correct evidence-bound institution doctrine 2026-08-25 02:51:24 +02:00
Meta Vens
9dd30da190 Record native Session Zero acceptance state 2026-08-25 02:26:51 +02:00
Meta Vens
44eec9d654 Treat Vault availability as an external blocker 2026-08-25 02:16:26 +02:00
Meta Vens
e39c4b7f46 Surface Vault outages as Session Zero gaps 2026-08-25 02:08:44 +02:00
Meta Vens
4435bcb6f4 Bound approved party identity input 2026-08-25 01:55:24 +02:00
Meta Vens
f786e30f8f Preserve composite Session Zero counters 2026-08-25 01:40:43 +02:00
Meta Vens
0b3bd6a1a7 Inline Session Zero provider schemas 2026-08-25 01:17:08 +02:00
Meta Vens
a914e6d722 Align Session Zero schema fixture 2026-08-25 01:04:06 +02:00
Meta Vens
56cb74f649 Bind Session Zero interpreters to channel authority 2026-08-25 00:56:25 +02:00
Meta Vens
8bf62bc33b Prevent duplicate gestalt individuation 2026-08-25 00:34:41 +02:00
Meta Vens
d1f2a88419 Preserve adopted identities across gestalt materialization 2026-08-25 00:11:03 +02:00
Meta Vens
8e7d98051e Pin shared CodexConnector contract 2026-08-24 23:55:58 +02:00
Meta Vens
f7395e0c46 Align visible effect test helper 2026-08-24 23:26:42 +02:00
Meta Vens
cc660b85f2 Admit voluntary commitments in action outcomes 2026-08-24 23:23:57 +02:00
Meta Vens
9f8cf2e30f Format strict array witness 2026-08-24 22:55:16 +02:00
Meta Vens
44a35adb95 Close empty strategic array schemas 2026-08-24 22:55:07 +02:00
Meta Vens
045e25bdd1 Make strategic effect lanes structural 2026-08-24 22:49:54 +02:00
Meta Vens
4bba382158 Retire contradictory strategic lane test 2026-08-24 22:21:23 +02:00
Meta Vens
7a301af5ae Align strategic effect admission before commit 2026-08-24 22:19:22 +02:00
Meta Vens
e9b78f9e88 Pin bounded connector diagnostics 2026-08-24 20:46:41 +02:00
Meta Vens
858fc16ef4 Pin corrected Codex provider lowering 2026-08-24 20:30:49 +02:00
Meta Vens
034ca9a5e4 Format connector schema witness 2026-08-24 20:18:18 +02:00
Meta Vens
a0ccf9a339 Assert nullable strict enum projection 2026-08-24 20:18:08 +02:00
Meta Vens
b3388e0c3b Use independent CodexConnector transport 2026-08-24 20:08:36 +02:00
Meta Vens
2f6da84b7f Record deferred initiative live acceptance 2026-08-24 13:41:10 +02:00
Meta Vens
4057e11dc2 Defer NPC initiative beyond foreground response 2026-08-24 13:26:26 +02:00
Meta Vens
bb5948c048 Short-circuit impossible action assessments 2026-08-24 12:49:26 +02:00
Meta Vens
032e0ff9d4 Record live route continuity acceptance 2026-08-24 11:56:36 +02:00
Meta Vens
06ed2c5ac2 Make unanimous governance finalization retryable 2026-08-24 11:42:04 +02:00
Meta Vens
fc5cce0a21 Preserve location-local route identity 2026-08-24 11:27:43 +02:00
Meta Vens
101251f2be Expose campaign transition receipts for acceptance 2026-08-24 11:27:42 +02:00
Meta Vens
99a250879f Make scoped action mutations provider-expressible 2026-08-24 10:51:36 +02:00
Meta Vens
1f2a1c9a26 Inline assessment scope lane schema 2026-08-24 10:05:39 +02:00
Meta Vens
2811944f93 Scope foreground action mutations 2026-08-24 09:56:49 +02:00
Meta Vens
179ad2c930 Preserve long assessment receipts 2026-08-24 09:25:41 +02:00
Meta Vens
732584f1e3 Verify foreground assessment effects 2026-08-24 09:00:04 +02:00
Meta Vens
bfc4d64fb3 Record live assessment cache proof 2026-08-24 08:33:03 +02:00
Meta Vens
b8d3fe69ee Stabilize repeated action assessments 2026-08-24 08:10:15 +02:00
Meta Vens
fb9c42c266 Bind direct speech to exact Persona responses 2026-08-24 07:33:48 +02:00
Meta Vens
ebd8bc6d9f Expose bounded live-turn diagnostics 2026-08-24 07:04:52 +02:00
Meta Vens
1028139d41 Project exact committed story turns 2026-08-24 06:46:36 +02:00
Meta Vens
cd4f712b1f Bind NPC initiative to one reaction wave 2026-08-24 06:30:07 +02:00
Meta Vens
fe26d3d882 Shape assessment effects from current authority 2026-08-24 06:22:09 +02:00
Meta Vens
686dccff7d Bind gestalt individuation to direct player speech 2026-08-24 06:04:14 +02:00
Meta Vens
9e60c516ae Compose distinct strategic mutations within effect lanes 2026-08-24 05:16:37 +02:00
Meta Vens
79dcb16dc6 Make strategic effect lanes structurally singular 2026-08-24 05:04:18 +02:00
Meta Vens
9169874bd3 Bind strategic verifier receipts per action 2026-08-24 04:23:33 +02:00
Meta Vens
25071a31f9 Treat neutral outcome optionals as absent 2026-08-24 04:10:07 +02:00
Meta Vens
e2051c73fa Constrain strategic outcome definition directly 2026-08-24 04:01:15 +02:00
Meta Vens
a210b6444d Preserve action ownership in model corrections 2026-08-24 03:54:55 +02:00
Meta Vens
f0a66a0e95 Emit CultCache connector invocation shape 2026-08-24 03:41:09 +02:00
Meta Vens
2e5ad82325 Route inference through Epiphany connector 2026-08-24 02:28:03 +02:00
Meta Vens
12f7e5f561 Do not gate daemon startup on inference 2026-08-24 00:31:18 +02:00
Meta Vens
5cf4ff8512 Scope cell verification to each acting subject 2026-08-24 00:21:19 +02:00
Meta Vens
a81f7c7adc Let strategic actors address human players 2026-08-23 23:44:23 +02:00
Meta Vens
48f4c1630c Separate native turn wait budget 2026-08-23 23:19:05 +02:00
Meta Vens
f05367b953 Retry transient OpenRouter admission 2026-08-23 23:07:20 +02:00
Meta Vens
ba78082658 Migrate effect validator fixtures 2026-08-23 22:50:23 +02:00
Meta Vens
25d1d7cd66 Compose strategic Persona effects atomically 2026-08-23 22:48:32 +02:00
Meta Vens
25c430eed8 Ground strategic Personas in exact agency 2026-08-23 22:02:33 +02:00
Meta Vens
2c79500b65 Fix resource custody regression witness 2026-08-23 21:51:13 +02:00
Meta Vens
2a4f4ec3f5 Scope legacy resource identity by custodian 2026-08-23 21:49:50 +02:00
Meta Vens
d77f94f49c Preserve materialized Gestalt actor authority 2026-08-23 21:40:59 +02:00
Meta Vens
72c3dced2d Record native auth custody repair 2026-08-23 21:14:54 +02:00
Meta Vens
447f4027c3 Preserve native authentication attempts 2026-08-23 21:01:19 +02:00
Meta Vens
a6bbea91ce Record native CultMesh player boundary 2026-08-23 20:55:31 +02:00
Meta Vens
801d4c537e Add Heimdall-backed native CultMesh play boundary 2026-08-23 20:29:45 +02:00
Meta Vens
929a841501 Record reliable discovery deployment 2026-08-23 19:54:55 +02:00
Meta Vens
7522ea8405 Repair large CultMesh RUDP publication 2026-08-23 19:21:59 +02:00
Meta Vens
df65ff3dc1 Classify campaign creation and gestalt presence 2026-08-23 16:05:08 +02:00
Meta Vens
2686f9f297 Make gestalt presence resolution-only 2026-08-23 15:57:34 +02:00
Meta Vens
8729acb5aa Record live region transition release 2026-08-23 15:50:20 +02:00
Meta Vens
636828f509 Fix region mutation batch test 2026-08-23 15:36:49 +02:00
Meta Vens
bee96763e6 Route region expansion through mutation algebra 2026-08-23 15:33:03 +02:00
Meta Vens
cebd4de372 Refresh transition authority and deployed state 2026-08-23 15:15:36 +02:00
Meta Vens
c7463c0506 Route population fission through mutation algebra 2026-08-23 15:06:54 +02:00
Meta Vens
6fbe47ad88 Seal mutation ownership and atomic versioning 2026-08-23 14:42:27 +02:00
Meta Vens
d730d1fd70 Unify world transitions through typed mutation algebra 2026-08-23 14:28:02 +02:00
Meta Vens
8568b37f16 Build typed world transition algebra foundation 2026-08-23 13:33:58 +02:00
Meta Vens
0f8d93de14 Carry actor memory into lived projection 2026-08-23 12:21:20 +02:00
Meta Vens
41460f056b Project canonical scene presence through Eve 2026-08-23 12:21:20 +02:00
Meta Vens
d7c78cd1bd Remove player-authored noise from world news 2026-08-23 12:10:02 +02:00
Meta Vens
ffea9047b2 Bind NPC memory to committed events 2026-08-23 12:04:06 +02:00
Meta Vens
41ff563459 Project directly perceived world events 2026-08-23 11:47:40 +02:00
Meta Vens
c5f229010f Project news from canonical channel access 2026-08-23 11:39:37 +02:00
Meta Vens
74f497703d Let assessed repairs reduce world pressure 2026-08-23 11:22:47 +02:00
Meta Vens
855d6be8fc Keep model diagnostics private in Eve play 2026-08-23 11:10:54 +02:00
Meta Vens
50e7112651 Record live multiresolution pressure proof 2026-08-23 10:32:48 +02:00
Meta Vens
a10a0125fc Publish every strategic commit through CultMesh 2026-08-23 10:18:12 +02:00
Meta Vens
6d2e6c41c8 Exclude exact semantic no-ops on cell retry 2026-08-23 10:06:52 +02:00
Meta Vens
33b9975b3e Route pressure no-ops through outcome validation 2026-08-23 09:59:43 +02:00
Meta Vens
bb846cccf7 Preserve strategic failure cause chains 2026-08-23 09:52:36 +02:00
Meta Vens
ea4d3b659a Give structured retries exact rejected context 2026-08-23 09:46:25 +02:00
Meta Vens
3a2b5d4671 Make outcome correction diagnostics precise 2026-08-23 09:37:59 +02:00
Meta Vens
ae4560c371 Exclude rejected strategic effects from retry 2026-08-23 09:26:04 +02:00
Meta Vens
43237e4828 Bind strategic targets to exact locations 2026-08-23 09:18:56 +02:00
Meta Vens
3cbaa8bb61 Expose exact rejected strategic candidates 2026-08-23 09:01:52 +02:00
Meta Vens
63c17af165 Name exact strategic activity targets 2026-08-23 08:54:45 +02:00
Meta Vens
29bacfb50d Constrain member obligations to social activity 2026-08-23 08:46:35 +02:00
Meta Vens
51f2a1150c Bind strategic pressure transitions to current state 2026-08-23 08:39:20 +02:00
Meta Vens
dff9b62065 Represent targetless local obstruction 2026-08-23 08:12:24 +02:00
Meta Vens
6af0bf7980 Name exact structured validation failures 2026-08-23 08:04:30 +02:00
Meta Vens
03134fcdd5 Omit unavailable strategic outcome lanes 2026-08-23 07:56:01 +02:00
Meta Vens
d1b045a08a Bind strategic outcomes to exact action authority 2026-08-23 07:47:51 +02:00
Meta Vens
bc188d9a2b Allow exact strategic reference slices 2026-08-23 07:36:42 +02:00
Meta Vens
10e5e844a1 Ground effect verification in exact topology 2026-08-23 07:27:37 +02:00
Meta Vens
aa7d396d78 Bind strategic effect fields to subject scope 2026-08-23 07:21:49 +02:00
Meta Vens
3f1c7a5cc4 Constrain strategic effects by exact subject authority 2026-08-23 07:15:01 +02:00
Meta Vens
3607771f33 Clarify strategic outcome reference authority 2026-08-23 07:07:15 +02:00
Meta Vens
122aa1fb3e Preserve actor continuity in strategic cells 2026-08-23 06:58:04 +02:00
Meta Vens
a830fe6699 Separate agency knowledge from news channels 2026-08-23 06:47:26 +02:00
Meta Vens
25f49af98e Add typed campaign inspection tool 2026-08-23 06:37:40 +02:00
Meta Vens
e0b12f9025 Keep strategic verifier diagnostics private 2026-08-23 06:29:05 +02:00
Meta Vens
bc8f1c342c Keep agency profiles aligned during group travel 2026-08-23 06:29:05 +02:00
Meta Vens
1d158184f5 Make verifier verdict coherence structural 2026-08-23 06:18:19 +02:00
Meta Vens
370ba36ee6 Compile facts for approved character playability 2026-08-23 06:09:21 +02:00
Meta Vens
360ca6e6f2 Verify narrative projections against committed state 2026-08-23 06:09:21 +02:00
Meta Vens
31300cd84f Route player waits through strategic world time 2026-08-23 06:01:25 +02:00
Meta Vens
a4122e91d4 Keep Session Zero proposals in one authority lane 2026-08-23 05:36:03 +02:00
Meta Vens
a49738eba2 Make Session Zero decisions explicit 2026-08-23 05:27:16 +02:00
Meta Vens
c6c9953458 Let players decline Session Zero proposals 2026-08-23 05:17:06 +02:00
Meta Vens
b58ab7f3c7 Focus Session Zero on the current player turn 2026-08-23 05:06:35 +02:00
Meta Vens
d844df8cac Admit live turns before scheduler contention 2026-08-23 04:46:09 +02:00
Meta Vens
3291ae86e5 Prioritize Session Zero over background inference 2026-08-23 04:29:33 +02:00
Meta Vens
d8bdaa0ead Bind Session Zero decision identity in Ghostlight 2026-08-23 04:15:28 +02:00
Meta Vens
70e6078507 Preserve legacy Session Zero patch rows 2026-08-23 03:58:33 +02:00
Meta Vens
fc92716ae6 Make Session Zero character patches reversible 2026-08-23 03:52:01 +02:00
Meta Vens
a112572d54 Separate character and permission proposal lanes 2026-08-23 03:40:39 +02:00
Meta Vens
61d8c65e84 Isolate Session Zero suggestions from model state 2026-08-23 03:30:06 +02:00
Meta Vens
a33b779c0d Keep published sessions out of campaign navigation 2026-08-23 03:17:19 +02:00
Meta Vens
f6891153a5 Preserve Eve versions across assessment results 2026-08-23 03:08:02 +02:00
Meta Vens
6691a48ca2 Constrain assessment deltas before inference 2026-08-23 02:58:56 +02:00
Meta Vens
d3ab038fe2 Keep narration bound to the player viewpoint 2026-08-23 02:53:47 +02:00
Meta Vens
b854ba5db9 Bind assessment knowledge to exact recipients 2026-08-23 02:49:08 +02:00
Meta Vens
df18494a7c Constrain Gestalt presence and preserve committed receipts 2026-08-23 02:39:38 +02:00
Meta Vens
726f5c2b4f Project exact d20 results through Eve 2026-08-23 02:23:22 +02:00
Meta Vens
1931630ba0 Bind assessed outcomes to exact world scope 2026-08-23 02:10:58 +02:00
Meta Vens
1d2047b2e2 Bind assessment modifiers to visible state 2026-08-23 01:58:30 +02:00
Meta Vens
1ddbd294af Preserve canonical actor agency in strategic cells 2026-08-23 01:49:06 +02:00
Meta Vens
502384f366 Pin Eve transient command routing repair 2026-08-23 01:30:02 +02:00
Meta Vens
da8d09d81c Publish complete Eve command-result surfaces 2026-08-23 01:16:05 +02:00
Meta Vens
e5ef5e22ea Keep private assessment outside strategic catch-up 2026-08-23 01:07:58 +02:00
Meta Vens
a7a00ab198 Separate Session Zero audit from preview proof 2026-08-23 00:16:04 +02:00
Meta Vens
d20cf9b106 Keep player identities out of world cast compilation 2026-08-22 23:45:48 +02:00
Meta Vens
b2c87b6de1 Let hosts discard noncanonical previews 2026-08-22 23:33:06 +02:00
Meta Vens
d00deb8cd4 Split private actors from shared world compilation 2026-08-22 23:19:32 +02:00
Meta Vens
887bcdb827 Bind relationship anchor identities locally 2026-08-22 22:57:28 +02:00
Meta Vens
9c7dfe8693 Bind Session Zero approval to world preview 2026-08-22 22:41:40 +02:00
Meta Vens
73c75effcb Bind approved relationships to canonical subjects 2026-08-22 22:20:49 +02:00
Meta Vens
f6b134c887 Keep compiler failures retryable 2026-08-22 21:45:44 +02:00
Meta Vens
a6b5833cdc Demote payloadless legacy Session Zero questions 2026-08-22 21:25:34 +02:00
Meta Vens
c52c7a96b1 Keep optional openings out of compilation readiness 2026-08-22 21:19:15 +02:00
Meta Vens
6ff6d3ff4c Narrow focused counter interpretation to its payload lane 2026-08-22 20:50:34 +02:00
Meta Vens
8c8731eedd Project focused counters from exact local state 2026-08-22 20:34:02 +02:00
Meta Vens
33a761bf4f Let model transports own attempt deadlines 2026-08-22 20:16:04 +02:00
Meta Vens
c3ab99f899 Make counter replacement focused and auditable 2026-08-22 20:03:05 +02:00
Meta Vens
a48240a4d0 Bind counter retries to their exact decision 2026-08-22 19:46:38 +02:00
Meta Vens
6c34113e8f Retry persisted Session Zero counters 2026-08-22 19:35:36 +02:00
Meta Vens
3e9e6ed4e2 Project exact Session Zero consent state 2026-08-22 19:26:43 +02:00
Meta Vens
59ebb9dd0b Pin Eve draft preservation fix 2026-08-22 19:14:27 +02:00
Meta Vens
6b01d4a7cc Reject payloadless Session Zero decisions 2026-08-22 19:10:01 +02:00
Meta Vens
47dedaefe4 Preserve Persona speech across Session Zero interpretation 2026-08-22 18:51:05 +02:00
Meta Vens
33ba5b0ac1 Add redacted Session Zero receipt inspector 2026-08-22 18:50:58 +02:00
Meta Vens
301a7843bb Record live Heimdall continuity proof 2026-08-22 18:38:57 +02:00
Meta Vens
41a1950f1d Give each Heimdall refresh attempt a fresh command identity 2026-08-22 18:31:41 +02:00
Meta Vens
9db7390e5e Verify Heimdall refresh receipts by signed session 2026-08-22 18:25:40 +02:00
Meta Vens
ebe327a1ef Preserve Heimdall session continuity 2026-08-22 18:18:03 +02:00
Meta Vens
c0d61b50e6 Record adversarial Session Zero acceptance 2026-08-22 13:26:46 +02:00
Meta Vens
257b6429c5 Keep generated Session Zero suggestions optional 2026-08-22 13:18:50 +02:00
Meta Vens
07afa058d5 Make Session Zero counters retire stale proposals 2026-08-22 13:01:23 +02:00
Meta Vens
8cad6b937d Preserve Session Zero context across Eve channels 2026-08-22 12:42:28 +02:00
Meta Vens
b06d2a72d7 Harden Eve input and OpenRouter structured turns 2026-08-22 12:25:53 +02:00
Meta Vens
0e56b5600b Complete Heimdall auth across browser return contexts 2026-08-22 11:43:42 +02:00
Meta Vens
7d354dd8c8 Record OpenRouter test deployment 2026-08-22 07:40:31 +02:00
Meta Vens
4c04c13c14 Add OpenRouter model routing 2026-08-22 07:30:52 +02:00
Meta Vens
324b10afbb Record Eve-native access deployment 2026-08-22 04:08:24 +02:00
Meta Vens
b515ca90c2 Read Heimdall boundary from its Odin catalog key 2026-08-22 03:47:08 +02:00
Meta Vens
3411c1c6ab Resolve Heimdall command authority through Odin 2026-08-22 03:36:38 +02:00
Meta Vens
7185710fb1 Open Eve revision stream only after authentication 2026-08-22 03:17:57 +02:00
Meta Vens
172ac0db55 Lower Ghostlight as one Eve-native surface 2026-08-22 02:05:01 +02:00
Meta Vens
62e5f05d9a Route Ghostlight through Eve and Heimdall authorities 2026-08-22 02:04:56 +02:00
Meta Vens
33f2132cd3 Refresh CultNet operation service substrate 2026-08-22 02:04:51 +02:00
Meta Vens
d407a51e96 Refresh Ghostlight rehydration state 2026-08-21 22:57:16 +02:00
Meta Vens
91e70d4e40 Record native Yggdrasil Ghostlight authority 2026-08-21 21:06:46 +02:00
Meta Vens
10b638e457 Verify provenance in frozen source builds 2026-08-21 20:41:19 +02:00
Meta Vens
0e185b51dd Keep Windows runtime bindings target scoped 2026-08-21 20:33:35 +02:00
Meta Vens
f12de09d00 Add Yggdrasil runtime and signed health support 2026-08-21 20:15:21 +02:00
Meta Vens
88d81a0ec8 Correct Yggdrasil outage diagnosis 2026-08-21 18:07:12 +02:00
Meta Vens
34fd554855 Record Session Zero deployment 2026-08-21 17:43:53 +02:00
Meta Vens
2b059da2ed Bind Session Zero stages to exact output schemas 2026-08-21 17:30:58 +02:00
Meta Vens
531d4d44bb Surface failed Session Zero DM turns 2026-08-21 17:21:14 +02:00
Meta Vens
9f8be42d5c Build Session Zero and bounded co-op 2026-08-21 16:58:56 +02:00
Meta Vens
acb7dae59e Separate agency evidence from strategic doctrine 2026-08-20 23:47:07 +02:00
Meta Vens
221b782c5f Require institution-specific agency mandates 2026-08-20 23:22:51 +02:00
Meta Vens
6c2abbac20 Ground agency candidates before capacity admission 2026-08-20 23:18:14 +02:00
Meta Vens
4680ed421a Record world compiler authority repair 2026-08-20 22:59:40 +02:00
Meta Vens
f8c515129c Protect world compilation source authority 2026-08-20 22:08:58 +02:00
Meta Vens
3da6a44308 Record Heimdall-gated playtest deployment 2026-08-20 21:01:54 +02:00
Meta Vens
7b58e3eaa2 Resume Heimdall login across Discord navigation 2026-08-20 20:57:05 +02:00
Meta Vens
b7259a4eda Gate Ghostlight playtests through Heimdall Discord roles 2026-08-20 20:47:18 +02:00
Meta Vens
92c3e81d92 Replace demo invites with Heimdall sign-in 2026-08-19 00:40:15 +02:00
Meta Vens
f2398545f3 Document hosted lore Vault entitlements 2026-08-19 00:25:22 +02:00
Meta Vens
164605be85 Provision reusable batches of single-use invites 2026-08-19 00:11:39 +02:00
Meta Vens
64f5f992cf Record credentialed browser acceptance 2026-08-18 23:56:56 +02:00
Meta Vens
9f991e4ce6 Accept registry-normalized campaign approvals 2026-08-18 23:48:16 +02:00
Meta Vens
83036a1387 Record final Starfire shakedown evidence 2026-08-18 23:43:44 +02:00
Meta Vens
53f3f0cc31 Recover campaign selection from stale auth state 2026-08-18 23:36:41 +02:00
Meta Vens
e669d29227 Publish branch lifecycle receipts atomically 2026-08-18 23:34:37 +02:00
Meta Vens
17b8086007 Record atomic campaign publication 2026-08-18 23:30:59 +02:00
Meta Vens
90e7ca38a2 Publish campaigns only after atomic initialization 2026-08-18 23:26:20 +02:00
Meta Vens
2e77310b30 Record server-owned generated starts 2026-08-18 23:21:39 +02:00
Meta Vens
4995f261f6 Keep generated role selection server-owned 2026-08-18 23:17:11 +02:00
Meta Vens
debdf609d3 Bound fission input before inference 2026-08-18 23:10:51 +02:00
Meta Vens
07176cdc2e Record browser handoff hardening 2026-08-18 23:06:50 +02:00
Meta Vens
ca9c1ccbfa Keep browser mutations locked through refresh 2026-08-18 23:02:06 +02:00
Meta Vens
f456993b07 Type-check the browser release build 2026-08-18 23:00:26 +02:00
Meta Vens
b6776d8169 Record authenticated API perimeter release 2026-08-18 22:58:42 +02:00
Meta Vens
f905376a22 Authenticate API requests before extraction 2026-08-18 22:53:14 +02:00
Meta Vens
d909a09866 Record deployed Odin liveness separation 2026-08-18 22:44:22 +02:00
Meta Vens
b76bb5a893 Require Odin to acknowledge RUDP publication 2026-08-18 22:39:49 +02:00
Meta Vens
e24c953e76 Decouple Odin replication from daemon liveness 2026-08-18 22:34:41 +02:00
Meta Vens
2b56181f2c Record deployed preview retirement release 2026-08-18 22:26:17 +02:00
Meta Vens
12a704a508 Retire superseded approval previews 2026-08-18 22:21:19 +02:00
Meta Vens
845e0a5e1d Record deployed speech and command release 2026-08-18 22:20:01 +02:00
Meta Vens
0815776fa8 Bound player commands before inference or mutation 2026-08-18 22:15:22 +02:00
Meta Vens
5db04a1fe7 Separate ordinary speech from assessed attempts 2026-08-18 22:11:43 +02:00
Meta Vens
d51bf0dc2d Record deployed durable auth release 2026-08-18 22:07:47 +02:00
Meta Vens
e55984c227 Make auth transitions durable before authority changes 2026-08-18 22:02:32 +02:00
Meta Vens
0cd418b2e8 Record deployed failure-hardening release 2026-08-18 21:59:22 +02:00
Meta Vens
73b748e183 Harden browser and campaign lifecycle failures 2026-08-18 21:54:57 +02:00
Meta Vens
3c1f73d95b Record deployed operator boundary 2026-08-18 21:49:21 +02:00
Meta Vens
f7c9db29be Restrict operator HTTP to loopback 2026-08-18 21:44:53 +02:00
Meta Vens
aae7b03c54 Record deployed resolution boundary 2026-08-18 21:41:42 +02:00
Meta Vens
c509d2a95b Restore operator ownership of resolution pins 2026-08-18 21:37:19 +02:00
Meta Vens
c1ee8daf77 Record deployed player information membrane 2026-08-18 21:33:33 +02:00
Meta Vens
c4a3d6f38b Close omniscient player pressure leak 2026-08-18 21:29:23 +02:00
Meta Vens
815f3dc510 Record deployed approval workflow 2026-08-18 21:25:21 +02:00
Meta Vens
5e198dfa87 Bind expansion previews to canonical location 2026-08-18 21:20:40 +02:00
Meta Vens
b5483242e1 Expose the world approval surface 2026-08-18 21:17:22 +02:00
Meta Vens
7f26221c21 Record current Ghostlight deployment 2026-08-18 21:13:28 +02:00
Meta Vens
f9b0ffb90e Preserve player intent and approval ownership 2026-08-18 21:07:02 +02:00
Meta Vens
b77e68e1f7 Separate gestalt budget from provider concurrency 2026-08-18 21:03:48 +02:00
Meta Vens
4d6c70a42f Record authenticated and gestalt acceptance 2026-08-18 20:55:15 +02:00
Meta Vens
7916ab0493 Preserve selected member agency in gestalt projections 2026-08-18 20:45:33 +02:00
Meta Vens
a92fb7a4a0 Retrieve opening suggestions across historical frames 2026-08-18 20:30:31 +02:00
Meta Vens
527041c3e9 Publish canonical player browser bundle 2026-08-18 20:22:42 +02:00
Meta Vens
624ded4aaf Bind player commands to canonical actor identity 2026-08-18 20:21:45 +02:00
Meta Vens
7c57327286 Make compiler suggestion correction precise 2026-08-18 20:14:22 +02:00
Meta Vens
3bfa21a8ae Record spoiler-safe deployed release 2026-08-18 20:04:42 +02:00
Meta Vens
85938884b8 Keep branch secrets out of player approval 2026-08-18 20:00:36 +02:00
Meta Vens
c7d950c9ae Advance quiet campaigns without fake agency 2026-08-18 19:51:38 +02:00
Meta Vens
a1c8232378 Record authenticated release rehearsal 2026-08-18 19:47:43 +02:00
Meta Vens
0e4b8f4617 Retry semantically duplicate opening sets 2026-08-18 19:41:45 +02:00
Meta Vens
b7524a9700 Prove high-risk outcome custody live 2026-08-18 19:32:55 +02:00
Meta Vens
4e3cda2ce1 Record Starfire human-test gate 2026-08-18 19:27:19 +02:00
Meta Vens
9367ad1329 Keep local mesh authoritative during Odin outages 2026-08-18 19:22:11 +02:00
Meta Vens
07d71173df Bind strategic outcomes to causal authority 2026-08-18 19:15:54 +02:00
Meta Vens
418a591f7c Document multiplayer as a post-solo extension 2026-08-18 16:36:47 +02:00
Meta Vens
4fe1f135c6 Derive outcome summaries from typed effects 2026-08-18 11:03:42 +02:00
Meta Vens
a08963b70e Explain semantic effect repairs precisely 2026-08-18 10:56:14 +02:00
Meta Vens
31221790ce Verify rematerialization from current member deltas 2026-08-18 10:49:31 +02:00
Meta Vens
5aa76d2809 Bind cell decisions to projected perspectives 2026-08-18 10:45:45 +02:00
Meta Vens
3637bef129 Expose exact outcome ownership handles 2026-08-18 10:37:36 +02:00
Meta Vens
429abeb36d Publish the strategic outcome output contract 2026-08-18 10:29:27 +02:00
Meta Vens
d22bceaf9e Clarify cell inaction ownership 2026-08-18 10:22:28 +02:00
Meta Vens
622d5ba167 Resolve strategic activities into typed outcomes 2026-08-18 10:12:17 +02:00
Meta Vens
15cafa81d1 Record multiresolution live-fire profile 2026-08-18 05:43:39 +02:00
Meta Vens
d0449516d8 Apply strategic waves from one snapshot 2026-08-18 05:23:59 +02:00
Meta Vens
e0bf1c2116 Diagnose attributed decision conflicts exactly 2026-08-18 05:07:17 +02:00
Meta Vens
54265a9423 Bound attributed inaction to voiced subjects 2026-08-18 05:03:24 +02:00
Meta Vens
97e05c571e Attribute inaction inside gestalt arenas 2026-08-18 04:52:55 +02:00
Meta Vens
6253f9f42e Treat committed posture as persistent state 2026-08-18 04:19:52 +02:00
Meta Vens
dab6aae441 Surface earned gestalt callbacks 2026-08-18 04:12:50 +02:00
Meta Vens
6b32702f7f Count singleton cells as direct resolution 2026-08-18 03:47:32 +02:00
Meta Vens
097262c940 Replan leases around changed topology 2026-08-18 03:26:41 +02:00
Meta Vens
8d5ded1613 Reject redundant resolution controls 2026-08-18 03:18:50 +02:00
Meta Vens
259318c812 Record multiresolution pressure evidence 2026-08-18 03:13:59 +02:00
Meta Vens
7e3b1abe53 Bound scale acceptance by eligible subjects 2026-08-18 03:08:24 +02:00
Meta Vens
a4ea28b307 Branch fairness from the strict migration golden 2026-08-18 02:50:25 +02:00
Meta Vens
dc45f56733 Separate migration choice from fairness pressure 2026-08-18 02:45:23 +02:00
Meta Vens
bc19c8ae9d Resume fairness after rejected scheduler pulses 2026-08-18 02:38:33 +02:00
Meta Vens
73c3477090 Preserve individual agency through diaspora 2026-08-18 02:28:08 +02:00
Meta Vens
8bd5688507 Pressure low-budget gestalt fairness 2026-08-18 02:17:09 +02:00
Meta Vens
bea5d6c080 Expose institution posture bounds to models 2026-08-18 02:07:01 +02:00
Meta Vens
468aa84e16 Define local motion at canonical location scale 2026-08-18 02:01:40 +02:00
Meta Vens
3e8eaf4abf Unify migration state references 2026-08-18 01:57:00 +02:00
Meta Vens
14e1a08984 Admit targetless local communication 2026-08-18 01:50:47 +02:00
Meta Vens
ace0d964a9 Discriminate gestalt effect verdicts 2026-08-18 01:44:33 +02:00
Meta Vens
0b6da0d42e Mark effect verdicts as JSON output 2026-08-18 01:39:04 +02:00
Meta Vens
1fdeb51db7 Verify gestalt effects per action 2026-08-18 01:35:39 +02:00
Meta Vens
88d8bdf987 Refuse unchanged invalid cell actions 2026-08-18 01:26:22 +02:00
Meta Vens
2d175af694 Bound cell projection to decision-bearing segments 2026-08-18 01:18:53 +02:00
Meta Vens
3f399b4244 Bind cell projector perspective ownership 2026-08-18 01:15:20 +02:00
Meta Vens
ee752cdca9 Give effect verification exact scene context 2026-08-18 01:05:24 +02:00
Meta Vens
040a04cf09 Reject substituted gestalt activity targets 2026-08-18 01:01:29 +02:00
Meta Vens
a23ab50d18 Model collective gestalt migration 2026-08-18 00:57:51 +02:00
Meta Vens
987aa7a29e Bind cell effect owners inside Ghostlight 2026-08-18 00:39:35 +02:00
Meta Vens
585fa80617 Preserve chosen member migration in interpretation 2026-08-18 00:29:30 +02:00
Meta Vens
1c63ee5272 Separate institution posture from pressure 2026-08-18 00:23:56 +02:00
Meta Vens
13959c7c09 Keep arena perspectives inside cell authority 2026-08-18 00:17:16 +02:00
Meta Vens
4e2154fac0 Preserve spatial agency inside arena cells 2026-08-18 00:09:44 +02:00
Meta Vens
bcb03e4647 Allow bounded colocated gestalt interaction 2026-08-18 00:02:45 +02:00
Meta Vens
c4906b531c Preserve dormant member action agency 2026-08-17 23:51:39 +02:00
Meta Vens
c994b0152c Distinguish internal gestalt preparation 2026-08-17 23:41:31 +02:00
Meta Vens
774e414b26 Model bounded gestalt background activity 2026-08-17 23:37:36 +02:00
Meta Vens
3810d0952d Make strategic priority semantics explicit 2026-08-17 23:10:13 +02:00
Meta Vens
0dfacfc853 End gestalt turns on attributable choices 2026-08-17 23:04:07 +02:00
Meta Vens
58e665f116 Accept autonomous gestalt inaction 2026-08-17 23:00:44 +02:00
Meta Vens
91c771b767 Clarify strategic consequence context 2026-08-17 22:55:59 +02:00
Meta Vens
222c38f13c Preserve failed gestalt model traces 2026-08-17 22:49:22 +02:00
Meta Vens
20a23e8c08 Bind verifier receipts to exact actions 2026-08-17 22:43:17 +02:00
Meta Vens
532335b90d Bind effect verification to exact proposals 2026-08-17 22:42:20 +02:00
Meta Vens
37f1e873b1 Verify Persona effects before world resolution 2026-08-17 22:39:52 +02:00
Meta Vens
835dea6c5d Give strategic retries exact failure context 2026-08-17 22:32:24 +02:00
Meta Vens
476045e411 Profile sustained gestalt live fire 2026-08-17 22:26:41 +02:00
Meta Vens
9a604137dc Make gestalt pressure changes consequential 2026-08-17 22:26:41 +02:00
Meta Vens
673f7a2758 Tighten gestalt cognition permissions 2026-08-17 22:18:47 +02:00
Meta Vens
2fb6e2805a Keep strategic prose out of world truth 2026-08-17 22:10:25 +02:00
Meta Vens
6d204cf8e0 Pressure sustained multiresolution agency 2026-08-17 21:58:16 +02:00
Meta Vens
d4f8d0b372 Bind gestalt migration identity in the runtime 2026-08-17 21:54:06 +02:00
Meta Vens
902a703c36 Preserve member agency across gestalt migration 2026-08-17 21:50:28 +02:00
Meta Vens
fc16476d11 Map canonical gestalt member migration 2026-08-17 21:35:06 +02:00
Meta Vens
410b21ac06 Seed actionable facts in opening locations 2026-08-17 21:29:44 +02:00
Meta Vens
ab0feece30 Make facts precede informational outcomes 2026-08-17 21:19:35 +02:00
Meta Vens
ea30938b48 Profile persistent HTTP journeys from operator receipts 2026-08-17 21:01:20 +02:00
Meta Vens
112b668d89 Preserve resume provenance in live-fire status 2026-08-17 20:56:23 +02:00
Meta Vens
cdddb094b4 Preserve player agency across live reactions 2026-08-17 20:56:23 +02:00
Meta Vens
412594f4ba Bind compiled relationships to canonical identities 2026-08-17 20:44:50 +02:00
Meta Vens
7bb711b5de Resume live fire from successful scenario receipts 2026-08-17 20:38:53 +02:00
Meta Vens
742838e590 Add persistent HTTP journey live-fire harness 2026-08-17 20:37:38 +02:00
Meta Vens
c88f8c5234 Give assessor retries exact rejected context 2026-08-17 20:35:22 +02:00
Meta Vens
40f5606331 Ground demand focal hints in differentiated pressure 2026-08-17 20:24:54 +02:00
Meta Vens
63174d938c Compress demand context and sharpen appraisal errors 2026-08-17 20:17:10 +02:00
Meta Vens
493be4696d Profile live-fire stages from raw receipts 2026-08-17 20:06:16 +02:00
Meta Vens
8966d702c2 Resume live-fire matrices without replaying scenarios 2026-08-17 20:03:03 +02:00
Meta Vens
6f9c078bce Give semantic retries exact correction context 2026-08-17 20:00:04 +02:00
Meta Vens
6e67ab75a9 Add budgeted live-fire scenario matrix 2026-08-17 19:39:15 +02:00
Meta Vens
e558e9c813 Record hosted MVP acceptance 2026-08-17 19:27:22 +02:00
Meta Vens
f1d55b181a Render authentication as a real gate 2026-08-17 19:12:31 +02:00
Meta Vens
3e367c99cd Invalidate stale build provenance 2026-08-17 18:58:54 +02:00
Meta Vens
07351a709f Record grounded global agency acceptance 2026-08-17 18:54:46 +02:00
Meta Vens
84951fda6f Ground remote agency without local causality 2026-08-17 18:51:12 +02:00
Meta Vens
17254e29cf Describe branch reset authority honestly 2026-08-17 18:21:31 +02:00
Meta Vens
8d34700e62 Preempt background inference for live turns 2026-08-17 18:13:23 +02:00
Meta Vens
e05b7edb16 Project every player route safely 2026-08-17 18:04:24 +02:00
Meta Vens
31f923f71d Type player knowledge as readable findings 2026-08-17 17:58:56 +02:00
Meta Vens
b565a2b9f7 Bind informational findings deterministically 2026-08-17 17:56:13 +02:00
Meta Vens
19c7035cc6 Admit only direct evidence to world seeds 2026-08-17 17:52:24 +02:00
Meta Vens
66b7986cca Protect player and compiler projection boundaries 2026-08-17 17:45:13 +02:00
Meta Vens
ed136f52c1 Harden model boundaries and live acceptance 2026-08-17 17:19:34 +02:00
Meta Vens
56d0998149 Explain multiresolution gestalt simulation 2026-08-17 09:53:09 +02:00
Meta Vens
930fd9ba2e Record multiresolution deployment acceptance 2026-08-17 09:38:46 +02:00
Meta Vens
bf586b90d0 Implement multiresolution gestalt agency graph 2026-08-17 09:33:34 +02:00
Meta Vens
434da0dc99 Record final hosted acceptance 2026-08-16 23:58:42 +02:00
Meta Vens
3eba32a4e8 Ground action assessment in present actors 2026-08-16 23:53:16 +02:00
Meta Vens
17244b9618 Make NPC initiative resolution atomic 2026-08-16 23:49:39 +02:00
Meta Vens
934afe67e1 Close world compiler and MVP authority map 2026-08-16 23:42:36 +02:00
Meta Vens
0422f93f42 Prove model-driven away simulation 2026-08-16 23:37:52 +02:00
Meta Vens
9d909d9ec8 Retry semantically invalid world compilations 2026-08-16 23:22:12 +02:00
Meta Vens
d4a21a4540 Complete assessment and evidence receipts 2026-08-16 23:16:18 +02:00
Meta Vens
12033fd3b4 Unify CultNet media channel policy 2026-08-16 23:11:33 +02:00
Meta Vens
22281891cb Complete Persona context and gestalt individuation 2026-08-16 23:03:30 +02:00
Meta Vens
23226e87b2 Add four-actor live turn acceptance smoke 2026-08-16 22:52:15 +02:00
Meta Vens
27c1bf4c08 Ground compiler retrieval through bounded model plans 2026-08-16 22:43:33 +02:00
Meta Vens
db5fa97ffa Record Ghostlight deployment acceptance frontier 2026-08-16 22:31:19 +02:00
Meta Vens
8cdcb5c7fe Reconcile compiler and VoidBot architecture truth 2026-08-16 22:27:24 +02:00
Meta Vens
dfda6a5af5 Clarify release provenance ownership 2026-08-16 22:23:28 +02:00
Meta Vens
d1788ba9bd Receipt strategic and gestalt transitions 2026-08-16 22:19:33 +02:00
Meta Vens
3d64aa973e Restrict the player command boundary 2026-08-16 22:12:46 +02:00
Meta Vens
18ce95773e Embed exact Ghostlight build provenance 2026-08-16 22:09:00 +02:00
Meta Vens
90b9b589c8 Validate model-driven strategic ticks 2026-08-16 22:05:19 +02:00
Meta Vens
51fc7495f3 Require gestalt rematerialization acceptance 2026-08-16 21:47:24 +02:00
Meta Vens
64595b443a Extract Ghostlight Persona projection membrane 2026-08-16 21:45:50 +02:00
Meta Vens
9cb8a037ad Lease individualized gestalt members 2026-08-16 21:43:11 +02:00
Meta Vens
c26798735b Bind compilation evidence to exact Vault documents 2026-08-16 21:38:40 +02:00
Meta Vens
25d8541a0e Consume exact VoidBot source witnesses 2026-08-16 21:33:29 +02:00
Meta Vens
3f7c3f1d6a Validate structured model stages before acceptance 2026-08-16 21:24:24 +02:00
Meta Vens
a80a269f4b Publish Ghostlight state through CultMesh 2026-08-16 21:22:24 +02:00
Meta Vens
d85366b470 Compile reversible gestalt populations 2026-08-16 21:15:57 +02:00
Meta Vens
3bca11097f Record registry HTTP acceptance 2026-08-16 21:07:10 +02:00
Meta Vens
381cbf22c2 Add operator inspection and live turn pressure 2026-08-16 21:03:18 +02:00
Meta Vens
8defc96133 Route sessions through isolated campaign runtimes 2026-08-16 21:01:04 +02:00
Meta Vens
c7c87bd6c2 Build isolated campaign registry 2026-08-16 20:53:45 +02:00
Meta Vens
736becdbd3 Publish revision-bound narration projections 2026-08-16 20:50:30 +02:00
Meta Vens
ec9d3320a3 Commit typed d20 outcome deltas 2026-08-16 20:47:03 +02:00
Meta Vens
a1b00658b0 Resolve NPC initiative through the kernel 2026-08-16 20:44:07 +02:00
Meta Vens
c1f2e0d2a4 Add reversible gestalt presence planning 2026-08-16 20:40:36 +02:00
Meta Vens
2b825cacb9 Build persistent world action loops 2026-08-16 20:36:01 +02:00
Meta Vens
26971eadbc Persist invite and session authority 2026-08-16 20:17:20 +02:00
Meta Vens
ab39d8cdac Build grounded world compiler flow 2026-08-16 20:12:53 +02:00
Meta Vens
def4263f24 Document detached GhostlightDungeon hosting 2026-08-16 17:00:31 +02:00
Meta Vens
747b27e9bc Run GhostlightDungeon as a normal daemon 2026-08-16 16:58:36 +02:00
Meta Vens
0700c1d188 Run GhostlightDungeon as a native Windows service 2026-08-16 16:55:12 +02:00
Meta Vens
74177ac733 Protect GhostlightDungeon runtime credentials 2026-08-16 16:46:04 +02:00
Meta Vens
6f51f3a4a3 Build GhostlightDungeon kernel and private Eve harness 2026-08-16 16:35:50 +02:00
Meta Vens
e4204debd9 Map Epiphany Persona projection extraction 2026-08-16 16:28:19 +02:00
Meta Vens
9953ecf231 Document GhostlightDungeon authority and Persona ownership 2026-08-16 16:26:53 +02:00
399 changed files with 103215 additions and 55319 deletions

18
.gitignore vendored
View file

@ -1,6 +1,14 @@
__pycache__/
*.pyc
.venv/
node_modules/
state/scratch-compaction-*.md
experiments/images/
sidecar/claude-sdk/dist/
target/
# Disposable local scaffolding. These accumulated to ~87MB of untracked debris
# once; the workspace must stay re-enterable, so they never come back.
.tmp-*
/tmp/
--help
--help.lock
/acceptance-run*
# Claude Code agent worktrees (Hands/Soul isolation); never source
.claude/worktrees/

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "vendor/cultcache-py"]
path = vendor/cultcache-py
url = https://github.com/GameCult/cultcache-py.git

273
AGENTS.md
View file

@ -2,201 +2,106 @@
## Project Purpose
Ghostlight is a research and implementation home for socially persistent
generative agents. The core problem is not "make an LLM sound like a person."
The core problem is building an architecture where personality, culture,
memory, masks, perception, and incentives can actually survive contact with
time, relationships, routine, tonal variation, and pressure.
Ghostlight is a Rust world kernel and the play daemon built on it. Models
propose; the kernel admits or refuses. The same kernel is meant to run the
simulation behind Aetheria's public story, a persistent cast and storylets, as
well as Ghostlight Dungeon, the single-player DM-led client (parked at
tag `parked/ghostlight-eve-surface`). `README.md` says
what exists today and what does not.
This repo exists so those organs can live somewhere cleaner than a side chamber
inside another project.
## Where State Lives
## Canonical State
- Campaign state (targets, rulings, cut specs, reports, verdicts, findings,
follow-ups) lives in the Eureka mind, not in this repo. The current campaign
is `ghostlight-verse`; query it through the `eureka-state` tools.
- `notes/ghostlight-verse-target.md` and `notes/ghostlight-verse-map.md` are the
campaign's prose target and map. Read the map for probed body facts.
- This repo has no state directory, no state CLI, and no evidence ledger. Do not
create one. History belongs in git; durable decisions belong in the mind.
- `notes/fresh-workspace-handoff.md` is a pointer: what runs, how it deploys,
where open work is queried.
- Dated cut documents and postmortems (`docs/architecture/*-cut.md`,
`*-postmortem.md`) are history. Do not edit them, including their old paths.
- Treat `state/ghostlight-state.cultcache.jsonl` as the CultCache-backed
persistence spine for machine-managed state records that have migrated.
- Treat `state/map.yaml` as the canonical human-readable project map.
- Treat `state/scratch.md` as disposable working memory for one bounded
subgoal.
- Treat `state/evidence.jsonl` and `state/branches.json` as readable export
surfaces during the persistence migration, not as the long-term direct-write
API.
- Treat `state/evidence.jsonl` as the distilled durable ledger of what was
learned, verified, rejected, or accepted.
- Treat `notes/ghostlight-implementation-plan.md` as the current implementation
plan for the larger organs.
- Update `state/map.yaml` when project understanding changes.
- Add evidence after meaningful research, implementation, verification, or
rejected paths, but keep it distilled. Routine "I just did this" proof
belongs in git history, commits, smoke artifacts, or targeted logs unless it
changes what the next session should believe.
- Do not turn notes, map, handoff, and evidence into four versions of the same
brain. Distinct jobs or bust.
## Invariants
## Important Paths
- **The kernel decides.** The kernel owns world state through its closed
operation set. Models, tools, controllers, and consumers produce proposals
only. No code outside the kernel commits world state, and no compatibility
writer exists.
- **The library is sealed.** `crates/ghostlight` exposes create/open,
immutable snapshots, command submission, typed receipts, and the runner and
port entry points. Do not publish mutable world types, an ID issuer, a reducer
entry, or a journal handle for the convenience of tests or adapters. The seal
is proven by the `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
and by `crates/ghostlight/tests/external_admission.rs`; keep both passing and
extend them when the public surface changes.
- **Every commit is a command.** Everything enters through the mailbox
(`crates/ghostlight/src/mailbox.rs`) as a command. Do not append a commit when
reduction produces no canonical mutation.
- **A Persona never sees structured state.** Persona turns are prose; an
Interpreter lowers them to typed proposals, and anything it cannot lower
becomes a recorded gap, not invented state.
- **Typed state.** Runtime documents are CultCache `.cc`. JSON is for schema
publication and boundaries with other systems.
- **Credentials.** Ghostlight never reads, copies, forwards, or logs a model
credential. A consumer never restates a foreign owner's document shape as its
own strict struct; read the owner's published contract and test against the
real counterpart.
- **Canon flows one way.** Simulation output never writes back into AetheriaLore
or Aetheria's catalog without operator review.
- Project root: `E:\Projects\Ghostlight`
- Canonical map: `E:\Projects\Ghostlight\state\map.yaml`
- Scratch surface: `E:\Projects\Ghostlight\state\scratch.md`
- Distilled evidence ledger: `E:\Projects\Ghostlight\state\evidence.jsonl`
- Branch ledger: `E:\Projects\Ghostlight\state\branches.json`
- Handoff summary: `E:\Projects\Ghostlight\notes\fresh-workspace-handoff.md`
- System map: `E:\Projects\Ghostlight\notes\ghostlight-current-system-map.md`
- Implementation plan: `E:\Projects\Ghostlight\notes\ghostlight-implementation-plan.md`
- Architecture rationale: `E:\Projects\Ghostlight\notes\architecture-rationale.md`
- State CLI: `E:\Projects\Ghostlight\tools\ghostlight_state.py`
- CultCache state seam: `E:\Projects\Ghostlight\tools\ghostlight_state_store.py`
- CultCache-Py submodule: `E:\Projects\Ghostlight\vendor\cultcache-py`
- CultCache state store: `E:\Projects\Ghostlight\state\ghostlight-state.cultcache.jsonl`
- Pre-compaction helper: `E:\Projects\Ghostlight\tools\ghostlight_prepare_compaction.py`
## Where Things Are
## Useful Commands
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
`docs/architecture/ghostlight-world-consumer-api.md`.
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
tag `parked/ghostlight-eve-surface`; not on main.
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
- Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
rebuild, not the current plan.
Use the bundled Python runtime if `python` is not on PATH:
## Build, Verify, Deploy
```powershell
npm run state:status
npm run state:prepare-compaction
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type research --status ok --note '...'
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-branch --id branch-id --hypothesis '...'
```
Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and
lifecycle brakes engaged, unit stopped; retirement waits on
`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the
`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence
about Linux, so say which host you used. Do not run `idunn up ghostlight`.
## Session Bootstrap And Re-entry Protocol
On fresh session load, do this before wandering off into implementation:
1. read:
- `state/map.yaml`
- `notes/fresh-workspace-handoff.md`
- `notes/ghostlight-current-system-map.md`
- `notes/ghostlight-implementation-plan.md`
2. run:
- `npm run state:status`
3. restate the current next action from the persisted state before starting
edits
4. if the user only asked to rehydrate or reorient, stop after orientation and
wait for an explicit continue instruction instead of treating the persisted
next action as permission to start coding
After compaction, resume, or suspicious continuity loss:
1. rerun `npm run state:status`
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
3. treat the persisted next action as authoritative unless fresh repo evidence
contradicts it
When context pressure is clearly rising:
1. stop broad exploration
2. narrow the active move to one bounded organ
3. persist map or handoff updates, plus distilled evidence only when the lesson
changes future belief, before the blackout lands
When the user says to prepare for imminent compaction:
1. immediately write hot live context from memory into a new
`state/scratch-compaction-<guid>.md` file before reading files, running
status checks, or doing tidy persistence work
2. run `tools/ghostlight_prepare_compaction.py` before editing persistence
surfaces
3. use its warnings as the checklist for map, handoff, scratch, evidence, and
git hygiene
4. update only the state that actually needs to change
5. after hot context has been folded into canonical persisted state, delete the
`state/scratch-compaction-<guid>.md` file so stale emergency memory does not
linger
6. run `tools/ghostlight_prepare_compaction.py` again after edits and scratch
cleanup
7. fix errors, address warnings, and commit the completed persistence pass
unless the work is deliberately mid-surgery
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
`F:\Projects\gamecult-ops` before acting.
## Operating Discipline
- Steward persistent state as part of the work. If a lesson changes future
behavior, update the smallest correct memory surface before moving on; if a
memory surface is stale, duplicated, or making re-entry slower, cut or
consolidate it.
- Before substantial edits, restate the current mechanism and intended change.
- Prefer one clear hypothesis per iteration.
- Verify with checks that reflect the real goal, not just proxy success.
- Revert or discard changes that do not clearly improve the target.
- If the diff grows while understanding shrinks, stop implementation and switch
to diagnosis.
- Keep maps and prose together; do not replace useful maps with prose-only
explanations.
- Commit completed work before it rots in the worktree unless the task is
deliberately mid-surgery or the user asked to leave changes uncommitted.
- Push after major completed passes when a remote exists, unless the user asked
not to or there is a concrete reason to keep the commit local for a moment.
- Before handoff, compaction, or phase boundaries, sync `state/map.yaml`, add
distilled evidence when the lesson changes future belief, refresh
`notes/fresh-workspace-handoff.md`, and make the next action explicit.
- Treat `state/map.yaml` as a working memory map, not an archive. Preserve
current mission, live seams, boundaries, and next action; let git history,
evidence, and artifact files carry chronology.
- In documentation, describe the live system and current rule directly. Do not
write victory laps, scar tours, clever autopsies, or contrastive little
boasts about what the project no longer does. If a sentence congratulates the
design for not being a previous wrong design, cut it or rewrite it as a plain
current rule. If rejected history matters, keep it short, explicit, and
decision-relevant; otherwise cut it. The docs are not a trophy case for
yesterday's wrong machine.
- Restate the current mechanism and intended change before substantial edits.
Prefer one clear hypothesis per iteration.
- Verify with checks that reflect the real goal, not proxy success. Revert
changes that do not clearly improve the target.
- If the diff grows while understanding shrinks, stop and diagnose.
- Delete obsolete authority before adding a new path. Do not keep an old writer
alive behind a shim.
- Commit completed work and push it. Merge, confirm the tip by SHA, then delete
the branch; push only after reading the test result line.
- In documentation, describe the live system and the current rule directly. No
claim of capability without a file that implements it. Keep rejected history
short and decision-relevant, or leave it in git.
## Aetheria Grounding Doctrine
## Aetheria Grounding
- Before writing or generating Aetheria scenes, check AetheriaLore for the
factions, movements, institutions, species or body types, location, and time
period involved.
- Before drafting story prose, choose an intentional tonal mode or blend for the
scene. Aetheria should support wit-with-stakes, warmth, absurdity, domestic
texture, ritual quiet, wonder, horror, noir, dour poetry, and dry systems
prose as needed. Do not default to constant crisis or procedural severity.
- Use Adams/Pratchett-style comic precision and humane levity as a useful
default touchstone when no stronger scene-specific style is called for:
jokes and absurdity should reveal stakes, not dissolve them.
- Establish ordinary life before interruption when the fixture calls for it:
routines, work texture, relationships, private jokes, rituals, small desires,
and local accommodations are part of worldbuilding, not filler.
- For illustrated Ink fixtures, segment prose into click-through visual
sections. Distinct places, focal areas, object closeups, arrivals, alarms,
pivotal one-sentence beats, and aftermath states need stable visual scene ids
and imagegen-ready prompts.
- Treat visual artifacts as illustrated replay and website collateral, not core
social-model training signal. Visual review judges the playable Ink
experience plus the visual plan, not clean-run prose receipts.
- Write visual prompts for a general image model, not a lore expert. Explain
fictional objects and body affordances in visible terms: geometry, materials,
lighting, stance, interfaces, tools, supports, cables, fluids, screens, and
crowd positions.
- Do the same for environments. A location label like "manifold line," "cavity
yard," "supervisor glass," or "anchor rail" is not a drawable scene until the
prompt describes its visible geometry, materials, interfaces, lights, and
scale.
- If a fixture has a target visual style, store that style cue once in the
sidecar and include it in every assembled image prompt. Do not rely on memory
or unstated website context to carry the look.
- Do not treat a character name as a visual description. Illustrated fixtures
need stable character visual refs for every recurring named character, and
scene prompts should either include those refs during prompt assembly or carry
enough appearance detail to stand alone.
- Include visual character refs only for characters actually visible in the
rendered frame. Do not feed imagegen refs for absent, off-frame, merely
mentioned, or object-represented characters.
- Treat visual continuity notes as tool/reviewer constraints, not prompt text.
If imagegen needs the fact, rewrite it as concrete visible description in the
base prompt, character ref, or branch/state modifier.
- Treat generated environment images as concept and material reference, not
spatial truth. Multi-angle or branch-consistent illustrated IF needs a durable
scene-set source such as a hand-built blockout, procedural scene file, layout
asset, or camera map. Imagegen can paint over camera-specific visual input;
it should not be asked to preserve room topology from a prior generated
image or text memory.
- Ground scene writing in the source's material details: habitat form,
infrastructure, law, class pressure, money, route access, surveillance,
communication channels, body affordances, and local failure costs.
- If the source is too vague for a concrete scene detail, do not bluff. Mark
the lore gap, patch the AetheriaLore source with a narrow elaboration, then
regenerate or revise the Ghostlight artifact from that patched source.
- Treat this as part of data generation. Ghostlight is expected to stress-test
Aetheria at room scale, find missing granularity, and push useful
elaborations back into the lore vault.
- Before writing or generating Aetheria material, check AetheriaLore for the
factions, institutions, species or body types, location, and time period
involved. Ground detail in the source's material facts: habitat, law, money,
route access, surveillance, communication channels, and local failure costs.
- If the source is too vague for a concrete detail, do not bluff. Mark the gap
and propose a narrow elaboration for AetheriaLore, subject to operator review.
- Choose a tonal mode on purpose. Aetheria supports wit with stakes, warmth,
absurdity, domestic texture, ritual quiet, wonder, horror, noir, and dry
systems prose; do not default to constant crisis. Comic precision in the
Adams/Pratchett manner is a good default, where jokes reveal stakes instead of
dissolving them.
- Establish ordinary life before interruption: routines, work, relationships,
small desires, and local accommodations are worldbuilding, not filler.

3
CLAUDE.md Normal file
View file

@ -0,0 +1,3 @@
Repository doctrine for this project lives in AGENTS.md, shared with Codex.
@AGENTS.md

1978
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

30
Cargo.toml Normal file
View file

@ -0,0 +1,30 @@
[workspace]
members = [
"crates/ghostlight",
"crates/ghostlight-persona-projection",
"crates/ghostlight-verse",
]
resolver = "2"
[workspace.package]
edition = "2024"
license = "MIT"
version = "0.1.0"
[workspace.dependencies]
anyhow = "1"
async-trait = "0.1"
chrono = { version = "0.4", features = ["serde"] }
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rmp-serde = "1.3"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
unicode-segmentation = "1"
uuid = { version = "1", features = ["v4", "serde"] }
zeroize = "1.8"

226
README.md
View file

@ -1,151 +1,121 @@
# Ghostlight
Ghostlight is a research and implementation home for socially persistent
generative agents.
**Ghostlight is a world kernel: typed world state that a language model can
propose changes to and can never write.** The valuable part is
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
The project is about characters and simulated people who remain coherent across
time, routine, pressure, memory, culture, misreading, masks, incentives, and
changing relationships. The aim is not to make a language model sound charming
for one scene. The aim is to build machinery that can track who someone is,
what they believe, what they hide, how they see other people, how they live when
nothing is exploding, and how their behavior changes when the world leans on
them.
**The thesis.** World state is typed documents. Every change to it, and every
narrative dependency on it, is an operation in a closed, predefined algebra.
Models propose operations; the kernel checks each against the state (references,
preconditions, authority, conservation, who knows what) and admits it or refuses
it with the complete list of mismatches. Narration describes only what was
committed. A model-as-game-master holds the world in a context window and
drifts; here the world is a digest-chained journal that survives restarts and
cannot be talked into contradicting itself.
In practical terms, Ghostlight is building the state and projection layer for
character-driven generation:
**Use it as a structured state backend.** Do not judge it by the parked Dungeon's
prose. The kernel is a library; prose comes from whichever model you plug into
its ports.
- durable canonical character state
- observer-specific perceived state
- directional relationship stance
- cultural and institutional pressure
- memory and goal selection
- speaker-local prompt projection
- evaluation targets for emergent verbal behavior
- a future path for training smaller specialist projection models
- `crates/ghostlight`: the sealed library. A consumer gets create/open
(`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command
submission through role-scoped ports (`PlayPort`, `ConsumerPort`,
`ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port
entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`,
`InferencePort`). Mutable state, ID issuers, the reducer and the journal
writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
and `crates/ghostlight/tests/external_admission.rs` prove the seal.
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects,
places, routes, holdings, knowledge, commitments, pressures), the operation
algebra, and what the reducer checks.
- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a
program that reads a world and proposes changes to the characters it controls
(`POST /cultnet/world-patch`; no process on main serves that door, a host serves
`ghostlight::admit_document`).
The first concrete consumer is Aetheria authoring support, especially Call of
the Void content generation, dialogue scaffolding, and procedural drama for
narrated stories in the Aetheria timeline.
Start with those two documents, then `cargo doc -p ghostlight --open`.
## Core Idea
**Populate the algebra first.** The kernel owns the component operations; a world owns its
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
Speak, which records an utterance and changes nothing. Until your world declares affordances
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
will be thin no matter how good the model is. The Aetheria verse genesis
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
only record the act; their effects are the next phase.
Ghostlight separates three things that are easy to blur together:
## Elaborators
1. **Canonical state**
What is true about a character, relationship, event, memory, or scene.
An elaborator is a model session, confined to one jurisdiction of the world, that
adds structure where the world is thin: it answers a derived causal boundary
(a destination nobody has described yet) or a scale deficit with typed
declarations, and the kernel verifies the result like any other patch. Sessions
are resumable and checkpointed against committed history. Eight stock lenses
(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil,
ember, numen) give each session a role-specific emphasis, for example customs and
local jokes, or money and ledgers. A lens changes the prompt only, never what the
kernel admits. Because boundaries are derived from state, elaborators can be
dispatched as the player approaches a place or a social structure, instead of
authoring the world up front.
2. **Perceived state**
What a particular observer thinks is true, including misreadings,
attribution bias, suspicion, trust, projection, and partial knowledge.
## Direction (planned, not built)
3. **Prompt projection**
A temporary speaker-local prompt surface for one scene or turn.
Use the kernel to simulate a whole world with many kinds of actor: Aetheria's
factions and a persistent cast whose members keep identity, memory and
commitments across sessions. Newspaper-staff simulations before the 2026-08
teardown are the earlier proof of the idea; that code is in git history, not in
the tree. Running this at scale is gated by inference cost, not by the design.
`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates
an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run`
advances it a number of ticks under a token cap, reporting per tick what each
faction did and what it cost. Nothing loads AetheriaLore prose, models
storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`.
The prompt is not the character. The prompt is a rendered slice of state.
## Ghostlight Dungeon (parked)
That distinction matters because believable dialogue should come from character
circumstance, not vibes. Sometimes that circumstance is acute pressure. Often it
is habit, boredom, affection, work rhythm, class embarrassment, ritual memory,
professional competence, petty irritation, or ordinary warmth trying to survive
inside a bleak system. Cat being sarcastic should fall out of shame sensitivity,
distance seeking, suspicion, relationship tension, scene stakes, and voice.
Oz withholding information should fall out of mask rigidity, privacy pressure,
relationship fear, and the need to stay useful. A quiet joke, a domestic aside,
or a moment of tenderness should be just as explainable in state terms as a
threat. The system should be able to show its work without making every scene
sound like Greg Egan having a panic attack in a server closet.
The single-player, DM-led client and its daemon, Eve play surface and browser
host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and
nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI
(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag.
## Current Status
## Repo map
Ghostlight is early architecture plus executable schema seams.
| Path | What it is |
|---|---|
| `crates/ghostlight/` | The sealed kernel library. |
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
| `AGENTS.md` | Invariants and where things are. |
| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). |
| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. |
Currently present:
`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design
essays and proposals from before the rebuild. They describe organs (campaigns,
co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the
running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history.
- v0 canonical agent-state schema
- required variable lists for state families and relationship stance
- Call of the Void-flavored Cat/Oz fixture
- dependency-free fixture validator
- prompt projection contract
- projection distillation plan for teacher/student projector training
- persistence surfaces for re-entry, evidence, planning, and compaction hygiene
## Build and test
Not present yet:
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
workstation:
- runtime prompt renderer
- projection training dataset
- student projection model
- event/classifier pipeline
- simulation loop
- relationship update engine
- culture prior engine
## How Projection Is Supposed To Work
For one speaker in one scene, Ghostlight should gather speaker-local state:
- what the speaker knows
- what they believe
- what they want
- what they are protecting
- what memories are active
- how they read the listener
- what the relationship currently feels like
- what circumstance the scene creates: routine, desire, scarcity, humor,
tenderness, danger, institutional friction, or open crisis
- what voice surface is active
The projection layer turns that into compact character-local prose and prompt
sections. The dialogue model then writes from the active circumstance and tonal
mode, not from a universal crisis register.
Longer term, a frontier model can act as a teacher projector that generates
reviewed projection artifacts. Those artifacts can train a smaller student
projector once the projection schema, deterministic input slicer, and evaluator
are stable.
## Repo Tour
- `AGENTS.md`: operating discipline for humans and agents
- `state/map.yaml`: canonical project map and current next action
- `state/scratch.md`: disposable working memory for one bounded subgoal
- `state/evidence.jsonl`: distilled belief-changing evidence
- `notes/fresh-workspace-handoff.md`: compact re-entry packet
- `notes/ghostlight-current-system-map.md`: current repo machinery
- `notes/ghostlight-implementation-plan.md`: larger implementation sequence
- `notes/architecture-rationale.md`: why the persistence and state split exists
- `docs/research/`: research backlog and modeling notes
- `docs/aetheria/`: Aetheria source material used by Ghostlight
- `docs/architecture/`: state, projection, and authoring architecture
- `docs/architecture/verse-service-contract.md`: GameCult Verse service contract
for Ghostlight's missing Eve GUI/TUI state and authoring surface
- `schemas/`: JSON schema contracts and required field lists
- `examples/`: schema fixtures and future projection examples
- `tools/ghostlight_state.py`: state inspection and evidence helper
- `tools/ghostlight_prepare_compaction.py`: pre-compaction audit helper
- `tools/validate_agent_state.py`: focused fixture validator
## Useful Commands
```powershell
npm run state:status
npm run state:prepare-compaction
npm run schema:validate
```sh
cargo build --locked --workspace
cargo test --locked -p ghostlight-persona-projection
cargo test --locked -p ghostlight
cargo test --locked -p ghostlight-verse
```
To add distilled evidence:
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
`npm run sdk:build` and `npm run sdk:test`. The acceptance test
`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored
by default and needs a real model.
```powershell
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type design --status accepted --note '...'
```
Inference is routed by model name: `local/` goes to an OpenAI-compatible
loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/`
to an https endpoint. Ghostlight never reads, stores or forwards a model
credential beyond a key file named at run time.
## Best Starting Points
- `docs/architecture/canonical-agent-state-schema.md`
- `docs/architecture/cultnet-shared-agent-state-contract.md`
- `docs/architecture/agent-state-variable-glossary.md`
- `docs/architecture/prompt-projection-contract.md`
- `docs/architecture/projection-distillation-plan.md`
- `examples/agent-state.call-of-the-void.json`
For Codex-driven work, read `AGENTS.md` before changing the repo. It contains
the working rules that keep the project state coherent between sessions.
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
publication, model-provider and diagnostic boundaries.

View file

@ -0,0 +1,11 @@
[package]
name = "ghostlight-persona-projection"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
sha2.workspace = true
unicode-segmentation.workspace = true

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
[package]
name = "ghostlight-verse"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
ghostlight = { path = "../ghostlight" }
tokio.workspace = true
[dev-dependencies]
serde_json.workspace = true
tempfile = "3"

View file

@ -0,0 +1,346 @@
//! The verse runner's operator command line.
//!
//! `genesis` creates the canonical Aetheria verse world in a state root, and
//! `run` advances it a given number of ticks under a token cap, printing per
//! tick what each faction did and what it cost. A run starts only when this
//! command is invoked, and ends when it has done what it was asked.
//!
//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and
//! `calls.cc`. The provider key is read from a file at run time and appears
//! nowhere else.
use anyhow::{Context, Result, anyhow, bail};
use chrono::{Duration, Utc};
use ghostlight::{
CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX,
DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent,
HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort,
RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore,
WorldMailbox, call_run_id, id_text, open_controller_work, open_inference,
read_aetheria_catalog, run_manual,
};
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a manual run spends per tick on cognition cells. The operator's lever
/// on cost is the token cap; the partition always covers every faction.
const COVER: CoverBudget = CoverBudget {
cells: 8,
constituent_cap: 8,
urgency_slots: 2,
};
const RUNTIME_ID: &str = "ghostlight-verse";
#[tokio::main]
async fn main() -> ExitCode {
let arguments: Vec<String> = std::env::args().skip(1).collect();
match command(arguments).await {
Ok(code) => code,
Err(error) => {
eprintln!("ghostlight-verse: {error:#}");
ExitCode::FAILURE
}
}
}
async fn command(arguments: Vec<String>) -> Result<ExitCode> {
let Some((name, rest)) = arguments.split_first() else {
bail!("usage: ghostlight-verse genesis|run [flags]");
};
match name.as_str() {
"genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await,
"run" => run(Flags::parse(rest, &RUN_FLAGS)?).await,
_ => bail!("unknown command, expected genesis or run"),
}
}
const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"];
const RUN_FLAGS: [&str; 7] = [
"state",
"ticks",
"token-cap",
"tick-span-minutes",
"model",
"base-url",
"key-file",
];
/// Flags a command may omit.
const OPTIONAL: [&str; 1] = ["key-file"];
/// `--name value` pairs, each named once and from the command's own list.
struct Flags(BTreeMap<&'static str, String>);
impl Flags {
fn parse(arguments: &[String], allowed: &[&'static str]) -> Result<Self> {
let mut seen = BTreeMap::new();
let mut rest = arguments.iter();
while let Some(flag) = rest.next() {
let Some(name) = flag.strip_prefix("--") else {
bail!("expected a --flag, found a bare argument");
};
let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else {
bail!("a flag this command does not take was given");
};
let Some(value) = rest.next() else {
bail!("--{known} needs a value");
};
if seen.insert(known, value.clone()).is_some() {
bail!("--{known} was given twice");
}
}
for &required in allowed {
if !OPTIONAL.contains(&required) && !seen.contains_key(required) {
bail!("--{required} is required");
}
}
Ok(Self(seen))
}
fn text(&self, name: &str) -> &str {
self.0.get(name).map_or("", String::as_str)
}
fn path(&self, name: &str) -> PathBuf {
PathBuf::from(self.text(name))
}
fn number<T: std::str::FromStr + PartialOrd + Default>(&self, name: &str) -> Result<T> {
match self.text(name).parse::<T>() {
Ok(value) if value > T::default() => Ok(value),
_ => bail!("--{name} is not a positive integer"),
}
}
}
async fn genesis(flags: Flags) -> Result<ExitCode> {
let state = flags.path("state");
std::fs::create_dir_all(&state).context("the state directory cannot be created")?;
let catalog = read_aetheria_catalog(flags.path("catalog"))
.context("the Aetheria catalog cannot be read")?;
let factions = catalog.factions.len();
let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1));
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
.map_err(store_fault("the world file in --state cannot be opened"))?;
let receipt = mailbox
.create_verse(
CreateVerseWorldIntent {
id: CommandId::new(),
title: "The Aetheria Verse".into(),
brief: "The Aetheria verse, simulated from its catalog.".into(),
catalog,
aetheria_commit: flags.text("aetheria-commit").into(),
lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()),
targets: BTreeMap::new(),
},
&principal,
)
.await
.context("the verse world cannot be created")?;
// A created world is a draft: the owner approves and activates it, so the
// factions hold opportunities.
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision;
mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: receipt.world_id,
expected_revision: revision,
body,
},
&principal,
)
.await
.context("the world cannot be activated")?;
}
drop(mailbox);
task.await.context("the world file did not close cleanly")?;
println!("world {}", id_text(receipt.world_id));
println!("factions {factions}");
println!("phase Active");
Ok(ExitCode::SUCCESS)
}
async fn run(flags: Flags) -> Result<ExitCode> {
let state = flags.path("state");
let ticks: u32 = flags.number("ticks")?;
let cap: u64 = flags.number("token-cap")?;
let span: u32 = flags.number("tick-span-minutes")?;
let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?;
if !state.join("world.cc").is_file() {
bail!("the state directory holds no world; run genesis first");
}
let model = flags.text("model").to_owned();
let models = ControllerModels {
projector: model.clone(),
persona: model.clone(),
interpreter: model.clone(),
operational_agent: model.clone(),
elaborator: model.clone(),
};
let inner = open_lane(&flags, &model, &models)?;
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
.map_err(store_fault("the world file in --state cannot be opened"))?;
let world = mailbox.snapshot().await.context("the world cannot be read")?;
let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).map_err(store_fault("the call records in --state cannot be opened"))?);
let runs = VerseRunStore::open(state.join("runs.cc")).map_err(store_fault("the run records in --state cannot be opened"))?;
let work = open_controller_work(state.join("controller-work.cc"))
.map_err(store_fault("the controller work store in --state cannot be opened"))?;
let config = VerseRunConfig {
ticks,
tick_span,
cover: COVER,
};
let claim = runs
.claim(&world, cap, &config)
.context("the run cannot be claimed")?;
let sequence = claim.sequence();
// A start that fails after the claim ends the claimed run, so it binds
// nothing: the next claim takes the next number.
let started = RecordingInferencePort::open(
inner,
calls,
call_run_id(sequence),
cap,
&RunStart::of(&world),
)
.context("the run's call records cannot be opened")
.and_then(|port| {
let port = Arc::new(port);
ControllerRunner::open(mailbox.clone(), port.clone(), work, models)
.map(|runner| (port, runner))
.context("the controller organ cannot open")
});
let (port, runner) = match started {
Ok(started) => started,
Err(error) => {
return Err(match runs.abandon(claim, "start_refused") {
Ok(()) => error,
Err(_) => error.context("the refused run's record could not be ended"),
});
}
};
println!(
"run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}"
);
let interrupted = Arc::new(AtomicBool::new(false));
{
let interrupted = interrupted.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
interrupted.store(true, Ordering::SeqCst);
}
});
}
let outcome = run_manual(
&mailbox,
&runner,
&port,
&runs,
claim,
&config,
|report| {
print!("{report}");
if interrupted.load(Ordering::SeqCst) {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.await
.context("the run could not be recorded")?;
println!(
"status: {}; {} of {} ticks closed",
outcome.record.status,
outcome.record.ticks_closed(),
outcome.record.ticks_requested
);
drop(runner);
drop(port);
drop(mailbox);
task.await.context("the world file did not close cleanly")?;
Ok(match outcome.record.status {
RunStatus::Failed { .. } => ExitCode::FAILURE,
_ => ExitCode::SUCCESS,
})
}
/// The inference lane the model's prefix names. A hosted model needs an https
/// origin and a key file; a local one reaches a loopback server and needs
/// neither, so a key file beside it is refused.
fn open_lane(
flags: &Flags,
model: &str,
models: &ControllerModels,
) -> Result<Arc<dyn ghostlight::InferencePort>> {
let (mut hosted, mut local) = (None, None);
if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) {
let Some(key) = flags.0.get("key-file") else {
bail!("a hosted model needs --key-file");
};
hosted = Some(HostedBinding {
base_url: flags.text("base-url").to_owned(),
key_path: Path::new(key).to_owned(),
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
caller_runtime_id: RUNTIME_ID.into(),
});
} else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) {
if flags.0.contains_key("key-file") {
bail!("a local model takes no --key-file");
}
let endpoint: SocketAddr = flags
.text("base-url")
.strip_prefix("http://")
.and_then(|address| address.trim_end_matches('/').parse().ok())
.context("--base-url for a local model is not http://<address>:<port>")?;
local = Some(LocalBinding {
endpoint,
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
caller_runtime_id: RUNTIME_ID.into(),
});
} else {
bail!("--model names no inference lane (expected a hosted/ or local/ prefix)");
}
open_inference(None, None, local, hosted, &models.each())
.map_err(|error| anyhow!(lane_fault(&error)))
}
/// A store that cannot open says which one and that it is under `--state`,
/// never the path or the store's own text, which carries the path.
fn store_fault<E>(message: &'static str) -> impl FnOnce(E) -> anyhow::Error {
move |_| anyhow!(message)
}
/// What a lane that cannot open says: the flag or the kind of fault, never a
/// value the operator passed.
fn lane_fault(error: &ControllerOpenError) -> &'static str {
match error {
ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier",
ControllerOpenError::Connector(_) => "the connector lane cannot open",
ControllerOpenError::WorkStore(_) => "the controller work journal cannot open",
ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane",
ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file",
ControllerOpenError::LocalEndpointNotLoopback { .. } => {
"--base-url for a local model must be a loopback address"
}
ControllerOpenError::HostedEndpointNotHttps => {
"--base-url for a hosted model must be an https URL"
}
ControllerOpenError::HostedKeyUnreadable => {
"--key-file is unreadable, empty, or not one run of visible ASCII"
}
ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix",
ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty",
}
}

View file

@ -0,0 +1,445 @@
//! The `ghostlight-verse` command against a loopback scripted responder. No
//! real model is called and no key file is read: the responder answers the
//! OpenAI-compatible chat-completions request the local lane sends.
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse");
const CATALOG: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc"
);
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
/// What the responder reports each call cost: 120 prompt and 30 completion.
const PER_CALL: u64 = 150;
fn verse(arguments: &[&str]) -> Output {
Command::new(BINARY).args(arguments).output().unwrap()
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn genesis(state: &Path) -> Output {
verse(&[
"genesis",
"--catalog",
CATALOG,
"--aetheria-commit",
COMMIT,
"--state",
state.to_str().unwrap(),
"--owner-hash",
"verse-owner",
])
}
/// Answers every chat-completions request with one tool call: the faction the
/// prompt names speaks its own line. Counts the requests it served.
struct Responder {
address: SocketAddr,
served: Arc<AtomicUsize>,
}
impl Responder {
fn start() -> Self {
Self::answering(200)
}
/// A provider that refuses every call with this status.
fn refusing(status: u16) -> Self {
Self::answering(status)
}
fn answering(status: u16) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let served = Arc::new(AtomicUsize::new(0));
let counter = served.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
let counter = counter.clone();
std::thread::spawn(move || serve(stream, &counter, status));
}
});
Self { address, served }
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
}
fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) {
let mut raw = Vec::new();
let mut chunk = [0u8; 4096];
let (head_end, length) = loop {
let read = stream.read(&mut chunk).unwrap_or(0);
if read == 0 {
return;
}
raw.extend_from_slice(&chunk[..read]);
if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
let length = head
.lines()
.find_map(|line| line.strip_prefix("content-length:"))
.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(0);
break (end + 4, length);
}
};
while raw.len() < head_end + length {
let read = stream.read(&mut chunk).unwrap_or(0);
if read == 0 {
return;
}
raw.extend_from_slice(&chunk[..read]);
}
let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap();
let text = request.to_string();
let faction = ["Adrasteia", "Brannoch"]
.into_iter()
.find(|name| text.contains(&format!("Decision owner:\\n{name}\\n")))
.expect("a request names its faction");
let sequence = served.fetch_add(1, Ordering::SeqCst);
let reply = json!({
"id": format!("reply-{sequence}"),
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": format!("call-{sequence}"),
"type": "function",
"function": {
"name": "speak",
"arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(),
},
}],
},
"finish_reason": "tool_calls",
}],
"usage": { "prompt_tokens": 120, "completion_tokens": 30 },
})
.to_string();
let response = format!(
"HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
reply.len()
);
let _ = stream.write_all(response.as_bytes());
}
fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output {
verse(&[
"run",
"--state",
state.to_str().unwrap(),
"--ticks",
ticks,
"--token-cap",
cap,
"--tick-span-minutes",
"60",
"--model",
"local/scripted",
"--base-url",
&responder.url(),
])
}
#[test]
fn binary_run_prints_acts_and_spend() {
let state = tempfile::tempdir().unwrap();
let made = genesis(state.path());
assert!(made.status.success(), "{}", stderr(&made));
let made = stdout(&made);
assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}");
assert!(state.path().join("world.cc").is_file());
let responder = Responder::start();
let output = run(state.path(), &responder, "2", "1000000");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
// Two ticks, each with both factions named by verb, and the spend as it
// accrues against the cap.
assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}");
for tick in 0..2u64 {
let spent = PER_CALL * 2 * (tick + 1);
assert!(
text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")),
"{text}"
);
assert!(
text.contains(&format!(
" tokens: {} this tick, {spent} spent, {} remaining\n",
PER_CALL * 2,
1_000_000 - spent
)),
"{text}"
);
}
for faction in ["Adrasteia", "Brannoch"] {
assert_eq!(
text.matches(&format!(" {faction} speak [")).count(),
2,
"{text}"
);
assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}");
}
assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}");
assert_eq!(responder.served.load(Ordering::SeqCst), 4);
// The state root holds each store the run wrote.
for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] {
assert!(state.path().join(file).is_file(), "{file} was not written");
}
// A second run is the next in the world and carries on from its clock.
let again = run(state.path(), &responder, "1", "1000000");
assert!(again.status.success(), "{}", stderr(&again));
let text = stdout(&again);
assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}");
assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}");
}
#[test]
fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let responder = Responder::start();
let output = run(state.path(), &responder, "3", "1000");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("tick 0 open: "), "{text}");
assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}");
assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}");
assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made");
}
#[test]
fn a_run_that_fails_says_so_and_exits_nonzero() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let responder = Responder::refusing(500);
let output = run(state.path(), &responder, "2", "1000000");
assert!(!output.status.success());
let text = stdout(&output);
assert!(text.contains("tick 0 open: "), "{text}");
assert!(
text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"),
"{text}"
);
assert_eq!(
responder.served.load(Ordering::SeqCst),
1,
"the run stopped at the first refusal"
);
}
#[test]
fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() {
let state = tempfile::tempdir().unwrap();
let responder = Responder::start();
// No world yet.
let output = run(state.path(), &responder, "1", "1000");
assert!(!output.status.success());
assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output));
assert!(genesis(state.path()).status.success());
// A second genesis does not overwrite the first.
assert!(!genesis(state.path()).status.success());
let secret = "sk-not-a-real-key-0000";
let key = state.path().join(secret);
let cases: [(Vec<&str>, &str); 9] = [
(vec![], "usage"),
(vec!["fly"], "unknown command"),
(
vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"],
"--token-cap is required",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "many",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--ticks is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "0",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--ticks is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--token-cap is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4",
"--base-url", "http://127.0.0.1:1",
],
"--model names no inference lane",
),
(
// A hosted model needs its key file; a missing one is named by flag.
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x",
"--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(),
],
"--key-file is unreadable",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(),
],
"a local model takes no --key-file",
),
];
for (arguments, expected) in cases {
let output = verse(&arguments);
assert!(!output.status.success(), "{arguments:?} succeeded");
let error = stderr(&output);
assert!(error.contains(expected), "{arguments:?}: {error}");
assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}");
assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}");
}
}
#[test]
fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let output = verse(&[
"run",
"--state",
state.path().to_str().unwrap(),
"--ticks",
"1",
"--token-cap",
"1000",
"--tick-span-minutes",
"60",
"--model",
"local/x",
"--base-url",
"http://10.77.0.99:4242",
]);
assert!(!output.status.success());
let error = stderr(&output);
assert!(error.contains("--base-url"), "{error}");
assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}");
// The lane failed before any run was claimed.
assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output));
}
/// A start refused after its claim ends the claimed record Failed, so the run
/// binds nothing. Reached from the command by removing the run records while
/// the call ledger still holds run 1 under another cap.
#[test]
fn a_start_the_ledger_refuses_ends_its_claimed_record_failed() {
let parent = tempfile::tempdir().unwrap();
let state = parent.path().join("state");
std::fs::create_dir_all(&state).unwrap();
assert!(genesis(&state).status.success());
let responder = Responder::start();
let first = run(&state, &responder, "1", "1000000");
assert!(first.status.success(), "{}", stderr(&first));
std::fs::remove_file(state.join("runs.cc")).unwrap();
let refused = run(&state, &responder, "1", "500000");
assert!(!refused.status.success());
let world = {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _entered = runtime.enter();
let (mailbox, task) = ghostlight::WorldMailbox::open(state.join("world.cc")).unwrap();
let world = runtime.block_on(mailbox.snapshot()).unwrap().world_id;
drop(mailbox);
runtime.block_on(task).unwrap();
world
};
let runs = ghostlight::VerseRunStore::open(state.join("runs.cc"))
.unwrap()
.runs(world)
.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].status,
ghostlight::RunStatus::Failed {
reason: "start_refused".into()
}
);
}
/// A store that cannot open names the flag and the fault, never the path.
#[test]
fn a_store_that_cannot_open_never_echoes_the_state_path() {
let responder = Responder::start();
for file in ["world.cc", "controller-work.cc"] {
for held in [false, true] {
let parent = tempfile::tempdir().unwrap();
let state = parent.path().join("distinctivestatename");
std::fs::create_dir_all(&state).unwrap();
assert!(genesis(&state).status.success());
let first = run(&state, &responder, "1", "1000000");
assert!(first.status.success(), "{}", stderr(&first));
let target = state.join(file);
let _guard: Box<dyn std::any::Any> = if !held {
std::fs::write(&target, b"not a database").unwrap();
Box::new(())
} else if file == "world.cc" {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let entered = runtime.enter();
let opened = ghostlight::WorldMailbox::open(&target).unwrap();
drop(entered);
Box::new((runtime, opened))
} else {
Box::new(ghostlight::open_controller_work(&target).unwrap())
};
let output = run(&state, &responder, "1", "1000000");
assert!(!output.status.success(), "{file} held={held}");
let error = stderr(&output);
assert!(error.contains("--state"), "{file} held={held}: {error}");
assert!(
!error.contains("distinctivestatename"),
"{file} held={held} echoed the path: {error}"
);
}
}
}

View file

@ -0,0 +1,34 @@
[package]
name = "ghostlight"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
async-trait.workspace = true
chrono.workspace = true
codex-connector.workspace = true
cultcache-rs.workspace = true
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
reqwest.workspace = true
rmp-serde.workspace = true
rmpv = "1.3"
serde.workspace = true
serde_bytes = "0.11"
serde_json.workspace = true
sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
unicode-normalization = "0.1.25"
unicode-properties = "0.1.4"
uuid.workspace = true
zeroize.workspace = true
[features]
# Exposes `open_local_port_with_timeout` to a consumer's tests. Production
# builds never enable it.
test-support = []
[dev-dependencies]
tempfile = "3"

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,297 @@
//! Aetheria's catalog file, read once into the two things Ghostlight takes from
//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records
//! keyed for import.
//!
//! The catalog is Aetheria's: this reader opens it read-only and never writes
//! it, and it ignores every schema it does not mirror. The mirrors below decode
//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction`
//! v1 by integer key: a MessagePack-keyed object is an array indexed by key,
//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored
//! and a field it drops is an error naming the record and the key. A record
//! that does not decode, an enum value outside the mirror and a verb that
//! breaks the grammar's name or role rules are errors, never skipped rows.
use crate::ImportKey;
use crate::grammar::{
GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath,
};
use cultcache_rs::SingleFileMessagePackBackingStore;
use rmpv::Value;
use std::collections::BTreeMap;
use std::path::Path;
const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar";
const VERB_SCHEMA: &str = "aetheria.verse_verb";
const FACTION_SCHEMA: &str = "aetheria.faction";
const VERB_NAME_MAX: usize = 48;
const ROLE_NAME_MAX: usize = 32;
/// The invoking subject, never a role.
const ACTOR_ROLE: &str = "actor";
/// What Ghostlight takes from Aetheria's catalog.
#[derive(Clone, Debug, PartialEq)]
pub struct AetheriaCatalog {
pub grammar: GrammarBinding,
/// In record-key order.
pub factions: Vec<AetheriaFaction>,
}
/// One `aetheria.faction` record. `allegiance` is carried and read by no
/// consumer yet.
#[derive(Clone, Debug, PartialEq)]
pub struct AetheriaFaction {
pub key: ImportKey,
pub name: String,
pub short_name: String,
pub description: String,
pub allegiance: BTreeMap<ImportKey, f32>,
}
/// Which of the grammar's name or role rules a verb broke.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum VerbRule {
#[error("verb name is not canonical")]
Name,
#[error("role name is not canonical")]
RoleName,
#[error("role name is reserved for the invoking subject")]
ReservedRole,
#[error("role name is declared twice")]
DuplicateRole,
}
/// Why a catalog could not be read. Records are named by record key and fields
/// by integer key; no error carries a value the catalog held.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum CatalogReadError {
#[error("the catalog file cannot be read")]
Unreadable,
#[error("the catalog holds no aetheria.verse_grammar global")]
NoGrammar,
#[error("the catalog holds more than one aetheria.verse_grammar record")]
MultipleGrammars,
#[error("record {record:?} field {field} is missing or not the mirrored type")]
MalformedRecord { record: String, field: u8 },
#[error("record {record:?} field {field} is not a canonical import key")]
NonCanonicalKey { record: String, field: u8 },
#[error("record {record:?} is a second aetheria.faction under one record key")]
DuplicateFaction { record: String },
#[error("record {record:?} field {field} is not a known render path")]
UnknownRenderPath { record: String, field: u8 },
#[error("record {record:?} field {field} is not a known referent kind")]
UnknownReferentKind { record: String, field: u8 },
#[error("verb record {record:?} breaks a grammar rule: {rule}")]
InvalidVerb { record: String, rule: VerbRule },
#[error("the verbs do not bind a grammar: {0}")]
Grammar(#[from] GrammarError),
}
/// Reads the catalog at `path` without writing it. An absent file is
/// `Unreadable`, not an empty catalog.
pub fn read_aetheria_catalog(path: impl AsRef<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
let path = path.as_ref();
std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?;
let mut rows = SingleFileMessagePackBackingStore::new(path)
.pull_all_read_only_snapshot()
.map_err(|_| CatalogReadError::Unreadable)?;
rows.sort_by(|a, b| a.key.cmp(&b.key));
let mut revision = None;
let mut verbs = Vec::new();
let mut factions = Vec::new();
for row in &rows {
match row.r#type.as_str() {
GRAMMAR_SCHEMA => {
if revision.is_some() {
return Err(CatalogReadError::MultipleGrammars);
}
let cells = Cells::of(&row.key, &row.payload)?;
revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?);
}
VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?),
FACTION_SCHEMA => {
let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?;
if factions
.last()
.is_some_and(|seen: &AetheriaFaction| seen.key == faction.key)
{
return Err(CatalogReadError::DuplicateFaction {
record: row.key.clone(),
});
}
factions.push(faction);
}
_ => {}
}
}
let revision = revision.ok_or(CatalogReadError::NoGrammar)?;
Ok(AetheriaCatalog {
grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?,
factions,
})
}
/// A record's payload: the positional array of its keyed fields.
struct Cells<'a> {
record: &'a str,
cells: Vec<Value>,
}
impl<'a> Cells<'a> {
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
let mut payload = payload;
match rmpv::decode::read_value(&mut payload) {
Ok(Value::Array(cells)) => Ok(Self { record, cells }),
_ => Err(CatalogReadError::MalformedRecord {
record: record.to_owned(),
field: 0,
}),
}
}
fn malformed(&self, field: u8) -> CatalogReadError {
CatalogReadError::MalformedRecord {
record: self.record.to_owned(),
field,
}
}
fn non_canonical(&self, field: u8) -> CatalogReadError {
CatalogReadError::NonCanonicalKey {
record: self.record.to_owned(),
field,
}
}
fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> {
self.cells
.get(usize::from(field))
.ok_or_else(|| self.malformed(field))
}
fn int(&self, field: u8) -> Result<i64, CatalogReadError> {
self.cell(field)?
.as_i64()
.ok_or_else(|| self.malformed(field))
}
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
self.cell(field)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| self.malformed(field))
}
/// A string Aetheria may leave null; null reads as empty.
fn text_or_empty(&self, field: u8) -> Result<String, CatalogReadError> {
match self.cell(field)? {
Value::Nil => Ok(String::new()),
Value::String(text) => text
.as_str()
.map(str::to_owned)
.ok_or_else(|| self.malformed(field)),
_ => Err(self.malformed(field)),
}
}
fn array(&self, field: u8) -> Result<&Vec<Value>, CatalogReadError> {
self.cell(field)?
.as_array()
.ok_or_else(|| self.malformed(field))
}
}
fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> {
let invalid = |rule| CatalogReadError::InvalidVerb {
record: cells.record.to_owned(),
rule,
};
let name = cells.text(1)?;
if !canonical_name(&name, VERB_NAME_MAX) {
return Err(invalid(VerbRule::Name));
}
let render_path = match cells.int(2)? {
1 => RenderPath::ShipAction,
2 => RenderPath::Conversation,
_ => {
return Err(CatalogReadError::UnknownRenderPath {
record: cells.record.to_owned(),
field: 2,
});
}
};
let mut roles: Vec<GrammarRole> = Vec::new();
for role in cells.array(3)? {
let role = Cells {
record: cells.record,
cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?,
};
let role_name = role.text(1).map_err(|_| cells.malformed(3))?;
if !canonical_name(&role_name, ROLE_NAME_MAX) {
return Err(invalid(VerbRule::RoleName));
}
if role_name == ACTOR_ROLE {
return Err(invalid(VerbRule::ReservedRole));
}
if roles.iter().any(|seen| seen.role == role_name) {
return Err(invalid(VerbRule::DuplicateRole));
}
let referent = match role.int(2).map_err(|_| cells.malformed(3))? {
1 => GrammarReferentKind::Person,
2 => GrammarReferentKind::Faction,
3 => GrammarReferentKind::Population,
4 => GrammarReferentKind::Place,
5 => GrammarReferentKind::Cargo,
_ => {
return Err(CatalogReadError::UnknownReferentKind {
record: cells.record.to_owned(),
field: 3,
});
}
};
roles.push(GrammarRole {
role: role_name,
referent,
});
}
Ok((name, GrammarVerb { render_path, roles }))
}
/// A lower-case letter, then lower-case letters, digits and underscores.
fn canonical_name(name: &str, max_len: usize) -> bool {
let mut chars = name.chars();
name.len() <= max_len
&& chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn read_faction(key: &str, cells: &Cells) -> Result<AetheriaFaction, CatalogReadError> {
let own = ImportKey::new(FACTION_SCHEMA, key);
if !own.is_canonical() {
return Err(cells.non_canonical(0));
}
let Some(allegiance) = cells.cell(12)?.as_map() else {
return Err(cells.malformed(12));
};
let mut standing = BTreeMap::new();
for (other, weight) in allegiance {
let other = other.as_str().ok_or_else(|| cells.malformed(12))?;
let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?;
let other = ImportKey::new(FACTION_SCHEMA, other);
if !other.is_canonical() {
return Err(cells.non_canonical(12));
}
standing.insert(other, weight as f32);
}
Ok(AetheriaFaction {
key: own,
name: cells.text(1)?,
short_name: cells.text_or_empty(2)?,
description: cells.text_or_empty(3)?,
allegiance: standing,
})
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,589 @@
use super::*;
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
use serde_json::{Value, json};
/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`)
/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`.
const FIXTURE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/aetheria-verse-catalog.cc"
);
fn fixture_rows() -> Vec<CultCacheEnvelope> {
SingleFileMessagePackBackingStore::new(FIXTURE)
.pull_all_read_only_snapshot()
.unwrap()
}
fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope {
rows.iter_mut().find(|row| row.key == key).unwrap()
}
/// Re-encodes one record's positional payload after `edit`.
fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec<Value>)) {
let row = row_of(rows, key);
let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else {
panic!("{key} is not a positional record");
};
change(&mut cells);
row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap();
}
fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.cc");
let mut store = SingleFileMessagePackBackingStore::new(&path);
for row in rows {
store.push(row).unwrap();
}
(dir, path)
}
fn read(rows: &[CultCacheEnvelope]) -> Result<AetheriaCatalog, CatalogReadError> {
let (_dir, path) = catalog_of(rows);
read_aetheria_catalog(&path)
}
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
GrammarVerb {
render_path: path,
roles: roles
.iter()
.map(|(role, referent)| GrammarRole {
role: (*role).into(),
referent: *referent,
})
.collect(),
}
}
/// What Aetheria's fixture writer authored, spelled out by hand.
fn authored_verbs() -> Vec<(String, GrammarVerb)> {
vec![
(
"haul".into(),
verb(
RenderPath::ShipAction,
&[
("cargo", GrammarReferentKind::Cargo),
("destination", GrammarReferentKind::Place),
],
),
),
(
"attack".into(),
verb(
RenderPath::ShipAction,
&[("target", GrammarReferentKind::Faction)],
),
),
("speak".into(), verb(RenderPath::Conversation, &[])),
]
}
fn faction_key(record: &str) -> ImportKey {
ImportKey::new("aetheria.faction", record)
}
#[test]
fn reads_the_aetheria_written_fixture() {
let catalog = read_aetheria_catalog(FIXTURE).unwrap();
let identity = catalog.grammar.identity();
assert_eq!(identity.schema_id, "aetheria.verse_grammar");
assert_eq!(identity.revision, 1);
for (kind, expected) in authored_verbs() {
assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}");
}
assert!(catalog.grammar.verb("mine").is_none());
assert_eq!(
catalog.factions,
vec![
AetheriaFaction {
key: faction_key("faction-adrasteia"),
name: "Adrasteia".into(),
short_name: "ADR".into(),
description: "First.".into(),
allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]),
},
AetheriaFaction {
key: faction_key("faction-brannoch"),
name: "Brannoch".into(),
short_name: "BRN".into(),
description: "Second.".into(),
allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]),
},
]
);
}
#[test]
fn grammar_digest_matches_a_binding_built_by_hand() {
let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap();
let read = read_aetheria_catalog(FIXTURE).unwrap().grammar;
assert_eq!(read.identity().digest, by_hand.identity().digest);
assert_eq!(read, by_hand);
// The digest follows the verbs: a role the file drops is a different grammar.
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| {
cells[3].as_array_mut().unwrap().pop();
});
assert_ne!(
read_catalog_digest(&rows),
by_hand.identity().digest,
"dropping a role did not move the digest"
);
}
fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String {
read(rows).unwrap().grammar.identity().digest
}
#[test]
fn refuses_a_catalog_without_a_grammar() {
let mut rows = fixture_rows();
rows.retain(|row| row.r#type != "aetheria.verse_grammar");
assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar));
}
#[test]
fn refuses_a_second_grammar_record() {
let mut rows = fixture_rows();
let mut second = rows
.iter()
.find(|row| row.r#type == "aetheria.verse_grammar")
.unwrap()
.clone();
second.key.push_str("-again");
rows.push(second);
assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars));
}
#[test]
fn refuses_an_unknown_render_path() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3));
let error = read(&rows).unwrap_err();
assert_eq!(
error,
CatalogReadError::UnknownRenderPath {
record: "verb-haul".into(),
field: 2
}
);
assert!(!error.to_string().contains('3'), "the value was echoed");
}
#[test]
fn refuses_an_unknown_referent_kind() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-attack", |cells| {
cells[3][0][2] = json!(9);
});
assert_eq!(
read(&rows),
Err(CatalogReadError::UnknownReferentKind {
record: "verb-attack".into(),
field: 3
})
);
}
#[test]
fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() {
let broken = |mutate: fn(&mut Vec<Value>)| {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", mutate);
read(&rows).unwrap_err()
};
let invalid = |rule| CatalogReadError::InvalidVerb {
record: "verb-haul".into(),
rule,
};
assert_eq!(
broken(|cells| cells[1] = json!("Haul")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("1haul")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("h".repeat(49))),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("Cargo")),
invalid(VerbRule::RoleName)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("c".repeat(33))),
invalid(VerbRule::RoleName)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("actor")),
invalid(VerbRule::ReservedRole)
);
assert_eq!(
broken(|cells| cells[3][1][1] = json!("cargo")),
invalid(VerbRule::DuplicateRole)
);
}
#[test]
fn the_longest_canonical_names_are_admitted() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| {
cells[1] = json!(format!("h{}9", "_9".repeat(23)));
cells[3][0][1] = json!(format!("c{}", "z".repeat(31)));
});
let catalog = read(&rows).unwrap();
assert_eq!(catalog.grammar.identity().revision, 1);
}
#[test]
fn two_verbs_with_one_name_do_not_bind() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack"));
assert_eq!(
read(&rows),
Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb))
);
}
#[test]
fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() {
let malformed = |record: &str, field| CatalogReadError::MalformedRecord {
record: record.into(),
field,
};
let mut rows = fixture_rows();
row_of(&mut rows, "faction-brannoch").payload =
rmp_serde::to_vec(&json!("not an array")).unwrap();
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[1] = Value::Null
});
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[12] = json!({"faction-adrasteia": "heavy"});
});
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12));
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
let mut rows = fixture_rows();
let grammar = rows
.iter()
.find(|row| row.r#type == "aetheria.verse_grammar")
.unwrap()
.key
.clone();
edit(&mut rows, &grammar, |cells| cells[1] = json!(-1));
assert_eq!(read(&rows), Err(malformed(&grammar, 1)));
}
#[test]
fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-speak", |cells| cells.push(json!("later")));
edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7)));
let mut stranger = rows[0].clone();
stranger.key = "ship-1".into();
stranger.r#type = "aetheria.ship".into();
stranger.schema_id = Some("sha256:not-mirrored".into());
stranger.payload = vec![0xc1];
rows.push(stranger);
assert_eq!(
read(&rows).unwrap(),
read_aetheria_catalog(FIXTURE).unwrap()
);
}
#[test]
fn a_null_short_name_or_description_reads_as_empty() {
let mut rows = fixture_rows();
edit(&mut rows, "faction-adrasteia", |cells| {
cells[2] = Value::Null;
cells[3] = Value::Null;
});
let catalog = read(&rows).unwrap();
assert_eq!(catalog.factions[0].short_name, "");
assert_eq!(catalog.factions[0].description, "");
}
#[test]
fn an_absent_file_is_unreadable_not_an_empty_catalog() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
read_aetheria_catalog(dir.path().join("missing.cc")),
Err(CatalogReadError::Unreadable)
);
}
#[test]
fn reading_leaves_the_catalog_and_its_directory_untouched() {
let (dir, path) = catalog_of(&fixture_rows());
let bytes = std::fs::read(&path).unwrap();
let listing = |dir: &std::path::Path| {
let mut names: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
names.sort();
names
};
let before = listing(dir.path());
read_aetheria_catalog(&path).unwrap();
read_aetheria_catalog(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(listing(dir.path()), before, "the reader created a file");
}
#[test]
fn every_referent_code_maps_to_its_own_kind() {
// The fixture binds only Faction, Cargo and Place; Person and Population
// are authored here at the role's integer key, each read back as itself.
for (code, expected) in [
(1, GrammarReferentKind::Person),
(2, GrammarReferentKind::Faction),
(3, GrammarReferentKind::Population),
(4, GrammarReferentKind::Place),
(5, GrammarReferentKind::Cargo),
] {
let mut rows = fixture_rows();
edit(&mut rows, "verb-attack", |cells| {
cells[3][0][2] = json!(code)
});
let catalog = read(&rows).unwrap();
assert_eq!(
catalog.grammar.verb("attack"),
Some(&verb(RenderPath::ShipAction, &[("target", expected)])),
"code {code}"
);
}
}
fn faction_with_key(record: &str) -> Vec<CultCacheEnvelope> {
let mut rows = fixture_rows();
row_of(&mut rows, "faction-brannoch").key = record.into();
rows
}
#[test]
fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
for bad in [
"faction-cafe\u{301}",
"faction-brannoch ",
"faction-\u{202e}brannoch",
"",
] {
assert_eq!(
read(&faction_with_key(bad)),
Err(CatalogReadError::NonCanonicalKey {
record: bad.into(),
field: 0
}),
"{bad:?}"
);
}
}
#[test]
fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] {
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[12] = json!({ bad: 0.5 });
});
assert_eq!(
read(&rows),
Err(CatalogReadError::NonCanonicalKey {
record: "faction-brannoch".into(),
field: 12
}),
"{bad:?}"
);
}
}
/// The fixture's snapshot bytes with the record rows changed by `change`, for
/// what the store's own writer will not produce: it keeps one row per record
/// key and stores rows in key order.
fn spliced_catalog(
change: impl FnOnce(&mut Vec<rmpv::Value>),
) -> (tempfile::TempDir, std::path::PathBuf) {
let bytes = std::fs::read(FIXTURE).unwrap();
let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap()
else {
panic!("snapshot is not a tuple");
};
let rmpv::Value::Array(records) = &mut snapshot[2] else {
panic!("snapshot holds no records");
};
change(records);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.cc");
let mut spliced = Vec::new();
rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap();
std::fs::write(&path, spliced).unwrap();
(dir, path)
}
#[test]
fn two_faction_rows_under_one_record_key_are_refused() {
let (_dir, path) = spliced_catalog(|records| {
let twin = records
.iter()
.find(|record| record[0].as_str() == Some("faction-adrasteia"))
.unwrap()
.clone();
records.push(twin);
});
assert_eq!(
read_aetheria_catalog(&path),
Err(CatalogReadError::DuplicateFaction {
record: "faction-adrasteia".into()
})
);
}
#[test]
fn factions_are_read_in_record_key_order_whatever_the_file_order() {
let (_dir, path) = spliced_catalog(|records| {
records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str()));
assert_eq!(records[0][0].as_str(), Some("verb-speak"));
});
let names: Vec<_> = read_aetheria_catalog(&path)
.unwrap()
.factions
.into_iter()
.map(|faction| faction.name)
.collect();
assert_eq!(names, ["Adrasteia", "Brannoch"]);
}
#[test]
fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() {
for not_a_map in [
json!([]),
json!(["faction-adrasteia"]),
Value::Null,
json!(1),
] {
let mut rows = fixture_rows();
let shape = not_a_map.clone();
edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape);
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: 12
}),
"{not_a_map}"
);
}
}
/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]`
/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1).
#[test]
fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
let mut rows = fixture_rows();
let row = row_of(&mut rows, "faction-adrasteia");
let rmpv::Value::Array(mut cells) =
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
else {
panic!("not positional");
};
cells.push(rmpv::Value::Binary(vec![1, 0, 7]));
cells.push(rmpv::Value::Ext(
-1,
vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80],
));
let mut payload = Vec::new();
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
row.payload = payload;
assert_eq!(
read(&rows).unwrap(),
read_aetheria_catalog(FIXTURE).unwrap()
);
}
/// A faction record re-encoded with `change` applied to its decoded cells, for
/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes.
fn with_cells(key: &str, change: impl FnOnce(&mut Vec<rmpv::Value>)) -> Vec<CultCacheEnvelope> {
let mut rows = fixture_rows();
let row = row_of(&mut rows, key);
let rmpv::Value::Array(mut cells) =
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
else {
panic!("not positional");
};
change(&mut cells);
let mut payload = Vec::new();
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
row.payload = payload;
rows
}
#[test]
fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() {
for key in [
rmpv::Value::from(5),
rmpv::Value::Nil,
rmpv::Value::Binary(b"faction-adrasteia".to_vec()),
] {
let rows = with_cells("faction-brannoch", |cells| {
cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]);
});
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: 12
})
);
}
}
#[test]
fn text_that_is_not_utf8_is_malformed_by_key_and_field() {
// The marker is overwritten in the encoded bytes with an invalid sequence of
// the same length, since a decoded string cannot hold one.
const MARKER: &str = "ZZZZ";
for field in [1usize, 3] {
let mut rows = with_cells("faction-brannoch", |cells| {
cells[field] = rmpv::Value::from(MARKER);
});
let payload = &mut row_of(&mut rows, "faction-brannoch").payload;
let at = payload
.windows(MARKER.len())
.position(|window| window == MARKER.as_bytes())
.unwrap();
payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: field as u8
}),
"field {field}"
);
}
}

View file

@ -0,0 +1,213 @@
//! The world clock and the motion one tick produces.
//!
//! One entry point into the reducer, [`derive_motion`], reached from `reduce`'s
//! `AdvanceTime` arm and from `apply_effect`'s `TimeAdvanced` arm, so a forged
//! tick is re-derived by the same function that produced the honest one. It is
//! pure over `(state, to)`: no wall clock, no entropy, no caller value beyond
//! the span.
use super::patch::{Commitment, CommitmentKey, CommitmentKind, DependencyTarget, MAX_ROUTE_COST};
use super::{PressureMagnitude, PressureSource, SubjectId, WorldState, action, is_retired};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// Minutes since genesis. Monotonic, kernel-owned, world-level. `u64` because a
/// world clock has no bound; `Cost` is `1..=MAX_ROUTE_COST` because it weighs
/// one edge, and reusing it here would put an edge's bound on the world's age.
#[derive(
Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash,
)]
#[serde(transparent)]
pub struct FictionalMinutes(pub u64);
impl FictionalMinutes {
pub(crate) fn checked_add(self, span: TickMinutes) -> Option<Self> {
self.0.checked_add(u64::from(span.0)).map(Self)
}
/// Minutes elapsed since an earlier reading. Saturating because the caller
/// is the attention order, which reads a stamp `verify_state_shape` already
/// holds to `<= now`.
pub(crate) fn since(self, earlier: Self) -> u64 {
self.0.saturating_sub(earlier.0)
}
}
/// The span one `AdvanceTime` names. Constructor-checked `1..=MAX_ROUTE_COST`
/// (one year of minutes), reusing pass 2's constant as a span bound rather than
/// an edge bound: an unbounded span lets one command jump the clock past every
/// due date in the world and makes the size of a commit a caller-chosen number.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[serde(transparent)]
pub struct TickMinutes(u32);
impl TickMinutes {
pub fn new(minutes: u32) -> Option<Self> {
(1..=MAX_ROUTE_COST)
.contains(&minutes)
.then_some(Self(minutes))
}
pub fn minutes(self) -> u32 {
self.0
}
}
/// Everything one tick moves, stated as outcomes rather than deltas so the
/// effect says what the world became.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Motion {
/// Routines that fulfilled, in `(subject, key)` order.
pub(crate) fulfilled: Vec<RoutineFulfilled>,
/// Pressure rows written, in `(target, source)` order, with the resulting
/// magnitude — not the delta.
pub(crate) pressed: Vec<PressureWritten>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct RoutineFulfilled {
pub(crate) subject: SubjectId,
pub(crate) key: CommitmentKey,
pub(crate) next_due: FictionalMinutes,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct PressureWritten {
pub(crate) target: SubjectId,
pub(crate) source: PressureSource,
pub(crate) magnitude: PressureMagnitude,
}
/// The whole magnitude policy, in one place, with no per-world tuning. A
/// blocked routine raises none at all: it retries on the next tick, which is
/// what keeps an unattended subject free of spurious pressure.
mod step {
pub(super) const OBLIGATION_PAST_DUE: u32 = 1;
pub(super) const GOAL_PAST_DUE: u32 = 1;
pub(super) const DEPENDENCY_UNAVAILABLE: u32 = 1;
}
/// Three stages, each reading only committed `state` and `to`. No stage sees
/// another stage's writes, which is what makes this a one-pass total function
/// with no ordering hazard and no partial state to reason about.
///
/// Pressure accrues per command, not per minute: one thousand one-minute ticks
/// and one thousand-minute tick over the same span produce different
/// magnitudes. The kernel stays an exact function of the command sequence, and
/// the tick cadence is one operational constant in `runtime.rs`.
pub(super) fn derive_motion(state: &WorldState, to: FictionalMinutes) -> Motion {
let mut fulfilled = Vec::new();
let mut pressed: BTreeMap<(SubjectId, PressureSource), u32> = BTreeMap::new();
for (subject, held) in &state.commitments {
// A retired subject cannot act, and neither fulfilling a routine nor
// pressing an obligation or goal is anything but that subject acting:
// its commitments stay in history, un-discharged and un-deleted, but
// the clock stops moving them the moment they are its.
if is_retired(state, *subject) {
continue;
}
for (key, commitment) in held {
if commitment.due > to {
continue;
}
match commitment.kind {
CommitmentKind::Routine => {
if let Some(next_due) = routine_rolls(state, *subject, commitment) {
fulfilled.push(RoutineFulfilled {
subject: *subject,
key: *key,
next_due,
});
}
}
CommitmentKind::Obligation | CommitmentKind::Goal => {
let step = if commitment.kind == CommitmentKind::Obligation {
step::OBLIGATION_PAST_DUE
} else {
step::GOAL_PAST_DUE
};
let source = PressureSource::Commitment {
subject: *subject,
key: *key,
};
*pressed.entry((*subject, source)).or_default() += step;
}
}
}
}
for (subject, targets) in &state.dependencies {
if is_retired(state, *subject) {
continue;
}
for target in targets {
if dependency_unavailable(state, *subject, *target) {
let source = PressureSource::Dependency(*target);
*pressed.entry((*subject, source)).or_default() += step::DEPENDENCY_UNAVAILABLE;
}
}
}
Motion {
fulfilled,
pressed: pressed
.into_iter()
.map(|((target, source), step)| PressureWritten {
target,
source,
magnitude: PressureMagnitude(
state
.pressures
.get(&target)
.and_then(|held| held.get(&source))
.map_or(0, |magnitude| magnitude.0)
.saturating_add(step),
),
})
.collect(),
}
}
/// A routine fulfils when every one of its checks holds, and its `due` then
/// rolls forward by exactly one period per command. Catch-up arithmetic would
/// make the number of fulfilments a function of the operator's tick cadence in
/// a second place; one roll converges over subsequent ticks.
fn routine_rolls(
state: &WorldState,
subject: SubjectId,
commitment: &Commitment,
) -> Option<FictionalMinutes> {
if !action::preconditions_hold(state, subject, &commitment.checks).is_empty() {
return None;
}
commitment.due.checked_add(commitment.period?)
}
/// Whether a subject's dependency is currently failing it. A closed route is a
/// supply failure; an unusable one is a permission question, and reading
/// authority here would drag `effective_authority` into every tick for every
/// dependency in the world.
pub(super) fn dependency_unavailable(
state: &WorldState,
subject: SubjectId,
target: DependencyTarget,
) -> bool {
match target {
DependencyTarget::Route(edge_id) => !state
.edges
.get(&edge_id)
.is_some_and(super::EdgeRecord::is_open),
// Absence is zero, per pass 3: the depender holds none of what it
// depends on.
DependencyTarget::Resource(entity_id) => !state
.holdings
.get(&subject)
.is_some_and(|held| held.contains_key(&entity_id)),
// The subject it depends on is nowhere in the world's topology.
DependencyTarget::Subject(other) => !state.positions.contains_key(&other),
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,209 @@
//! The action grammar a world is bound to at creation.
//!
//! A grammar is the closed set of affordance kinds a world may declare, each
//! with its role signature and the one render path its acts take. The grammar's
//! home is the Aetheria repo; the kernel holds only this binding: the verbs it
//! enforces, and a content digest that names exactly which grammar that was.
//! The digest is derived here and nowhere else, so a binding cannot claim a
//! digest its verbs do not have.
use crate::SubjectKind;
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
/// How an act of one verb reaches the player: filmed in the engine as a ship
/// action, or shown as Aetheria's portrait-and-choices conversation.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum RenderPath {
ShipAction,
Conversation,
}
/// What a grammar role may be bound to, in the grammar's own vocabulary. Each
/// lowers to exactly one kernel `RefKind`.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum GrammarReferentKind {
Person,
Faction,
Population,
Place,
Cargo,
}
impl GrammarReferentKind {
fn ref_kind(self) -> RefKind {
match self {
Self::Person => RefKind::Subject(Some(SubjectKind::Person)),
Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)),
Self::Population => RefKind::Subject(Some(SubjectKind::Population)),
Self::Place => RefKind::Entity(EntityKind::Place),
Self::Cargo => RefKind::Entity(EntityKind::Resource),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct GrammarRole {
pub role: String,
pub referent: GrammarReferentKind,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct GrammarVerb {
pub render_path: RenderPath,
/// In signature order: the order is part of the verb.
pub roles: Vec<GrammarRole>,
}
impl GrammarVerb {
/// Whether a declared role list is this verb's signature: same names, same
/// referent kinds, same order.
pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool {
self.role_specs() == roles
}
/// This verb's signature as the kernel declares it: the one lowering of a
/// grammar role, so declaring a verb and admitting a declaration cannot
/// disagree.
pub(crate) fn role_specs(&self) -> Vec<RoleSpec> {
self.roles
.iter()
.map(|role| RoleSpec {
role: Role(role.role.clone()),
kind: role.referent.ref_kind(),
})
.collect()
}
}
/// Why a bound grammar refuses an affordance entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GrammarRefusal {
KindNotInGrammar,
RolesDisagree,
SpeakNotConversation,
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
pub enum GrammarError {
#[error("grammar schema id is empty")]
EmptySchemaId,
#[error("grammar declares one verb kind twice")]
DuplicateVerb,
#[error("grammar digest does not match its verbs")]
DigestMismatch,
}
/// Which grammar a world is bound to: its schema id, revision and content
/// digest. What a consumer reads; the verb table stays in the binding.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GrammarIdentity {
pub schema_id: String,
pub revision: u32,
pub digest: String,
}
/// The grammar a world was created under. Immutable: the only constructor
/// derives the digest, and deserialisation re-derives it and refuses a mismatch.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(try_from = "RawBinding")]
pub struct GrammarBinding {
schema_id: String,
revision: u32,
digest: String,
verbs: BTreeMap<String, GrammarVerb>,
}
#[derive(Deserialize)]
struct RawBinding {
schema_id: String,
revision: u32,
digest: String,
verbs: BTreeMap<String, GrammarVerb>,
}
impl TryFrom<RawBinding> for GrammarBinding {
type Error = GrammarError;
fn try_from(raw: RawBinding) -> Result<Self, GrammarError> {
let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?;
if bound.digest != raw.digest {
return Err(GrammarError::DigestMismatch);
}
Ok(bound)
}
}
impl GrammarBinding {
/// The one constructor. Verbs arrive in any order and are keyed by kind;
/// the digest is the sha256 of the canonical encoding of the kind-sorted
/// verbs, so the same verbs bind to the same digest however they were listed.
pub fn new(
schema_id: impl Into<String>,
revision: u32,
verbs: impl IntoIterator<Item = (String, GrammarVerb)>,
) -> Result<Self, GrammarError> {
let schema_id = schema_id.into();
if schema_id.is_empty() {
return Err(GrammarError::EmptySchemaId);
}
let mut table = BTreeMap::new();
for (kind, verb) in verbs {
if table.insert(kind, verb).is_some() {
return Err(GrammarError::DuplicateVerb);
}
}
let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes");
let digest = format!("sha256:{:x}", Sha256::digest(encoded));
Ok(Self {
schema_id,
revision,
digest,
verbs: table,
})
}
pub fn identity(&self) -> GrammarIdentity {
GrammarIdentity {
schema_id: self.schema_id.clone(),
revision: self.revision,
digest: self.digest.clone(),
}
}
/// Every verb, in kind order.
pub(crate) fn verbs(&self) -> impl Iterator<Item = (&str, &GrammarVerb)> {
self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb))
}
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
self.verbs.get(kind)
}
/// The one grammar check. The resolver turns a refusal into a mismatch and
/// `admit_resolved`, the only writer of the affordance catalog, into an
/// invariant error, so no path adds an entry without passing it.
pub(crate) fn refusal(
&self,
kind: &AffordanceKindName,
roles: &[RoleSpec],
) -> Option<GrammarRefusal> {
let Some(verb) = self.verb(&kind.0) else {
return Some(GrammarRefusal::KindNotInGrammar);
};
if !verb.admits_roles(roles) {
return Some(GrammarRefusal::RolesDisagree);
}
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
return Some(GrammarRefusal::SpeakNotConversation);
}
None
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,745 @@
use super::*;
use crate::patch::{
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind,
Mismatch, OutcomeBand, Precondition, Role, RoleSpec,
};
use crate::tests::{auth_principal, command, creation, owner};
use crate::{
CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt,
WorldId, WorldKernel, WorldPatch,
};
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
GrammarVerb {
render_path: path,
roles: roles
.iter()
.map(|(role, referent)| GrammarRole {
role: (*role).into(),
referent: *referent,
})
.collect(),
}
}
/// What the fixture world's genesis needs: the kernel speak entry and the
/// fixture's own `convene`. Anything else a test wants is added to it.
fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding {
let mut verbs = vec![
("speak".to_owned(), verb(RenderPath::Conversation, &[])),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
];
verbs.extend(
extra
.iter()
.map(|(kind, v)| ((*kind).to_owned(), v.clone())),
);
GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap()
}
fn bound(grammar: Option<GrammarBinding>, title: &str) -> CreateWorld {
let mut world = creation(CommandId::new(), title);
world.grammar = grammar;
world
}
fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration {
Declaration::Affordance(AffordanceDeclaration {
handle: DraftHandle::new(handle),
kind: AffordanceKindName(kind.into()),
roles: roles
.iter()
.map(|(role, kind)| RoleSpec {
role: Role((*role).into()),
kind: *kind,
})
.collect(),
// Speech with one audience is the smallest entry the resolver does
// not refuse as inert; the grammar check does not read it.
preconditions: vec![Precondition::CanBroadcast {
via: AudienceSpec::Colocated,
}],
effect_slots: Vec::new(),
outcome_bands: vec![OutcomeBand {
weight: 1,
effects: Vec::new(),
}],
carries_speech: true,
})
}
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
WorldPatch {
declarations,
operations: Vec::new(),
evidence: Vec::new(),
}
}
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
match result {
Err(KernelError::PatchRejected(set)) => set,
Err(other) => panic!("expected a patch refusal, got {other:?}"),
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
}
}
/// Each call gets its own store path: kernels are created, not reopened.
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let directory = tempfile::tempdir().unwrap();
let result = WorldKernel::create(
directory.path().join("world.cc"),
world,
&auth_principal(owner()),
)
.map(|(kernel, _)| kernel);
(directory, result)
}
fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
CallerId::Principal(owner()),
CommandBody::AdmitPatch {
answers: None,
patch,
},
),
&auth_principal(owner()),
)
}
fn person() -> RefKind {
RefKind::Subject(Some(SubjectKind::Person))
}
fn place() -> RefKind {
RefKind::Entity(EntityKind::Place)
}
fn not_in_grammar(handle: &str) -> Vec<Mismatch> {
vec![Mismatch::AffordanceKindNotInGrammar {
handle: DraftHandle::new(handle),
}]
}
fn disagrees(handle: &str) -> Vec<Mismatch> {
vec![Mismatch::AffordanceRolesDisagreeWithGrammar {
handle: DraftHandle::new(handle),
}]
}
#[test]
fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() {
// Genesis: a declared kind the binding lacks is refused.
let mut world = bound(Some(grammar_with(&[])), "Outside");
world.patch.declarations.push(declare("wave", "wave", &[]));
assert_eq!(rejected(create(world).1), not_in_grammar("wave"));
// A later AdmitPatch: the same kind is refused, and a verb the grammar
// does carry is admitted by the same path.
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside"));
let mut kernel = kernel.unwrap();
assert_eq!(
rejected(admit(
&mut kernel,
patch_of(vec![declare("wave", "wave", &[])])
)),
not_in_grammar("wave")
);
let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() {
let extra = [
(
"meet",
verb(
RenderPath::ShipAction,
&[
("who", GrammarReferentKind::Person),
("where_", GrammarReferentKind::Place),
],
),
),
("hail", verb(RenderPath::Conversation, &[])),
];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures"));
let mut kernel = kernel.unwrap();
for (label, roles) in [
("renamed", vec![("whom", person()), ("where_", place())]),
("rekinded", vec![("who", person()), ("where_", person())]),
("swapped", vec![("where_", place()), ("who", person())]),
("short", vec![("who", person())]),
(
"long",
vec![("who", person()), ("where_", place()), ("why", person())],
),
] {
let set = rejected(admit(
&mut kernel,
patch_of(vec![declare(label, "meet", &roles)]),
));
assert_eq!(set, disagrees(label), "{label}");
}
// A role on a verb the grammar declares with none.
let set = rejected(admit(
&mut kernel,
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
));
assert_eq!(set, disagrees("hail"));
// The exact signature is admitted.
let receipt = admit(
&mut kernel,
patch_of(vec![declare(
"meet",
"meet",
&[("who", person()), ("where_", place())],
)]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() {
let kinds = [
(GrammarReferentKind::Person, person()),
(
GrammarReferentKind::Faction,
RefKind::Subject(Some(SubjectKind::Institution)),
),
(
GrammarReferentKind::Population,
RefKind::Subject(Some(SubjectKind::Population)),
),
(GrammarReferentKind::Place, place()),
(
GrammarReferentKind::Cargo,
RefKind::Entity(EntityKind::Resource),
),
];
for (referent, own) in &kinds {
let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))];
for (_, offered) in &kinds {
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents"));
let mut kernel = kernel.unwrap();
let result = admit(
&mut kernel,
patch_of(vec![declare("act", "act", &[("with", *offered)])]),
);
if offered == own {
assert!(
matches!(result, Ok(SubmitReceipt::Applied(_))),
"{referent:?} refused its own kind"
);
} else {
assert_eq!(
rejected(result),
disagrees("act"),
"{referent:?} admitted {offered:?}"
);
}
}
}
}
#[test]
fn grammar_world_without_speak_refuses_the_kernel_speak_grant() {
let no_speak = GrammarBinding::new(
"aetheria.grammar",
1,
[("convene".to_owned(), verb(RenderPath::Conversation, &[]))],
)
.unwrap();
assert_eq!(
rejected(create(bound(Some(no_speak), "Mute")).1),
not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE)
);
// The kernel entry is held to the signature as well as the kind.
let loud = GrammarBinding::new(
"aetheria.grammar",
1,
[
(
"speak".to_owned(),
verb(
RenderPath::Conversation,
&[("to", GrammarReferentKind::Person)],
),
),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
],
)
.unwrap();
assert_eq!(
rejected(create(bound(Some(loud), "Loud")).1),
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
);
}
#[test]
fn unbound_world_keeps_the_open_catalog() {
let mut world = bound(None, "Open");
world
.patch
.declarations
.push(declare("wave", "wave", &[("at", person())]));
let (_dir, kernel) = create(world);
let mut kernel = kernel.unwrap();
let receipt = admit(
&mut kernel,
patch_of(vec![declare("beacon", "beacon", &[])]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let snapshot = kernel.snapshot().unwrap();
assert!(snapshot.grammar.is_none());
assert!(snapshot.affordances.len() >= 4);
assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none()));
// The state row of a world with no grammar carries no grammar key, so its
// bytes and digests are those of a world written before grammars existed.
let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap();
let keys: std::collections::BTreeMap<String, serde::de::IgnoredAny> =
rmp_serde::from_slice(&encoded).unwrap();
assert!(!keys.contains_key("grammar"));
assert!(keys.contains_key("brief"));
}
#[test]
fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() {
let extra = [("dock", verb(RenderPath::ShipAction, &[]))];
let grammar = grammar_with(&extra);
let identity = grammar.identity();
let (_dir, kernel) = create(bound(Some(grammar), "Reports"));
let mut kernel = kernel.unwrap();
admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap();
let snapshot = kernel.snapshot().unwrap();
assert_eq!(snapshot.grammar, Some(identity));
let path_of = |kind: &str| {
snapshot
.affordances
.iter()
.find(|a| a.entry.kind.0 == kind)
.unwrap_or_else(|| panic!("{kind} is in the catalog"))
.render_path
};
assert_eq!(path_of("dock"), Some(RenderPath::ShipAction));
assert_eq!(path_of("convene"), Some(RenderPath::Conversation));
assert_eq!(path_of("speak"), Some(RenderPath::Conversation));
}
#[test]
fn a_reopened_grammar_world_still_holds_its_grammar() {
let grammar = grammar_with(&[]);
let identity = grammar.identity();
let (directory, kernel) = create(bound(Some(grammar), "Reopened"));
let kernel = kernel.unwrap();
let world_id = kernel.snapshot().unwrap().world_id;
drop(kernel);
let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap();
assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity));
assert_eq!(
rejected(admit(
&mut reopened,
patch_of(vec![declare("wave", "wave", &[])])
)),
not_in_grammar("wave")
);
}
#[test]
fn the_bound_grammar_is_part_of_the_world_state_digest() {
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested"));
let kernel = kernel.unwrap();
let bound_digest = crate::state_digest(&kernel.state).unwrap();
let mut state = kernel.state.clone();
state.grammar = None;
let open_digest = crate::state_digest(&state).unwrap();
state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))]));
let other_digest = crate::state_digest(&state).unwrap();
assert_ne!(bound_digest, open_digest);
assert_ne!(bound_digest, other_digest);
}
#[test]
fn grammar_digest_is_derived_and_order_free() {
let forward = [
("alpha".to_owned(), verb(RenderPath::ShipAction, &[])),
(
"beta".to_owned(),
verb(
RenderPath::Conversation,
&[("to", GrammarReferentKind::Person)],
),
),
("gamma".to_owned(), verb(RenderPath::Conversation, &[])),
];
let mut reversed = forward.clone();
reversed.reverse();
let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap();
let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap();
assert_eq!(a.digest, b.digest);
assert_eq!(a, b);
// Content-derived: each part of a verb moves the digest.
let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| {
let mut verbs = forward.to_vec();
edit(&mut verbs);
GrammarBinding::new("aetheria.grammar", 1, verbs)
.unwrap()
.digest
};
assert_ne!(
a.digest,
changed(&|v| v[0].1.render_path = RenderPath::Conversation)
);
assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into()));
assert_ne!(
a.digest,
changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction)
);
assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into()));
assert_ne!(
a.digest,
changed(&|v| {
v.pop();
})
);
// Role order is part of a verb.
let ordered = |roles: &[(&str, GrammarReferentKind)]| {
GrammarBinding::new(
"g",
1,
[("meet".to_owned(), verb(RenderPath::ShipAction, roles))],
)
.unwrap()
.digest
};
assert_ne!(
ordered(&[
("a", GrammarReferentKind::Person),
("b", GrammarReferentKind::Place)
]),
ordered(&[
("b", GrammarReferentKind::Place),
("a", GrammarReferentKind::Person)
])
);
// The identity reads the same fields back.
let identity = a.identity();
assert_eq!(identity.schema_id, "aetheria.grammar");
assert_eq!(identity.revision, 1);
assert_eq!(identity.digest, a.digest);
}
#[test]
fn a_tampered_grammar_digest_fails_to_deserialise() {
let binding = grammar_with(&[]);
let value = serde_json::to_value(&binding).unwrap();
// Untouched: the round trip holds.
let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap();
assert_eq!(back, binding);
// A digest the verbs do not have.
let mut forged = value.clone();
forged["digest"] = "sha256:00".into();
assert!(serde_json::from_value::<GrammarBinding>(forged.clone()).is_err());
// A verb edited under the digest it already had.
let mut edited = value;
edited["verbs"]["convene"]["render_path"] = "ship_action".into();
assert!(serde_json::from_value::<GrammarBinding>(edited).is_err());
// The named encoding a state row uses refuses it the same way.
let bytes = rmp_serde::to_vec_named(&forged).unwrap();
assert!(rmp_serde::from_slice::<GrammarBinding>(&bytes).is_err());
}
#[test]
fn a_grammar_names_each_verb_once_and_has_a_schema() {
assert_eq!(
GrammarBinding::new("", 1, Vec::new()).unwrap_err(),
GrammarError::EmptySchemaId
);
assert_eq!(
GrammarBinding::new(
"g",
1,
[
("hail".to_owned(), verb(RenderPath::Conversation, &[])),
("hail".to_owned(), verb(RenderPath::ShipAction, &[])),
]
)
.unwrap_err(),
GrammarError::DuplicateVerb
);
}
/// The fixture world was written by the code at base 367e2eb, which has no
/// grammar field anywhere. Reopening it must reproduce the digests that code
/// recorded.
#[test]
fn existing_world_reopens_with_unchanged_digests() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("world.cc");
std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap();
let world_id: WorldId =
serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap();
let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens");
let snapshot = kernel.snapshot().unwrap();
let recorded = include_str!("../../tests/fixtures/base_world.digests");
let mut lines = recorded.lines();
assert_eq!(snapshot.revision.to_string(), lines.next().unwrap());
assert_eq!(snapshot.state_digest, lines.next().unwrap());
assert_eq!(
snapshot.last_commit_digest.as_deref(),
Some(lines.next().unwrap())
);
assert!(snapshot.grammar.is_none());
// Its state still derives the recorded digest from its bytes.
assert_eq!(
crate::state_digest(&kernel.state).unwrap(),
snapshot.state_digest
);
}
fn submit_as(
kernel: &mut WorldKernel,
caller: CallerId,
answers: Option<crate::PatchAnswer>,
patch: WorldPatch,
) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
caller.clone(),
CommandBody::AdmitPatch { answers, patch },
),
&crate::AuthenticatedCaller::fixture(caller),
)
}
/// A bound Draft world whose revision has moved past genesis: one owner patch
/// that adds a dead-end place the elaborator can answer.
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
let mut kernel = kernel.unwrap();
let commons = *kernel.state.entities.keys().next().unwrap();
admit(
&mut kernel,
patch_of(vec![
Declaration::Entity(crate::patch::EntityDeclaration {
handle: DraftHandle::new("dead-end"),
label: "The Unwalked Road".into(),
kind: EntityKind::Place,
container: None,
}),
Declaration::Route(crate::patch::RouteDeclaration {
handle: DraftHandle::new("gate"),
label: "The Field Gate".into(),
from: crate::patch::Ref::Existing(commons),
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
access: crate::patch::AccessKind::Public,
cost: crate::patch::Cost(1),
}),
]),
)
.unwrap();
let dead_end = *kernel
.state
.entities
.iter()
.find(|(_, record)| record.label == "The Unwalked Road")
.map(|(id, _)| id)
.unwrap();
(dir, kernel, dead_end)
}
type Door = (&'static str, CallerId, Option<crate::PatchAnswer>);
/// Each door refuses an outside kind and a wrong role signature, typed, and the
/// refusal leaves the revision where it was. The owner and the consumer declare
/// unanswered only in Draft; Play and the elaborator act in Active.
fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec<Door>) {
for (door, caller, answers) in doors {
let revision = kernel.state.revision;
let kind = submit_as(
kernel,
caller.clone(),
answers.clone(),
patch_of(vec![declare("wave", "wave", &[])]),
);
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
let roles = submit_as(
kernel,
caller,
answers,
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
);
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
assert_eq!(
kernel.state.revision, revision,
"{door} refusal moved the world"
);
}
}
#[test]
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
assert_eq!(kernel.state.revision, 1);
let draft_doors = vec![
("owner", CallerId::Principal(owner()), None),
(
"consumer",
CallerId::System(crate::SystemCapability::Consumer {
consumer: crate::ConsumerId::of_name("grammar-door"),
}),
None,
),
];
refuse_at_each_door(&mut kernel, draft_doors);
crate::tests::activate(&mut kernel);
assert!(
kernel.state.revision > 1,
"the world has moved past genesis"
);
let answer = crate::derive_boundaries(&kernel.state)
.unwrap()
.into_iter()
.find(|boundary| {
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
if *place == dead_end)
})
.expect("the dead end is an unelaborated destination");
let active_doors = vec![
(
"play",
CallerId::System(crate::SystemCapability::Play),
None,
),
(
"elaborator",
CallerId::System(crate::SystemCapability::Elaborator {
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(crate::PatchAnswer::Boundary(answer)),
),
];
refuse_at_each_door(&mut kernel, active_doors);
// The same path admits what the grammar carries, so the refusals above are
// the grammar's and not a closed door.
let receipt = submit_as(
&mut kernel,
CallerId::System(crate::SystemCapability::Play),
None,
patch_of(vec![declare("hail", "hail", &[])]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
let ship = |roles: &[(&str, GrammarReferentKind)]| {
GrammarBinding::new(
"aetheria.grammar",
1,
[
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
],
)
.unwrap()
};
assert_eq!(
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
}]
);
// A ShipAction speak with a role fails on the signature first and is still
// refused: no variant of speak but zero roles, Conversation, is admitted.
assert_eq!(
rejected(
create(bound(
Some(ship(&[("to", GrammarReferentKind::Person)])),
"Loud"
))
.1
),
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
);
// The control: Conversation speak binds.
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
}
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
/// a world with no grammar must not install an entry the bound grammar lacks:
/// the catalog's only writer holds the grammar check itself.
#[test]
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
let extra = [
(
"meet",
verb(
RenderPath::ShipAction,
&[("who", GrammarReferentKind::Person)],
),
),
("hail", verb(RenderPath::Conversation, &[])),
];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged"));
let kernel = kernel.unwrap();
let mut twin = kernel.state.clone();
twin.grammar = None;
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
crate::patch::resolve_patch(
&twin,
CommandId::new(),
&patch_of(vec![declare("probe", kind, roles)]),
None,
None,
)
.expect("the unbound twin admits any canonical kind")
};
for (label, resolved, admitted) in [
("outside kind", resolve("wave", &[]), false),
("wrong roles", resolve("meet", &[("whom", person())]), false),
("carried verb", resolve("hail", &[]), true),
] {
let mut state = kernel.state.clone();
let catalog = state.affordance_catalog.len();
let result = crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&crate::WorldEffect::PatchAdmitted {
answers: None,
resolved,
},
);
if admitted {
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
} else {
assert!(
matches!(result, Err(KernelError::Invariant(_))),
"{label}: {result:?}"
);
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
}
}
}

View file

@ -0,0 +1,752 @@
//! One record, one subject: admission keeps the import-key mapping injective at
//! every door and at every revision, and a world nobody imported into is
//! byte-for-byte what it was before keys existed.
use crate::patch::{
AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind,
ImportKey, Mismatch, Ref, RouteDeclaration,
};
use crate::tests::{activate, auth_principal, command, creation, owner};
use crate::{
CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey,
KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt,
SystemCapability, WorldKernel, WorldPatch,
};
use std::collections::BTreeSet;
fn key(schema: &str, record: &str) -> ImportKey {
ImportKey::new(schema, record)
}
fn alpha() -> ImportKey {
key("aetheria.ship", "Alpha")
}
/// The fixture genesis with its witness imported from `alpha()`.
fn keyed_creation(title: &str) -> CreateWorld {
let mut world = creation(CommandId::new(), title);
for declaration in &mut world.patch.declarations {
if let Declaration::Subject(subject) = declaration
&& subject.handle == DraftHandle::new("persona")
{
subject.import_key = Some(alpha());
}
}
world
}
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let directory = tempfile::tempdir().unwrap();
let result = WorldKernel::create(
directory.path().join("world.cc"),
world,
&auth_principal(owner()),
)
.map(|(kernel, _)| kernel);
(directory, result)
}
fn keyed_world() -> (tempfile::TempDir, WorldKernel) {
let (dir, kernel) = create(keyed_creation("Keyed"));
(dir, kernel.unwrap())
}
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
match result {
Err(KernelError::PatchRejected(set)) => set,
Err(other) => panic!("expected a patch refusal, got {other:?}"),
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
}
}
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
WorldPatch {
declarations,
operations: Vec::new(),
evidence: Vec::new(),
}
}
fn submit_as(
kernel: &mut WorldKernel,
caller: CallerId,
answers: Option<PatchAnswer>,
patch: WorldPatch,
) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
caller.clone(),
CommandBody::AdmitPatch { answers, patch },
),
&crate::AuthenticatedCaller::fixture(caller),
)
}
fn commons(kernel: &WorldKernel) -> EntityId {
*kernel.state.entities.keys().next().unwrap()
}
/// A mirror subject: the one shape every door may declare, since it needs no
/// catalog grant.
fn mirror(handle: &str, import_key: Option<ImportKey>, place: EntityId) -> Declaration {
Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new(handle),
label: format!("The {handle}"),
kind: SubjectKind::Person,
controller: NewController::External {
consumer: ConsumerId::of_name("import-key"),
},
affordances: BTreeSet::new(),
position: Some(Ref::Existing(place)),
import_key,
})
}
fn held(handle: &str) -> Mismatch {
Mismatch::ImportKeyHeld {
handle: DraftHandle::new(handle),
}
}
fn duplicate(handle: &str) -> Mismatch {
Mismatch::DuplicateImportKey {
handle: DraftHandle::new(handle),
}
}
fn malformed(handle: &str) -> Mismatch {
Mismatch::MalformedImportKey {
handle: DraftHandle::new(handle),
}
}
fn keys_in(kernel: &WorldKernel) -> Vec<ImportKey> {
kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.filter_map(|subject| subject.import_key)
.collect()
}
#[test]
fn one_patch_cannot_declare_two_subjects_with_one_import_key() {
// At genesis.
let mut world = creation(CommandId::new(), "Twins");
for declaration in &mut world.patch.declarations {
if let Declaration::Subject(subject) = declaration
&& (subject.handle == DraftHandle::new("persona")
|| subject.handle == DraftHandle::new("operator"))
{
subject.import_key = Some(alpha());
}
}
let (_dir, result) = create(world);
assert_eq!(rejected(result), vec![duplicate("operator")]);
// In a later patch, with the key unheld.
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let revision = kernel.state.revision;
let beta = Some(key("aetheria.ship", "Beta"));
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![
mirror("first", beta.clone(), place),
mirror("second", beta, place),
]),
);
assert_eq!(rejected(error), vec![duplicate("second")]);
assert_eq!(kernel.state.revision, revision, "a refusal moved the world");
}
/// The fixture world past genesis but still in Draft: the owner has added an
/// unwalked place the elaborator can later answer.
fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) {
let (dir, mut kernel) = keyed_world();
let place = commons(&kernel);
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![
Declaration::Entity(EntityDeclaration {
handle: DraftHandle::new("dead-end"),
label: "The Unwalked Road".into(),
kind: EntityKind::Place,
container: None,
}),
Declaration::Route(RouteDeclaration {
handle: DraftHandle::new("gate"),
label: "The Field Gate".into(),
from: Ref::Existing(place),
to: Ref::Draft(DraftHandle::new("dead-end")),
access: AccessKind::Public,
cost: Cost(1),
}),
]),
)
.unwrap();
let dead_end = *kernel
.state
.entities
.iter()
.find(|(_, record)| record.label == "The Unwalked Road")
.map(|(id, _)| id)
.unwrap();
(dir, kernel, dead_end)
}
/// Each door names the held key, typed, and the refusal leaves the revision
/// where it was. The owner and the consumer declare in Draft only; Play and the
/// elaborator act in Active.
fn refuse_held_key_at_each_door(
kernel: &mut WorldKernel,
place: EntityId,
doors: Vec<(&str, CallerId, Option<PatchAnswer>)>,
) {
for (door, caller, answers) in doors {
let revision = kernel.state.revision;
let result = submit_as(
kernel,
caller,
answers,
patch_of(vec![mirror("copy", Some(alpha()), place)]),
);
assert_eq!(rejected(result), vec![held("copy")], "{door}");
assert_eq!(
kernel.state.revision, revision,
"{door} refusal moved the world"
);
}
}
fn consumer() -> CallerId {
CallerId::System(SystemCapability::Consumer {
consumer: ConsumerId::of_name("import-key"),
})
}
#[test]
fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() {
let (_dir, mut kernel, dead_end) = advanced_keyed_world();
assert!(kernel.state.revision > 0, "the world has moved past genesis");
refuse_held_key_at_each_door(
&mut kernel,
dead_end,
vec![
("owner", CallerId::Principal(owner()), None),
("consumer", consumer(), None),
],
);
activate(&mut kernel);
let answer = crate::derive_boundaries(&kernel.state)
.unwrap()
.into_iter()
.find(|boundary| {
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
if *place == dead_end)
})
.expect("the dead end is an unelaborated destination");
refuse_held_key_at_each_door(
&mut kernel,
dead_end,
vec![
("play", CallerId::System(SystemCapability::Play), None),
(
"elaborator",
CallerId::System(SystemCapability::Elaborator {
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(PatchAnswer::Boundary(answer.clone())),
),
],
);
// The same doors admit an unheld key, so the refusals are the key's; and
// what a door admitted is held against every later door.
let fresh = key("aetheria.ship", "Gamma");
let receipt = submit_as(
&mut kernel,
CallerId::System(SystemCapability::Elaborator {
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(PatchAnswer::Boundary(answer)),
patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let again = submit_as(
&mut kernel,
CallerId::System(SystemCapability::Play),
None,
patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]),
);
assert_eq!(rejected(again), vec![held("fresh-again")]);
}
#[test]
fn a_held_import_key_is_refused_in_a_later_patch() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let revision = kernel.state.revision;
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("again", Some(alpha()), place)]),
);
assert_eq!(rejected(error), vec![held("again")]);
assert_eq!(kernel.state.revision, revision);
assert_eq!(keys_in(&kernel), vec![alpha()]);
}
#[test]
fn a_retired_subjects_import_key_is_never_reissued() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let witness = kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.find(|subject| subject.import_key.is_some())
.unwrap()
.id;
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
WorldPatch {
declarations: Vec::new(),
operations: vec![ComponentOp::Retire {
subject: Ref::Existing(witness),
}],
evidence: Vec::new(),
},
)
.unwrap();
let retired = kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.find(|subject| subject.id == witness)
.unwrap();
assert!(retired.retired, "the witness is retired");
assert_eq!(
retired.import_key,
Some(alpha()),
"a retired subject keeps its key"
);
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("reborn", Some(alpha()), place)]),
);
assert_eq!(rejected(error), vec![held("reborn")]);
}
#[test]
fn keys_that_differ_in_any_way_are_distinct_records() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
// Same length, case, prefix, the same concatenation split elsewhere, and
// the parts swapped: none is the held key.
let forgeries = [
key("aetheria.ship", "Alphb"),
key("aetheria.ship", "alpha"),
key("aetheria.ship", "Alph"),
key("aetheria.ship", "Alpha2"),
key("aetheria.shipA", "lpha"),
key("aetheria.sh", "ipAlpha"),
key("Aetheria.ship", "Alpha"),
key("Alpha", "aetheria.ship"),
key("aetheria.ship2", "Alpha"),
];
let declarations = forgeries
.iter()
.enumerate()
.map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place))
.collect();
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(declarations),
)
.expect("nine distinct keys beside the held one are admitted together");
let held_now: BTreeSet<ImportKey> = keys_in(&kernel).into_iter().collect();
assert_eq!(held_now.len(), forgeries.len() + 1);
// And each is then held exactly: repeating any one is refused.
for (index, forged) in forgeries.iter().enumerate() {
let again = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("repeat", Some(forged.clone()), place)]),
);
assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}");
}
}
#[test]
fn a_malformed_import_key_is_refused_without_echoing_it() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let long = "x".repeat(201);
let wide = "é".repeat(101);
let cases = [
key("", "Delta"),
key("aetheria.ship", ""),
key("aetheria ship", "Delta"),
key("aetheria.ship", "Del\tta"),
key("aetheria.ship", "Del\u{a0}ta"),
key("aetheria.ship", " Delta"),
key("aetheria.ship", "Delta\n"),
key(&long, "Delta"),
key("aetheria.ship", &long),
key("aetheria.ship", &wide),
key(&wide, "Delta"),
];
for (index, bad) in cases.iter().enumerate() {
let set = rejected(submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("bad", Some(bad.clone()), place)]),
));
assert_eq!(set, vec![malformed("bad")], "case {index}");
let spelled = format!("{set:?}");
assert!(
!spelled.contains("Delta") && !spelled.contains("aetheria"),
"case {index} echoed the key"
);
}
// The limit is 200 bytes per part, counted in bytes.
let edge = "é".repeat(100);
let at_limit = [
key(&"x".repeat(200), &edge),
key("aetheria.ship", &"y".repeat(200)),
];
let declarations = at_limit
.iter()
.enumerate()
.map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place))
.collect();
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(declarations),
)
.expect("keys of exactly 200 bytes per part are admitted");
}
#[test]
fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() {
let (_dir, kernel) = keyed_world();
let place = commons(&kernel);
let resolve = |handle: &str| {
crate::patch::resolve_patch(
&kernel.state,
CommandId::new(),
&patch_of(vec![mirror(
handle,
Some(key("aetheria.ship", "Omega")),
place,
)]),
None,
None,
)
.expect("the key is unheld at the revision both were resolved against")
};
let (first, second) = (resolve("first"), resolve("second"));
let mut state = kernel.state.clone();
let before = state.subjects.len();
let effect = |resolved| crate::WorldEffect::PatchAdmitted {
answers: None,
resolved,
};
crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&effect(first),
)
.unwrap();
assert_eq!(state.subjects.len(), before + 1);
let result = crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&effect(second),
);
assert!(
matches!(result, Err(KernelError::Invariant(_))),
"{result:?}"
);
assert_eq!(
state.subjects.len(),
before + 1,
"the second subject was installed"
);
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|window| window == needle)
}
#[test]
fn keyless_world_digests_unchanged() {
// A subject nobody imported serialises without the field at all, so every
// digest written before keys existed is the digest of the same bytes.
let (_dir, kernel) = create(creation(CommandId::new(), "Keyless"));
let kernel = kernel.unwrap();
let state = rmp_serde::to_vec_named(&kernel.state).unwrap();
assert!(
!contains(&state, b"import_key"),
"a keyless world grew a field"
);
// A keyed world carries it, round-trips, and digests differently.
let (_dir, keyed) = keyed_world();
let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap();
assert!(contains(&keyed_state, b"import_key"));
let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap();
assert_eq!(
crate::state_digest(&back).unwrap(),
crate::state_digest(&keyed.state).unwrap()
);
assert_ne!(
crate::state_digest(&keyed.state).unwrap(),
crate::state_digest(&kernel.state).unwrap()
);
// And a pre-key state deserialises with every key absent.
let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap();
assert!(stripped.subjects.values().all(|s| s.import_key.is_none()));
}
#[test]
fn a_keyed_world_reopens_with_its_keys_and_digests() {
let (dir, kernel) = keyed_world();
let snapshot = kernel.snapshot().unwrap();
let world_id = snapshot.world_id;
drop(kernel);
let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap();
let again = reopened.snapshot().unwrap();
assert_eq!(again.state_digest, snapshot.state_digest);
let keys = keys_in(&reopened);
assert_eq!(keys, vec![alpha()]);
// A reader of the snapshot gets both parts back, separately.
assert_eq!(keys[0].schema(), "aetheria.ship");
assert_eq!(keys[0].record(), "Alpha");
}
#[test]
fn a_record_key_is_one_canonical_spelling() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let nfc = "Caf\u{e9}";
let nfd = "Cafe\u{301}";
let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.ship", record)),
place,
)]),
)
};
submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted");
let refused = [
nfd,
"Alpha\u{200b}",
"Alpha\u{0}",
"\u{feff}Alpha",
"Alpha\u{202e}",
"Alpha\u{7}",
"Del ta",
"Del\tta",
"Del\u{a0}ta",
"Del\u{2003}ta",
"Del\nta",
// The violation sits deep in a long, real-shaped path, past any prefix.
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}",
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart",
];
for (index, record) in refused.iter().enumerate() {
let set = rejected(submit(&mut kernel, "bad", record));
assert_eq!(set, vec![malformed("bad")], "spelling {index}");
assert!(
!format!("{set:?}").contains("Alpha"),
"spelling {index} echoed the key"
);
}
assert_eq!(
keys_in(&kernel).len(),
2,
"only the genesis key and the NFC key are held"
);
}
#[test]
fn a_lore_page_path_with_spaces_is_a_record_key() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart";
let declare = |kernel: &mut WorldKernel, handle: &str| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.lore", record)),
place,
)]),
)
};
declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted");
assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]);
}
#[test]
fn a_loaded_state_with_a_bad_import_key_is_refused() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
"beta",
Some(key("aetheria.ship", "Beta")),
place,
)]),
)
.unwrap();
assert!(crate::journal::verify_state_shape(&kernel.state).is_ok());
let corrupt = |edit: &dyn Fn(&mut ImportKey)| {
let mut state = kernel.state.clone();
for subject in state.subjects.values_mut() {
if let Some(held) = subject.import_key.as_mut() {
edit(held);
}
}
crate::journal::verify_state_shape(&state)
};
let shared = corrupt(&|held| *held = alpha());
assert!(
matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")),
"two subjects sharing one key: {shared:?}"
);
assert!(
corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(),
"a key with edge spaces must not load"
);
assert!(
corrupt(&|held| *held = key("", "")).is_err(),
"an empty key must not load"
);
assert!(
corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(),
"a key with a format character must not load"
);
}
/// The admission backstop, reached with an effect resolution never produced: a
/// resolved patch whose two subjects carry one key. The control is the same
/// effect with distinct keys, admitted by the same function.
#[test]
fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() {
let (_dir, kernel) = keyed_world();
let place = commons(&kernel);
let resolve = || {
crate::patch::resolve_patch(
&kernel.state,
CommandId::new(),
&patch_of(vec![
mirror("first", Some(key("aetheria.ship", "Beta")), place),
mirror("second", Some(key("aetheria.ship", "Gamma")), place),
]),
None,
None,
)
.expect("two distinct keys resolve")
};
let resolves_to = crate::resolution_revision(&kernel.state).unwrap();
let mut control = kernel.state.clone();
crate::admit_resolved(&mut control, &resolve(), resolves_to)
.expect("distinct keys are admitted");
let mut twinned = resolve();
assert_eq!(twinned.subjects.len(), 2);
twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone();
let mut state = kernel.state.clone();
let error = crate::admit_resolved(&mut state, &twinned, resolves_to)
.expect_err("one key on two resolved subjects is refused");
assert!(
matches!(&error, KernelError::Invariant(why) if why.contains("import key")),
"{error:?}"
);
assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key");
}
/// A decomposable character at every position of a real-shaped key: the NFD
/// spelling is refused wherever it sits, the NFC spelling is admitted, so the
/// rule cannot be a prefix, suffix or first-character check.
#[test]
fn a_decomposable_character_is_refused_in_nfd_at_every_position() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let template: Vec<char> = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"
.chars()
.collect();
let submit = |kernel: &mut WorldKernel, handle: &str, record: String| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.lore", &record)),
place,
)]),
)
};
let spelled = |position: usize, inserted: &str| {
let mut record: String = template[..position].iter().collect();
record.push_str(inserted);
record.extend(&template[position..]);
record
};
for position in 0..=template.len() {
let nfd = spelled(position, "e\u{301}");
assert!(
!unicode_normalization::is_nfc(&nfd),
"position {position} is not NFD"
);
let set = rejected(submit(&mut kernel, "bad", nfd));
assert_eq!(set, vec![malformed("bad")], "NFD at position {position}");
}
for position in 0..=template.len() {
let nfc = spelled(position, "\u{e9}");
submit(&mut kernel, &format!("good{position}"), nfc)
.unwrap_or_else(|_| panic!("NFC at position {position} was refused"));
}
assert_eq!(
keys_in(&kernel).len(),
1 + template.len() + 1,
"the genesis key and one NFC key per position are held, no NFD key"
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,379 @@
//! The stock lenses. A lens is the flavor an elaborator session reaches for:
//! prompt emphasis over the one fixed tool catalog, never admission, authority,
//! or quota. This module owns the stock table, each lens's instruction text,
//! and the draw recipe; it reads no world state and writes nothing.
use super::{CommandId, KernelError, WorldId, digest};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
/// The sentence every lens's instructions begin with.
const ELABORATION_INSTRUCTIONS: &str = "Use only the supplied tools to author structure inside your jurisdiction. Answer the boundary or deficit you were given, then submit. Recording a gap changes nothing.";
/// The library's stock lens set. Serialized by snake_case name, so a name the
/// library does not know cannot be represented at any deserialization boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lens {
Patina,
Charter,
Ledger,
Hearth,
Tangle,
Veil,
Ember,
Numen,
}
impl Lens {
/// Every lens, in declaration order. The draw walks this order.
pub const ALL: [Lens; 8] = [
Lens::Patina,
Lens::Charter,
Lens::Ledger,
Lens::Hearth,
Lens::Tangle,
Lens::Veil,
Lens::Ember,
Lens::Numen,
];
pub fn name(self) -> &'static str {
match self {
Lens::Patina => "Patina",
Lens::Charter => "Charter",
Lens::Ledger => "Ledger",
Lens::Hearth => "Hearth",
Lens::Tangle => "Tangle",
Lens::Veil => "Veil",
Lens::Ember => "Ember",
Lens::Numen => "Numen",
}
}
/// What kind of structure the lens reaches for.
pub(crate) fn reaches_for(self) -> &'static str {
match self {
Lens::Patina => "ordinary objects, customs, nicknames, local jokes",
Lens::Charter => "government, law, offices, selection, succession, redress",
Lens::Ledger => "resources, labor, trade, infrastructure, scarcity, class pressure",
Lens::Hearth => "kinship, daily life, care, obligation, belonging",
Lens::Tangle => "factions, alliances, rivalries, leverage",
Lens::Veil => "secrets, rumors, misinformation, taboos, unevenly held knowledge",
Lens::Ember => "disputes, hazards, instability, escalation, urgent pressure",
Lens::Numen => "religion, magic, ritual, awe, cosmology, the genuinely strange",
}
}
/// Existing `PATCH_TOOLS` names in emphasis order. Emphasis is prompt text
/// only: the catalog, its order, and its schemas are the same for every lens.
pub(crate) fn leads_with(self) -> &'static [&'static str] {
match self {
Lens::Patina => &[
"set_persona_material",
"declare_affordance",
"declare_resource",
"declare_place",
],
Lens::Charter => &[
"grant_authority",
"open_office",
"install_incumbent",
"open_forum",
"declare_subject",
],
Lens::Ledger => &[
"transfer",
"bind",
"declare_resource",
"alter_cost",
"declare_route",
],
Lens::Hearth => &[
"create_commitment",
"set_persona_material",
"declare_subject",
],
Lens::Tangle => &[
"advance_pressure",
"create_commitment",
"grant_authority",
"declare_subject",
"bind",
],
Lens::Veil => &[
"acquire_knowledge",
"communicate",
"declare_channel",
"set_reach",
"declare_fact",
"forget",
],
Lens::Ember => &[
"advance_pressure",
"create_commitment",
"close_route",
"consume",
],
Lens::Numen => &[
"declare_fact",
"declare_affordance",
"set_persona_material",
"witness",
],
}
}
/// The elaborator's instructions under this lens: the shared sentence, then
/// the lens clause. Read once, when a session is first built; the session
/// then carries the text, and nothing rebuilds it from here.
pub(crate) fn instructions(self) -> String {
format!(
"{ELABORATION_INSTRUCTIONS} Lens: {}. Reach for {}. Lead with {}; every supplied tool remains available.",
self.name(),
self.reaches_for(),
self.leads_with().join(", "),
)
}
}
/// A world's lens weights. Validity is decided where weights are admitted;
/// `Default` is the empty map a world holds before genesis writes its weights,
/// and it never draws.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct LensWeights(BTreeMap<Lens, u32>);
impl LensWeights {
pub fn new(weights: BTreeMap<Lens, u32>) -> Self {
Self(weights)
}
/// A lens the map does not name weighs zero.
pub fn get(&self, lens: Lens) -> u32 {
self.0.get(&lens).copied().unwrap_or(0)
}
pub fn iter(&self) -> impl Iterator<Item = (Lens, u32)> + '_ {
self.0.iter().map(|(lens, weight)| (*lens, *weight))
}
/// Some weight is nonzero, so a draw has somewhere to land.
pub(crate) fn draws(&self) -> bool {
self.0.values().any(|weight| *weight > 0)
}
/// Every stock lens weighs the same in both sets. A lens a map does not
/// name weighs zero, so `{charter: 3}` and `{patina: 0, charter: 3}` are
/// one set spelled two ways: they draw identically and neither replaces
/// the other.
pub(crate) fn weighs_the_same_as(&self, other: &LensWeights) -> bool {
Lens::ALL
.into_iter()
.all(|lens| self.get(lens) == other.get(lens))
}
}
/// The draw's whole input: the world and the session's fixed identity. The
/// weights select over it; they are not part of it.
#[derive(Serialize)]
struct LensPreimage {
world_id: WorldId,
command_id: CommandId,
}
/// One digest, one modulo, one cumulative walk over `Lens::ALL` — the recipe
/// `select_band` uses, not a second entropy shape. The same world, session
/// identity, and weights draw the same lens. Modulo rather than rejection
/// sampling: the bias is bounded by `total_weight / 2^64`.
pub(crate) fn draw(
weights: &LensWeights,
world_id: WorldId,
command_id: CommandId,
) -> Result<Lens, KernelError> {
// The guard is on the divisor itself, as `select_band`'s is: no other
// statement of "these weights draw" can let the modulo below divide by zero.
let total: u128 = Lens::ALL
.iter()
.map(|lens| u128::from(weights.get(*lens)))
.sum();
if total == 0 {
return Err(KernelError::Invariant("lens weights never draw".into()));
}
let preimage = digest(&LensPreimage {
world_id,
command_id,
})?;
let head = preimage
.strip_prefix("sha256:")
.and_then(|hex| hex.get(..16))
.and_then(|head| u64::from_str_radix(head, 16).ok())
.ok_or_else(|| KernelError::Invariant("lens draw digest is not hex".into()))?;
let mut position = u128::from(head) % total;
for lens in Lens::ALL {
let weight = u128::from(weights.get(lens));
if position < weight {
return Ok(lens);
}
position -= weight;
}
Err(KernelError::Invariant(
"lens weights did not cover the draw".into(),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::patch::PATCH_TOOLS;
use crate::tests::stock_weights;
use std::collections::BTreeSet;
fn ids() -> Vec<CommandId> {
(0..200)
.map(|i| CommandId::derived("l1-probe", &[&i.to_string()]))
.collect()
}
fn tally(weights: &LensWeights) -> BTreeMap<Lens, usize> {
let mut counts = BTreeMap::new();
for id in ids() {
let lens = draw(weights, WorldId::nil_for_test(), id).unwrap();
*counts.entry(lens).or_insert(0) += 1;
}
counts
}
#[test]
fn every_lead_names_a_patch_tool() {
let catalog: BTreeSet<&str> = PATCH_TOOLS.iter().map(|entry| entry.name).collect();
for lens in Lens::ALL {
let leads = lens.leads_with();
assert!(!leads.is_empty(), "{lens:?} leads with nothing");
let distinct: BTreeSet<&str> = leads.iter().copied().collect();
assert_eq!(distinct.len(), leads.len(), "{lens:?} repeats a lead");
for name in leads {
assert!(
catalog.contains(name),
"{lens:?} leads with unknown tool {name}"
);
}
}
}
#[test]
fn the_draw_is_a_function_of_world_and_session() {
let weights = stock_weights();
let world = WorldId::nil_for_test();
for id in ids() {
assert_eq!(
draw(&weights, world, id).unwrap(),
draw(&weights, world, id).unwrap()
);
}
let expected: BTreeMap<Lens, usize> = BTREE_STOCK_TALLY.into_iter().collect();
assert_eq!(tally(&weights), expected);
}
/// Computed at Cut 1 and pinned: the replay proof for 200 derived ids under
/// stock weights.
const BTREE_STOCK_TALLY: [(Lens, usize); 8] = [
(Lens::Patina, 22),
(Lens::Charter, 37),
(Lens::Ledger, 26),
(Lens::Hearth, 19),
(Lens::Tangle, 23),
(Lens::Veil, 29),
(Lens::Ember, 18),
(Lens::Numen, 26),
];
#[test]
fn a_zero_weight_never_draws() {
let weights = LensWeights::new(BTreeMap::from([
(Lens::Patina, 0),
(Lens::Charter, 3),
(Lens::Ledger, 1),
]));
let counts = tally(&weights);
assert_eq!(counts.get(&Lens::Patina), None, "a zero weight drew");
let expected: BTreeMap<Lens, usize> = ZERO_WEIGHT_TALLY.into_iter().collect();
assert_eq!(counts, expected);
}
/// Computed at Cut 1 and pinned; near 3:1 over the same 200 ids.
const ZERO_WEIGHT_TALLY: [(Lens, usize); 2] = [(Lens::Charter, 155), (Lens::Ledger, 45)];
#[test]
fn all_zero_weights_are_an_error() {
let world = WorldId::nil_for_test();
let id = CommandId::derived("l1-probe", &["0"]);
let all_zero = LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 0)).collect());
assert_eq!(all_zero.iter().count(), 8);
for weights in [LensWeights::default(), all_zero] {
assert!(matches!(
draw(&weights, world, id),
Err(KernelError::Invariant(_))
));
}
}
#[test]
fn an_unknown_lens_name_does_not_deserialize() {
for refused in [
r#"{"Patina":1}"#,
r#"{"patina ":1}"#,
r#"{"patinaa":1}"#,
r#"{"patina":1,"tribunal":1}"#,
] {
assert!(
serde_json::from_str::<LensWeights>(refused).is_err(),
"{refused} deserialized"
);
}
let accepted = serde_json::from_str::<LensWeights>(r#"{"patina":1}"#).unwrap();
assert_eq!(accepted.get(Lens::Patina), 1);
assert_eq!(accepted.iter().count(), 1);
let stock = stock_weights();
let bytes = rmp_serde::to_vec_named(&stock).unwrap();
let back: LensWeights = rmp_serde::from_slice(&bytes).unwrap();
assert_eq!(back, stock);
assert_eq!(rmp_serde::to_vec_named(&back).unwrap(), bytes);
}
#[test]
fn each_lens_text_names_its_own_leads() {
let mut texts = BTreeSet::new();
for lens in Lens::ALL {
let text = lens.instructions();
assert!(text.contains(lens.name()), "{lens:?} text omits its name");
for name in lens.leads_with() {
assert!(text.contains(name), "{lens:?} text omits {name}");
}
texts.insert(text);
}
assert_eq!(texts.len(), 8, "two lenses share a text");
}
#[test]
fn lenses_serialize_as_snake_case_names() {
let names: Vec<String> = Lens::ALL
.iter()
.map(|lens| serde_json::to_string(lens).unwrap())
.collect();
assert_eq!(
names,
[
r#""patina""#,
r#""charter""#,
r#""ledger""#,
r#""hearth""#,
r#""tangle""#,
r#""veil""#,
r#""ember""#,
r#""numen""#,
]
);
}
}

19173
crates/ghostlight/src/lib.rs Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,196 @@
//! The one emitter for every model-facing tool schema in the tree.
//!
//! Two catalogs sit above it and share nothing else: the action catalog
//! (`controllers::catalog_tools`) projects a world-state snapshot filtered by
//! grant, and the patch catalog (`patch::patch_tools`) projects the reducer's
//! own vocabulary. Both spell a property the same way because both spell it
//! here. Nothing in this module reads world state, a snapshot, or a config.
use codex_connector::CodexToolDefinition;
use serde_json::{Value, json};
pub(super) fn tool(name: &str, description: &str, schema: Value) -> CodexToolDefinition {
CodexToolDefinition {
name: name.into(),
description: description.into(),
parameters_json: serde_json::to_string(&schema)
.expect("static tool schemas must serialize"),
}
}
pub(super) fn empty_schema() -> Value {
json!({"type":"object","additionalProperties":false,"properties":{}})
}
/// Every generated object is closed and total: `additionalProperties` is false
/// and every property is required. An optional argument is expressed as a
/// nullable property, never as an absent one, so the decoder and the schema
/// agree without a second rule about omission.
pub(super) fn object(properties: Vec<(String, Value)>) -> Value {
let required: Vec<Value> = properties
.iter()
.map(|(name, _)| Value::String(name.clone()))
.collect();
json!({
"type": "object",
"additionalProperties": false,
"required": required,
"properties": properties.into_iter().collect::<serde_json::Map<_, _>>(),
})
}
pub(super) fn canonical_string(description: &str) -> Value {
json!({"type": "string", "description": description})
}
pub(super) fn bounded_integer(min: u64, max: u64) -> Value {
json!({"type": "integer", "minimum": min, "maximum": max})
}
/// The same schema with the text the model reads beside it. Meaning that lives
/// only in a prompt paragraph is lost by the time the model fills the field.
pub(super) fn described(mut schema: Value, description: &str) -> Value {
if let Value::Object(fields) = &mut schema {
fields.insert("description".into(), Value::String(description.into()));
}
schema
}
pub(super) fn name_enum(values: &[&str]) -> Value {
json!({"type": "string", "enum": values})
}
/// A closed sum spelled as a tagged object. `tag` is the discriminator field
/// name; each variant carries its own closed object of content.
pub(super) fn variant(tag: &str, variants: Vec<(&str, Vec<(String, Value)>)>) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, content)| {
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
properties.extend(content);
object(properties)
})
.collect();
json!({ "anyOf": branches })
}
pub(super) fn nullable(inner: Value) -> Value {
json!({ "anyOf": [inner, {"type": "null"}] })
}
pub(super) fn list(inner: Value) -> Value {
json!({"type": "array", "items": inner})
}
/// `{"ref": "draft"|"existing", "value": <handle text|canonical uuid>}` —
/// Invariant 1 rendered as a schema, and the only reference shape either
/// catalog emits.
pub(super) fn reference(referent: &str) -> Value {
json!({
"type": "object",
"additionalProperties": false,
"required": ["ref", "value"],
"properties": {
"ref": {"type": "string", "enum": ["draft", "existing"]},
"value": {
"type": "string",
"description": format!(
"a draft handle declared in this same patch, or the canonical id of an existing {referent}"
),
},
},
})
}
/// An adjacently tagged sum: `{"<tag>": "<variant>", "<content>": <payload>}`.
/// A variant with no payload (`None`) omits the content field entirely, which
/// is what serde emits for a unit variant in an adjacently tagged enum.
pub(super) fn variant_content(
tag: &str,
content: &str,
variants: Vec<(&str, Option<Value>)>,
) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, payload)| {
let mut properties = vec![(tag.to_owned(), json!({"type": "string", "const": name}))];
if let Some(inner) = payload {
properties.push((content.to_owned(), inner));
}
object(properties)
})
.collect();
json!({ "anyOf": branches })
}
/// An externally tagged sum: a bare string for a unit variant, or a
/// single-key object for one that carries a payload.
pub(super) fn external_variant(variants: Vec<(&str, Option<Value>)>) -> Value {
let branches: Vec<Value> = variants
.into_iter()
.map(|(name, payload)| match payload {
None => json!({"type": "string", "const": name}),
Some(inner) => object(vec![(name.to_owned(), inner)]),
})
.collect();
json!({ "anyOf": branches })
}
/// The provider's strict function-schema rules, enforced offline so the
/// catalog cannot be refused on the road one keyword at a time: every
/// node names a `type` or is an `anyOf` of nodes that do; every object is
/// closed and lists every property as required; `oneOf`, `allOf`, and
/// `not` never appear. The first live seeded run failed on exactly these.
#[cfg(test)]
pub(super) fn assert_strict(schema: &Value, at: &str) {
for forbidden in ["oneOf", "allOf", "not"] {
assert!(
schema.get(forbidden).is_none(),
"{at}: `{forbidden}` is not permitted in a strict schema"
);
}
if let Some(branches) = schema.get("anyOf").and_then(Value::as_array) {
assert!(!branches.is_empty(), "{at}: empty anyOf");
for (index, branch) in branches.iter().enumerate() {
assert_strict(branch, &format!("{at}/anyOf/{index}"));
}
return;
}
let kind = schema
.get("type")
.and_then(Value::as_str)
.unwrap_or_else(|| panic!("{at}: schema must have a `type` key: {schema}"));
match kind {
"object" => {
assert_eq!(
schema.get("additionalProperties"),
Some(&Value::Bool(false)),
"{at}: object must be closed"
);
let properties = schema
.get("properties")
.and_then(Value::as_object)
.unwrap_or_else(|| panic!("{at}: object without properties"));
let mut names: Vec<&str> = properties.keys().map(String::as_str).collect();
names.sort_unstable();
let mut required: Vec<&str> = schema
.get("required")
.and_then(Value::as_array)
.map(|list| list.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
required.sort_unstable();
assert_eq!(names, required, "{at}: every property must be required");
for (name, property) in properties {
assert_strict(property, &format!("{at}/{name}"));
}
}
"array" => {
let items = schema
.get("items")
.unwrap_or_else(|| panic!("{at}: array without items"));
assert_strict(items, &format!("{at}/items"));
}
"string" | "integer" | "number" | "boolean" | "null" => {}
other => panic!("{at}: unexpected type {other}"),
}
}

View file

@ -0,0 +1,778 @@
//! A read-only markdown vault, behind the world's `EvidenceSource` seam.
//!
//! It resolves referent strings to notes and hands back bounded excerpts. It
//! never writes, never leaves its configured root, never touches git or the
//! network, and holds no world truth: its output is `EvidenceReceipt`s whose
//! `excerpt` and `source` are prompt material and whose `reference` is the only
//! thing that can ever reach the journal.
//!
//! It is the one authority in the seed lane that can be tested with no kernel
//! at all, which is why it is a module.
use super::EvidenceRef;
use super::elaboration::{EvidenceError, EvidenceQuery, EvidenceReceipt, EvidenceSource};
use async_trait::async_trait;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
/// Below `MAX_PATCH_EVIDENCE` (64), so a session that cites everything it was
/// handed still fits inside one patch.
const MAX_VAULT_RECEIPTS: usize = 24;
const MAX_HITS_PER_REFERENT: usize = 3;
/// One hop, never recursive: a note's neighbours are context, and a transitive
/// walk would hand a session the whole vault under one referent.
const MAX_LINK_FANOUT: usize = 4;
const MAX_EXCERPT_CHARS: usize = 800;
const MAX_VAULT_NOTES: usize = 4_096;
const MAX_NOTE_BYTES: u64 = 512 * 1024;
/// Neither variant carries an absolute filesystem path: `seed_once` maps this
/// error straight into the owner's command receipt, and the reference format
/// this module hands back is deliberately vault-relative, so the error text
/// must be too. `Root` names no path at all; `Scope` carries only the
/// vault-relative scope string the caller supplied.
#[derive(Debug, Error)]
pub enum VaultError {
#[error("vault root is not a readable directory")]
Root,
#[error("vault scope escapes its root: {0}")]
Scope(String),
#[error("vault holds more than {MAX_VAULT_NOTES} notes")]
TooManyNotes,
}
/// One note as the reader holds it. The body is kept in memory because the
/// corpora this reads are a few megabytes of markdown and a per-query file read
/// would buy nothing but a second failure mode.
#[derive(Clone, Debug)]
struct VaultNote {
/// The evidence reference: the note's vault-relative path with forward
/// slashes, e.g. `Spoilers/Places/Low Sere.md`. It is a path and not a
/// wikilink target, because stems collide across directories; not a scheme,
/// because the kernel has no scheme registry; and not a digest, because a
/// human must be able to resolve it years later.
reference: String,
title: String,
/// Everything after the frontmatter block.
body: String,
/// Wikilink targets, left side only.
links: Vec<String>,
}
pub struct VaultEvidenceSource {
notes: Vec<VaultNote>,
/// Many-to-one: filename stem, frontmatter title, and each alias all key
/// the same note. Ambiguity is resolved by the shortest reference path, and
/// refused for that referent when two candidates tie.
by_key: BTreeMap<String, Vec<usize>>,
}
impl VaultEvidenceSource {
/// `root` is the configured vault root; `scope` is a relative subdirectory
/// or empty. Both are canonicalized and the scope must live under the root,
/// so `..`, an absolute scope, and a symlink out are all refused here. A
/// spoiler tier is a directory in every vault that has one, so it is
/// expressed as a scope and there is no tier field anywhere in this file.
pub fn open(root: &Path, scope: &str) -> Result<Self, VaultError> {
let root = root.canonicalize().map_err(|_| VaultError::Root)?;
if !root.is_dir() {
return Err(VaultError::Root);
}
let scope_path = if scope.trim().is_empty() {
root.clone()
} else {
let relative = Path::new(scope.trim());
if relative.is_absolute()
|| relative
.components()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Err(VaultError::Scope(scope.to_owned()));
}
let joined = root.join(relative);
let canonical = joined
.canonicalize()
.map_err(|_| VaultError::Scope(scope.to_owned()))?;
if !canonical.starts_with(&root) {
return Err(VaultError::Scope(scope.to_owned()));
}
canonical
};
let mut paths: Vec<PathBuf> = Vec::new();
collect_notes(&root, &scope_path, &mut paths)?;
paths.sort();
let mut notes = Vec::new();
for path in paths {
let Ok(metadata) = std::fs::metadata(&path) else {
continue;
};
if metadata.len() > MAX_NOTE_BYTES {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(relative) = path.strip_prefix(&root) else {
continue;
};
let reference = relative
.components()
.map(|part| part.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
if reference.trim() != reference || reference.is_empty() {
continue;
}
let stem = path
.file_stem()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_default();
let (front, body) = split_frontmatter(&text);
let (front_title, aliases) = read_front_keys(front);
let title = front_title.unwrap_or_else(|| stem.clone());
let links = wikilink_targets(body);
let mut keys: Vec<String> = vec![stem, title.clone()];
keys.extend(aliases);
notes.push((
VaultNote {
reference,
title,
body: body.to_owned(),
links,
},
keys,
));
}
let mut by_key: BTreeMap<String, Vec<usize>> = BTreeMap::new();
for (index, (_, keys)) in notes.iter().enumerate() {
for key in keys {
let key = key.trim().to_lowercase();
if key.is_empty() {
continue;
}
by_key.entry(key).or_default().push(index);
}
}
Ok(Self {
notes: notes.into_iter().map(|(note, _)| note).collect(),
by_key,
})
}
/// Exact key first, then a bounded keyword search. A referent whose exact
/// match is ambiguous between two equally short paths resolves to nothing
/// rather than to a guess.
fn resolve(&self, referent: &str) -> Vec<usize> {
let key = referent.trim().to_lowercase();
if let Some(candidates) = self.by_key.get(&key) {
let mut ranked: Vec<usize> = candidates.clone();
ranked.sort_by_key(|index| {
(
self.notes[*index].reference.len(),
self.notes[*index].reference.clone(),
)
});
ranked.dedup();
if ranked.len() == 1 {
return ranked;
}
let shortest = self.notes[ranked[0]].reference.len();
let tied = ranked
.iter()
.filter(|index| self.notes[**index].reference.len() == shortest)
.count();
return if tied == 1 {
vec![ranked[0]]
} else {
Vec::new()
};
}
let tokens: Vec<String> = key
.split_whitespace()
.filter(|token| token.len() > 2)
.map(str::to_owned)
.collect();
if tokens.is_empty() {
return Vec::new();
}
let mut scored: Vec<(usize, usize)> = self
.notes
.iter()
.enumerate()
.filter_map(|(index, note)| {
let title = note.title.to_lowercase();
let body = note.body.to_lowercase();
let score: usize = tokens
.iter()
.map(|token| {
usize::from(title.contains(token.as_str())) * 4
+ body.matches(token.as_str()).count().min(8)
})
.sum();
(score > 0).then_some((score, index))
})
.collect();
scored.sort_by_key(|(score, index)| {
(
std::cmp::Reverse(*score),
self.notes[*index].reference.clone(),
)
});
scored
.into_iter()
.take(MAX_HITS_PER_REFERENT)
.map(|(_, index)| index)
.collect()
}
fn receipt(&self, index: usize, heading: Option<&str>) -> EvidenceReceipt {
let note = &self.notes[index];
EvidenceReceipt {
reference: EvidenceRef::new(note.reference.clone()),
excerpt: excerpt(&note.body, heading),
source: note.title.clone(),
}
}
}
#[async_trait]
impl EvidenceSource for VaultEvidenceSource {
async fn retrieve(&self, query: &EvidenceQuery) -> Result<Vec<EvidenceReceipt>, EvidenceError> {
let mut seen: BTreeSet<String> = BTreeSet::new();
let mut receipts = Vec::new();
for referent in &query.referents {
let (target, heading) = match referent.split_once('#') {
Some((target, heading)) => (target, Some(heading)),
None => (referent.as_str(), None),
};
for index in self.resolve(target) {
if receipts.len() >= MAX_VAULT_RECEIPTS {
return Ok(receipts);
}
if seen.insert(self.notes[index].reference.clone()) {
receipts.push(self.receipt(index, heading));
}
for link in self.notes[index].links.iter().take(MAX_LINK_FANOUT) {
if receipts.len() >= MAX_VAULT_RECEIPTS {
return Ok(receipts);
}
let (link_target, link_heading) = match link.split_once('#') {
Some((target, heading)) => (target, Some(heading)),
None => (link.as_str(), None),
};
for linked in self.resolve(link_target) {
if receipts.len() >= MAX_VAULT_RECEIPTS {
return Ok(receipts);
}
if seen.insert(self.notes[linked].reference.clone()) {
receipts.push(self.receipt(linked, link_heading));
}
}
}
}
}
Ok(receipts)
}
}
/// `root` is the vault's canonical root, fixed for the whole walk. Every
/// directory and note is canonicalized and admitted only when its canonical
/// form still starts with `root`, so a junction or symlink planted inside the
/// scope cannot walk the reader out to wherever it points. A directory entry
/// whose own metadata is a reparse point is refused outright rather than
/// descended into: canonicalizing its *contents* would still resolve through
/// the reparse point before the containment check ever runs.
fn collect_notes(root: &Path, directory: &Path, into: &mut Vec<PathBuf>) -> Result<(), VaultError> {
let entries = std::fs::read_dir(directory).map_err(|_| VaultError::Root)?;
for entry in entries.flatten() {
let path = entry.path();
let Ok(metadata) = std::fs::symlink_metadata(&path) else {
continue;
};
if is_reparse_point(&metadata) {
continue;
}
if metadata.is_dir() {
let Ok(canonical) = path.canonicalize() else {
continue;
};
if !canonical.starts_with(root) {
continue;
}
collect_notes(root, &path, into)?;
} else if path.extension().is_some_and(|value| value == "md") {
let Ok(canonical) = path.canonicalize() else {
continue;
};
if !canonical.starts_with(root) {
continue;
}
if into.len() >= MAX_VAULT_NOTES {
return Err(VaultError::TooManyNotes);
}
into.push(path);
}
}
Ok(())
}
/// A directory junction on Windows carries the reparse-point attribute
/// alongside the directory attribute, so `file_attributes` is the only signal
/// that distinguishes it from a real directory; elsewhere a symlink is enough
/// of a proxy for the same hazard.
#[cfg(windows)]
fn is_reparse_point(metadata: &std::fs::Metadata) -> bool {
use std::os::windows::fs::MetadataExt;
const FILE_ATTRIBUTE_REPARSE_POINT: u32 = 0x400;
metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
}
#[cfg(not(windows))]
fn is_reparse_point(metadata: &std::fs::Metadata) -> bool {
metadata.file_type().is_symlink()
}
/// A leading `---` line opens a frontmatter block that runs to the next `---`.
/// Absence is normal — whole vaults carry none — so the fallback is the whole
/// file as body. The block is split, never parsed as YAML: only `title:` and
/// `aliases:` are read, line-wise, and everything else is ignored.
fn split_frontmatter(text: &str) -> (&str, &str) {
let trimmed = text.strip_prefix('\u{feff}').unwrap_or(text);
let Some(rest) = trimmed
.strip_prefix("---\r\n")
.or_else(|| trimmed.strip_prefix("---\n"))
else {
return ("", trimmed);
};
for (offset, line) in rest.match_indices('\n') {
let candidate = rest[..offset].rsplit('\n').next().unwrap_or_default();
if candidate.trim_end() == "---" {
let start = offset - candidate.len();
return (&rest[..start], &rest[offset + line.len()..]);
}
}
("", trimmed)
}
fn read_front_keys(front: &str) -> (Option<String>, Vec<String>) {
let mut title = None;
let mut aliases = Vec::new();
let mut in_alias_list = false;
for line in front.lines() {
let unindented = line.trim_start();
if let Some(item) = unindented.strip_prefix("- ")
&& in_alias_list
{
aliases.push(clean_scalar(item));
continue;
}
in_alias_list = false;
if let Some(value) = unindented.strip_prefix("title:") {
let value = clean_scalar(value);
if !value.is_empty() {
title = Some(value);
}
} else if let Some(value) = unindented.strip_prefix("aliases:") {
let value = value.trim();
if let Some(inner) = value.strip_prefix('[').and_then(|v| v.strip_suffix(']')) {
aliases.extend(inner.split(',').map(clean_scalar).filter(|v| !v.is_empty()));
} else if value.is_empty() {
in_alias_list = true;
} else {
aliases.push(clean_scalar(value));
}
}
}
(
title,
aliases.into_iter().filter(|v| !v.is_empty()).collect(),
)
}
fn clean_scalar(value: &str) -> String {
value
.trim()
.trim_matches(|c| c == '"' || c == '\'')
.trim()
.to_owned()
}
/// Three forms: `[[Target]]`, `[[Target|display]]`, and `[[Target#Heading]]`.
/// The display half is discarded; the heading half is kept, because it seeks
/// the excerpt.
fn wikilink_targets(body: &str) -> Vec<String> {
let mut targets = Vec::new();
let mut rest = body;
while let Some(open) = rest.find("[[") {
rest = &rest[open + 2..];
let Some(close) = rest.find("]]") else {
break;
};
let inner = &rest[..close];
rest = &rest[close + 2..];
let target = inner.split('|').next().unwrap_or_default().trim();
if !target.is_empty() && !targets.iter().any(|seen| seen == target) {
targets.push(target.to_owned());
}
}
targets
}
/// The named heading's section when one was asked for, else the head of the
/// body. Cut at a char boundary, because a reference is bytes and an excerpt is
/// text.
fn excerpt(body: &str, heading: Option<&str>) -> String {
let region = heading
.and_then(|heading| heading_section(body, heading))
.unwrap_or(body);
let region = region.trim();
match region.char_indices().nth(MAX_EXCERPT_CHARS) {
None => region.to_owned(),
Some((cut, _)) => region[..cut].to_owned(),
}
}
fn heading_section<'a>(body: &'a str, heading: &str) -> Option<&'a str> {
let wanted = heading.trim().to_lowercase();
let mut start = None;
let mut level = 0usize;
let mut offset = 0usize;
for line in body.split_inclusive('\n') {
let hashes = line.chars().take_while(|c| *c == '#').count();
if hashes > 0 {
let text = line[hashes..].trim().to_lowercase();
match start {
None if text == wanted => {
start = Some(offset + line.len());
level = hashes;
}
Some(begin) if hashes <= level => return Some(&body[begin..offset]),
_ => {}
}
}
offset += line.len();
}
start.map(|begin| &body[begin..])
}
#[cfg(test)]
mod tests {
use super::*;
fn write(root: &Path, relative: &str, text: &str) {
let path = root.join(relative);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
fn fixture() -> tempfile::TempDir {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
write(
root,
"Public/Places/Low Sere.md",
"The sere runs dry nine months a year.\nSee [[Rain Gate]] and [[Rain Gate|the gate]].\n",
);
write(
root,
"Public/Places/Rain Gate.md",
"---\ntitle: The Rain Gate\naliases:\n - Gate of Rain\n - Hinge\nstatus: canonical\n---\n# Body\nWater enters here.\n\n## Tolls\nA toll of one is charged.\n",
);
write(root, "Public/index.md", "Public index.\n");
write(root, "Public/World/index.md", "World index, deeper.\n");
write(
root,
"Spoilers/Dungeons/Provenance.md",
"The hinge was built by the drowned. Link: [[Low Sere#Nothing]]\n",
);
directory
}
fn query(referents: &[&str]) -> EvidenceQuery {
EvidenceQuery {
referents: referents.iter().map(|value| (*value).to_owned()).collect(),
}
}
/// Spec test 14. References are vault-relative paths, every wikilink form
/// resolves to the same note as the bare one, an ambiguous stem resolves to
/// the shortest path, the excerpt is bounded, and a source opened on
/// `Public` cannot see `Spoilers`.
#[tokio::test]
async fn the_vault_resolves_titles_aliases_and_wikilinks_inside_its_scope() {
let directory = fixture();
let whole = VaultEvidenceSource::open(directory.path(), "").unwrap();
let receipts = whole.retrieve(&query(&["Low Sere"])).await.unwrap();
assert_eq!(
receipts[0].reference,
EvidenceRef::new("Public/Places/Low Sere.md"),
"a reference is the vault-relative path, forward-slashed"
);
// The bare and the piped link are one target, and the linked note came
// back through the one-hop fanout.
assert!(
receipts
.iter()
.any(|receipt| receipt.reference == EvidenceRef::new("Public/Places/Rain Gate.md"))
);
// Frontmatter title and alias key the same note as the stem does.
for name in ["Rain Gate", "The Rain Gate", "Gate of Rain", "hinge"] {
let hit = whole.retrieve(&query(&[name])).await.unwrap();
assert_eq!(
hit[0].reference,
EvidenceRef::new("Public/Places/Rain Gate.md"),
"{name} did not resolve to the note"
);
}
// A heading link seeks its section rather than the head of the body.
let heading = whole.retrieve(&query(&["Rain Gate#Tolls"])).await.unwrap();
assert!(heading[0].excerpt.contains("toll of one"));
assert!(!heading[0].excerpt.contains("Water enters here"));
// Two `index.md` notes; the shallower path wins.
let ambiguous = whole.retrieve(&query(&["index"])).await.unwrap();
assert_eq!(
ambiguous[0].reference,
EvidenceRef::new("Public/index.md"),
"an ambiguous stem resolved to the deeper path"
);
for receipt in whole
.retrieve(&query(&["Low Sere", "index"]))
.await
.unwrap()
{
assert!(receipt.excerpt.chars().count() <= MAX_EXCERPT_CHARS);
assert!(receipt.reference.text().trim() == receipt.reference.text());
assert!(!receipt.reference.text().is_empty());
}
assert!(
whole
.retrieve(&query(&["Low Sere", "Provenance", "index"]))
.await
.unwrap()
.len()
<= MAX_VAULT_RECEIPTS
);
// The spoiler tier is a directory, so it is a scope.
let public = VaultEvidenceSource::open(directory.path(), "Public").unwrap();
assert!(
public
.retrieve(&query(&["Provenance"]))
.await
.unwrap()
.is_empty()
);
assert!(
whole
.retrieve(&query(&["Provenance"]))
.await
.unwrap()
.iter()
.any(|receipt| receipt.reference
== EvidenceRef::new("Spoilers/Dungeons/Provenance.md")),
"the unscoped source should still read the spoiler subtree"
);
}
/// The scope check at `open` only bounds the scope string; the walk itself
/// must bound every step, because a junction planted after `open` (or
/// simply present in the tree) is a directory as far as `read_dir` is
/// concerned. `collect_notes` now canonicalizes every directory and note it
/// visits and admits it only when the canonical form still starts with the
/// vault's canonical root, and it refuses to descend into a directory
/// entry that is itself a reparse point.
///
/// A junction needs no privilege on Windows, so this is reachable by
/// anyone who can write inside the configured vault.
#[cfg(windows)]
#[tokio::test]
async fn soul_a_junction_inside_the_root_reads_a_note_outside_it() {
let directory = fixture();
let outside = tempfile::tempdir().unwrap();
write(
outside.path(),
"Secret.md",
"The vault root does not cover me.\n",
);
let link = directory.path().join("Public").join("Escape");
let made = std::process::Command::new("cmd")
.args([
"/C",
"mklink",
"/J",
&link.display().to_string(),
&outside.path().display().to_string(),
])
.output();
if made.is_err() || !link.exists() {
eprintln!("skipped: this environment cannot create a directory junction");
return;
}
let source = VaultEvidenceSource::open(directory.path(), "Public").unwrap();
let receipts = source.retrieve(&query(&["Secret"])).await.unwrap();
assert!(
receipts.is_empty(),
"a note outside the configured root was read and handed back as {:?}",
receipts
.iter()
.map(|receipt| receipt.reference.text().to_owned())
.collect::<Vec<_>>()
);
}
/// Soul. Two bounds and one promise. A note over `MAX_NOTE_BYTES` is
/// skipped on its metadata, so it is never read into memory and never
/// reaches a receipt; and neither `open` nor `retrieve` writes anything,
/// asserted against the tree's own bytes and modification times rather than
/// against the doc comment.
#[tokio::test]
async fn soul_an_oversized_note_is_never_read_and_the_reader_never_writes() {
let directory = fixture();
let root = directory.path();
let huge = format!(
"---\ntitle: The Long Note\n---\n{}\n",
"obese ".repeat(usize::try_from(MAX_NOTE_BYTES).unwrap() / 4)
);
assert!(u64::try_from(huge.len()).unwrap() > MAX_NOTE_BYTES);
write(root, "Public/Places/Long Note.md", &huge);
let before = tree(root);
let source = VaultEvidenceSource::open(root, "").unwrap();
for name in ["Long Note", "The Long Note", "obese"] {
let receipts = source.retrieve(&query(&[name])).await.unwrap();
assert!(
receipts
.iter()
.all(|receipt| receipt.reference
!= EvidenceRef::new("Public/Places/Long Note.md")),
"{name} returned a note over the byte cap"
);
}
assert_eq!(before, tree(root), "the reader changed the vault");
}
/// Every file under `root`, by relative path, length, and modification
/// time: what a read-only reader must leave exactly as it found it.
fn tree(root: &Path) -> BTreeMap<String, (u64, std::time::SystemTime)> {
let mut found = BTreeMap::new();
let mut paths = Vec::new();
collect_all(root, &mut paths);
for path in paths {
let metadata = std::fs::metadata(&path).unwrap();
found.insert(
path.strip_prefix(root).unwrap().display().to_string(),
(metadata.len(), metadata.modified().unwrap()),
);
}
found
}
fn collect_all(directory: &Path, into: &mut Vec<PathBuf>) {
for entry in std::fs::read_dir(directory).unwrap().flatten() {
let path = entry.path();
if path.is_dir() {
collect_all(&path, into);
} else {
into.push(path);
}
}
}
/// `MAX_VAULT_RECEIPTS` is the cap the module names, and the reason it
/// gives is that a session citing everything still fits in one patch. The
/// guard is checked once per top-level referent and again per receipt a
/// link's own fanout would add, so a keyword-resolving link that hands
/// back up to `MAX_HITS_PER_REFERENT` notes can never push the total past
/// the cap.
#[tokio::test]
async fn soul_the_link_fanout_overshoots_the_receipt_cap() {
let directory = tempfile::tempdir().unwrap();
let root = directory.path();
// Enough plain notes to walk the receipt count up to the cap, each one
// resolving by its own stem and linking nowhere.
for index in 0..MAX_VAULT_RECEIPTS {
write(root, &format!("plain{index}.md"), "A plain note.\n");
}
// One note whose single wikilink resolves by keyword, not by key, to
// three notes at once.
write(root, "hub.md", "See [[quarry stone]].\n");
for index in 0..MAX_HITS_PER_REFERENT {
write(
root,
&format!("quarry{index}.md"),
"quarry stone quarry stone\n",
);
}
let source = VaultEvidenceSource::open(root, "").unwrap();
let mut referents: Vec<String> = (0..MAX_VAULT_RECEIPTS - 1)
.map(|index| format!("plain{index}"))
.collect();
referents.push("hub".to_owned());
let receipts = source.retrieve(&EvidenceQuery { referents }).await.unwrap();
assert!(
receipts.len() <= MAX_VAULT_RECEIPTS,
"one call returned {} receipts against a cap of {MAX_VAULT_RECEIPTS}",
receipts.len()
);
}
/// `seed_once` maps `VaultError` straight into `RuntimeCommandError::Payload`,
/// so the string reaches the owner's command receipt. The reference format
/// this module hands back is deliberately vault-relative, and `VaultError`
/// now carries no absolute filesystem path either: `Root` names no path,
/// and `Scope` carries only the caller-supplied scope string.
#[test]
fn soul_a_vault_error_carries_an_absolute_path() {
let directory = fixture();
let root = directory.path();
for (case, opened) in [
(
"an escaping scope",
VaultEvidenceSource::open(root, "Public/../.."),
),
// The reachable misconfiguration: `GHOSTLIGHT_SEED_VAULT_ROOT`
// naming something that is not a directory.
(
"a root that is a file",
VaultEvidenceSource::open(&root.join("Public/index.md"), ""),
),
] {
let Err(error) = opened else {
panic!("{case} was admitted");
};
let message = error.to_string();
assert!(
!message.contains(&root.display().to_string()) && !message.contains(":\\"),
"{case} handed back an absolute path: {message}"
);
}
}
/// Spec test 15. The scope is a corner of a configured vault, never a read
/// primitive over the filesystem.
#[test]
fn a_vault_scope_cannot_escape_its_root() {
let directory = fixture();
let root = directory.path();
for scope in ["..", "../..", "Public/../..", "Public/../../etc"] {
assert!(
VaultEvidenceSource::open(root, scope).is_err(),
"{scope} was admitted"
);
}
let absolute = root.join("Public");
assert!(VaultEvidenceSource::open(root, &absolute.display().to_string()).is_err());
assert!(VaultEvidenceSource::open(root, "Public").is_ok());
}
}

View file

@ -0,0 +1,759 @@
//! Provider call records, the run's spend, and replay.
//!
//! `RecordingInferencePort` is the one owner of a run's spend. Before it
//! forwards a call it decides, from the request alone, whether the call could
//! pass the run's token cap; after the reply it charges what the call really
//! cost. Every reply is stored as a `ProviderCallRecord` before the output
//! returns to the lane, so `ReplayInferencePort` can serve the same run again
//! from the store with no provider behind it.
//!
//! Lanes and runners never count tokens. A request is held against the cap by
//! its bound: the serialized provider request's bytes (a token is at least one
//! byte, so bytes bound the prompt) plus the request's own output allowance.
//!
//! The run's spend is one persisted ledger row per run, owned by the store and
//! written where it is spent. A call reserves its bound in that row before the
//! provider is called; a reply settles the reservation to what it reported
//! (prompt plus completion, cached prompt tokens being part of the prompt; none
//! reported is charged the bound); a fault leaves the reservation charged. A
//! restart reads the row, so no restart returns budget. A reply that reports
//! more than the call's bound is charged in full and faults the call, so the
//! run's spend never passes the cap unnoticed. One port at a time owns a run.
use crate::controllers::{
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose,
InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms,
};
use async_trait::async_trait;
use chrono::Utc;
use codex_connector::CodexProviderRequest;
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
path::Path,
sync::{Arc, Mutex},
};
use thiserror::Error;
const CALL_RECORD_ROW: &str = "verse_provider_call.v1";
const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1";
/// The run ledger document's schema.
const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1";
/// The call record document's schema. A store holding any other row type or
/// schema is refused at open: this store is not migrated from, or shared with,
/// another document.
pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1";
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
pub enum CallRecordError {
#[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")]
InvalidRunId,
#[error("the call record store could not be read or written")]
Store,
#[error("the call record store holds a row that is not a canonical call record")]
Corrupt,
#[error("a call record's sequence is not the next in its run")]
OutOfSequence,
#[error("replay needs one run's records in dense sequence from 1")]
NotReplayable,
#[error("another recording port already owns this run")]
RunInUse,
#[error("the call could pass the run's token cap")]
OverCap,
#[error("the run was recorded with a different {0}")]
RunMismatch(RunField),
}
/// What a run is bound to when it begins, named by the field a mismatch refuses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunField {
/// The token cap the run was opened with.
Cap,
/// The identity of the world the run started from.
WorldId,
/// The state digest of the world the run started from.
StateDigest,
}
impl std::fmt::Display for RunField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Cap => "token cap",
Self::WorldId => "world id",
Self::StateDigest => "world state digest",
})
}
}
/// The world a run began from: its identity and state digest. Replay is of a
/// restored copy of this world, so it takes the same two values.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunStart {
world_id: String,
state_digest: String,
}
impl RunStart {
pub fn of(world: &crate::WorldSnapshot) -> Self {
Self {
world_id: world.world_id.text(),
state_digest: world.state_digest.clone(),
}
}
}
/// A run's identity as a key segment.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RunId(String);
impl RunId {
pub fn new(id: &str) -> Result<Self, CallRecordError> {
let valid = !id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
if valid {
Ok(Self(id.to_owned()))
} else {
Err(CallRecordError::InvalidRunId)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// One provider call of one run: the request's content digest (never its text, so no
/// prompt or credential is stored), the reply exactly as the lane received it,
/// and what the call was charged. Keyed (run id, sequence).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderCallRecord {
run_id: String,
sequence: u64,
purpose: InferencePurpose,
model: String,
content_sha256: String,
events: Vec<InferenceEvent>,
receipt_digest: String,
usage: Option<TokenUsage>,
charged: u64,
}
impl ProviderCallRecord {
pub fn run_id(&self) -> &str {
&self.run_id
}
pub fn sequence(&self) -> u64 {
self.sequence
}
pub fn purpose(&self) -> InferencePurpose {
self.purpose
}
pub fn model(&self) -> &str {
&self.model
}
/// Lowercase hex of the request's content digest (see `content_digest`).
pub fn content_sha256(&self) -> &str {
&self.content_sha256
}
pub fn usage(&self) -> Option<TokenUsage> {
self.usage
}
/// Tokens this call was charged against the run's cap.
pub fn charged(&self) -> u64 {
self.charged
}
/// The reply as the lane received it.
pub fn output(&self) -> InferenceOutput {
let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone());
match self.usage {
Some(usage) => output.with_usage(usage),
None => output,
}
}
}
fn row_key(run: &str, sequence: u64) -> String {
format!("{run}/{sequence:020}")
}
fn hex(digest: &[u8]) -> String {
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// What a request asks of the provider, without the ids that name it. The
/// request and conversation ids are derived from the command id, and a command
/// id from the world it ran in, so keying on them would tie a recorded run to
/// one world's random identity. Everything else the provider is sent
/// (model, instructions, input, tools, shape) is in the digest.
fn content_digest(request: &CodexProviderRequest) -> Result<String, InferenceFault> {
// Exhaustive on purpose: a field added upstream must be placed in the key
// or left out here by name before this compiles.
let CodexProviderRequest {
schema_id,
request_id: _request_id, // names the call, not what it asks
conversation_id: _conversation_id, // names the call, not what it asks
model,
instructions,
input,
reasoning_effort,
reasoning_summary,
service_tier,
output_format_name,
previous_response_id,
tools,
tool_choice,
parallel_tool_calls,
output_schema_json,
max_output_tokens,
prompt_cache_key,
} = request;
let bytes = rmp_serde::to_vec(&(
schema_id,
model,
instructions,
input,
reasoning_effort,
reasoning_summary,
service_tier,
output_format_name,
previous_response_id,
tools,
tool_choice,
parallel_tool_calls,
output_schema_json,
max_output_tokens,
prompt_cache_key,
))
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
Ok(hex(&Sha256::digest(bytes)))
}
/// A run's persisted ledger, the one owner of what the run began as: the cap
/// it was opened under, the world it started from, and the tokens reserved or
/// charged so far.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct RunLedger {
run_id: String,
cap: u64,
start: RunStart,
spent: u64,
}
enum DecodedRow {
Call(ProviderCallRecord),
Ledger(RunLedger),
}
pub(super) fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
row: &CultCacheEnvelope,
) -> Result<T, CallRecordError> {
let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?;
let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?;
if again == row.payload {
Ok(value)
} else {
Err(CallRecordError::Corrupt)
}
}
fn decode_row(row: &CultCacheEnvelope) -> Result<DecodedRow, CallRecordError> {
match (row.r#type.as_str(), row.schema_id.as_deref()) {
(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => {
let record: ProviderCallRecord = canonical(row)?;
// A run id that is no whole key segment would key into another
// run's records, and sequences count from 1.
if RunId::new(&record.run_id).is_err()
|| record.sequence == 0
|| row.key != row_key(&record.run_id, record.sequence)
{
return Err(CallRecordError::Corrupt);
}
Ok(DecodedRow::Call(record))
}
(RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => {
let ledger: RunLedger = canonical(row)?;
if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id {
return Err(CallRecordError::Corrupt);
}
Ok(DecodedRow::Ledger(ledger))
}
_ => Err(CallRecordError::Corrupt),
}
}
#[derive(Default)]
struct RunTally {
last_sequence: u64,
/// The persisted ledger, read at open and written where it is spent.
ledger: Option<RunLedger>,
}
struct StoreState {
store: OwnedRedbMessagePackBackingStore,
runs: BTreeMap<String, RunTally>,
/// Runs with a recording port alive; one ledger has one owner.
owned: BTreeSet<String>,
}
/// The call records and spend ledgers of every run in one CultCache file.
/// Records are appended and never changed; a run's sequence is dense from 1.
pub struct CallRecordStore {
state: Mutex<StoreState>,
}
impl CallRecordStore {
/// Opens the file, creating it empty when absent. Every row must be a
/// canonical call record or run ledger of this schema with its key, a
/// dense sequence, and a ledger that covers its records' charges; anything
/// else, an earlier or later schema version included, is refused and
/// nothing is migrated.
pub fn open(path: impl AsRef<Path>) -> Result<Self, CallRecordError> {
let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())
.map_err(|_| CallRecordError::Store)?;
store
.validate_path_identity()
.map_err(|_| CallRecordError::Store)?;
let rows = store.pull_all().map_err(|_| CallRecordError::Store)?;
let mut runs: BTreeMap<String, RunTally> = BTreeMap::new();
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
let mut charged: BTreeMap<String, u64> = BTreeMap::new();
for row in &rows {
match decode_row(row)? {
DecodedRow::Call(record) => {
*counts.entry(record.run_id.clone()).or_default() += 1;
let total = charged.entry(record.run_id.clone()).or_default();
*total = total.saturating_add(record.charged);
let tally = runs.entry(record.run_id).or_default();
tally.last_sequence = tally.last_sequence.max(record.sequence);
}
DecodedRow::Ledger(ledger) => {
let run = ledger.run_id.clone();
runs.entry(run).or_default().ledger = Some(ledger);
}
}
}
// Sequences start at 1 and keys are unique, so a run is dense exactly
// when its rows number its highest sequence. Every record was reserved
// in a ledger before it was made, so a run with records has a ledger
// that covers what they charged.
for (run, tally) in &runs {
let rows = counts.get(run).copied().unwrap_or(0);
let recorded = charged.get(run).copied().unwrap_or(0);
let covered = tally
.ledger
.as_ref()
.is_some_and(|ledger| ledger.spent >= recorded);
if rows != tally.last_sequence || !covered {
return Err(CallRecordError::Corrupt);
}
}
Ok(Self {
state: Mutex::new(StoreState {
store,
runs,
owned: BTreeSet::new(),
}),
})
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, CallRecordError> {
self.state.lock().map_err(|_| CallRecordError::Store)
}
/// The sequence the next record of this run must carry.
pub fn next_sequence(&self, run: &RunId) -> Result<u64, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.map_or(0, |tally| tally.last_sequence)
+ 1)
}
/// Tokens this run has reserved or been charged: its persisted ledger.
pub fn spent(&self, run: &RunId) -> Result<u64, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map_or(0, |ledger| ledger.spent))
}
/// Takes the run's ledger for one port, or refuses when another holds it.
/// The first claim of a run writes its ledger: the cap it is opened under
/// and the world it starts from. A later claim must name the same cap; the
/// start is not compared, because a resumed world has moved on.
fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
if !state.owned.insert(run.as_str().to_owned()) {
return Err(CallRecordError::RunInUse);
}
let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) {
Some(ledger) if ledger.cap == cap => Ok(()),
Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)),
None => Self::write_ledger(
&mut state,
RunLedger {
run_id: run.as_str().to_owned(),
cap,
start: start.clone(),
spent: 0,
},
),
};
if begun.is_err() {
state.owned.remove(run.as_str());
}
begun
}
/// The world a run began from, when it has begun.
fn start_of(&self, run: &RunId) -> Result<Option<RunStart>, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map(|ledger| ledger.start.clone()))
}
fn disown(&self, run: &RunId) {
if let Ok(mut state) = self.state.lock() {
state.owned.remove(run.as_str());
}
}
/// Persists the ledger, then adopts it in memory. A write that fails
/// leaves the previous ledger.
fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> {
let row = CultCacheEnvelope {
key: ledger.run_id.clone(),
r#type: RUN_LEDGER_ROW.into(),
payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?,
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(RUN_LEDGER_SCHEMA.into()),
};
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
let run = ledger.run_id.clone();
state.runs.entry(run).or_default().ledger = Some(ledger);
Ok(())
}
/// Persists `spent` as the run's spend; the run must have been claimed.
fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> {
let mut ledger = state
.runs
.get(run)
.and_then(|tally| tally.ledger.clone())
.ok_or(CallRecordError::Store)?;
ledger.spent = spent;
Self::write_ledger(state, ledger)
}
fn held(state: &StoreState, run: &RunId) -> u64 {
state
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map_or(0, |ledger| ledger.spent)
}
/// Charges `bound` to the run's persisted ledger, or refuses with
/// `OverCap` when the ledger plus the bound would pass `cap`. The check and
/// the write hold one lock, so calls on the wire hold their bounds.
fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
let after = Self::held(&state, run).saturating_add(bound);
if after > cap {
return Err(CallRecordError::OverCap);
}
Self::write_spent(&mut state, run.as_str(), after)
}
/// Replaces a reservation of `bound` with what the call was charged. With
/// a record, the record is stored first, so a crash between the two writes
/// leaves the larger reservation charged.
fn settle(
&self,
run: &RunId,
bound: u64,
charged: u64,
record: Option<&ProviderCallRecord>,
) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
if let Some(record) = record {
Self::push_record(&mut state, record)?;
}
let settled = Self::held(&state, run)
.saturating_sub(bound)
.saturating_add(charged);
Self::write_spent(&mut state, run.as_str(), settled)
}
fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
Self::push_record(&mut state, record)
}
fn push_record(
state: &mut StoreState,
record: &ProviderCallRecord,
) -> Result<(), CallRecordError> {
let last = state
.runs
.get(&record.run_id)
.map_or(0, |tally| tally.last_sequence);
if record.sequence != last + 1 {
return Err(CallRecordError::OutOfSequence);
}
let row = CultCacheEnvelope {
key: row_key(&record.run_id, record.sequence),
r#type: CALL_RECORD_ROW.into(),
payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?,
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(CALL_RECORD_SCHEMA.into()),
};
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
state
.runs
.entry(record.run_id.clone())
.or_default()
.last_sequence = record.sequence;
Ok(())
}
/// The run's records in sequence.
pub fn records(&self, run: &RunId) -> Result<Vec<ProviderCallRecord>, CallRecordError> {
let prefix = format!("{}/", run.as_str());
let rows = self
.lock()?
.store
.pull_all()
.map_err(|_| CallRecordError::Store)?;
let mut records = Vec::new();
for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) {
if let DecodedRow::Call(record) = decode_row(row)? {
records.push(record);
}
}
records.sort_by_key(|record| record.sequence);
Ok(records)
}
}
/// Decorates a port for one run: reserves each call's bound in the run's
/// ledger before the provider is called, settles it to the real cost, and
/// stores every reply before returning it.
pub struct RecordingInferencePort {
inner: Arc<dyn InferencePort>,
store: Arc<CallRecordStore>,
run: RunId,
cap: u64,
}
impl RecordingInferencePort {
/// Takes ownership of the run's ledger, which resumes from the store. The
/// first open of a run records its cap and the world it starts from; a
/// reopen with another cap is refused with `RunMismatch(Cap)`. A second
/// port on a run whose first is alive is refused with `RunInUse`.
pub fn open(
inner: Arc<dyn InferencePort>,
store: Arc<CallRecordStore>,
run: RunId,
cap_tokens: u64,
start: &RunStart,
) -> Result<Self, CallRecordError> {
store.claim(&run, cap_tokens, start)?;
Ok(Self {
inner,
store,
run,
cap: cap_tokens,
})
}
/// The run this port records.
pub fn run_id(&self) -> &RunId {
&self.run
}
/// The token cap the run was opened under.
pub fn cap(&self) -> u64 {
self.cap
}
/// Tokens reserved or charged to the run so far.
pub fn spent(&self) -> u64 {
self.store.spent(&self.run).unwrap_or(u64::MAX)
}
/// The most this request can cost: its serialized bytes plus its output
/// allowance. A request with no output allowance has no bound, so it
/// cannot be held under a cap.
fn bound(request: &PreparedInference) -> Result<u64, InferenceFault> {
let allowance = request
.invocation
.request
.max_output_tokens
.ok_or_else(InferenceFault::budget_exhausted)?;
let payload = serde_json::to_vec(&request.invocation.request)
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
Ok((payload.len() as u64).saturating_add(u64::from(allowance)))
}
fn settle(
&self,
bound: u64,
identity: (InferencePurpose, String, String),
result: Result<InferenceOutput, InferenceFault>,
) -> Result<InferenceOutput, InferenceFault> {
// A fault leaves its reservation charged: the request may have run.
let output = result?;
let charged = match output.usage {
Some(usage) => usage.prompt.saturating_add(usage.completion),
None => bound,
};
if charged > bound {
self.store
.settle(&self.run, bound, charged, None)
.map_err(unrecorded)?;
return Err(InferenceFault::budget_exhausted());
}
let (purpose, model, content_sha256) = identity;
let record = ProviderCallRecord {
run_id: self.run.as_str().to_owned(),
sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?,
purpose,
model,
content_sha256,
events: output.events.clone(),
receipt_digest: output.receipt_digest.clone(),
usage: output.usage,
charged,
};
self.store
.settle(&self.run, bound, charged, Some(&record))
.map_err(unrecorded)?;
Ok(output)
}
}
impl Drop for RecordingInferencePort {
fn drop(&mut self) {
self.store.disown(&self.run);
}
}
fn unrecorded(_: CallRecordError) -> InferenceFault {
InferenceFault::integrity_violation("a provider call could not be recorded")
}
#[async_trait]
impl InferencePort for RecordingInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
self.inner.prepare(request)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
let bound = Self::bound(&request)?;
let identity = (
request.purpose,
request.invocation.request.model.clone(),
content_digest(&request.invocation.request)?,
);
self.store
.reserve(&self.run, bound, self.cap)
.map_err(|error| match error {
CallRecordError::OverCap => InferenceFault::budget_exhausted(),
_ => unrecorded(error),
})?;
let result = self.inner.infer(request).await;
self.settle(bound, identity, result)
}
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
self.inner.lend_tool_results(prepared, oracle);
}
}
/// Serves a run's stored replies, in sequence, to the same requests, matched
/// by what they ask of the provider and not by the ids that name them. It holds
/// no provider and makes no call: a request that is not the next recorded one
/// is a fault.
pub struct ReplayInferencePort {
caller_runtime_id: String,
pending: Mutex<VecDeque<ProviderCallRecord>>,
}
impl ReplayInferencePort {
/// Replays `run` from the store against `world`, a restored copy of the
/// world the run started from. Any other world is refused here, before a
/// call, with `RunMismatch` naming the field; a run the store never began
/// is `NotReplayable`.
pub fn new(
caller_runtime_id: impl Into<String>,
store: &CallRecordStore,
run: &RunId,
world: &RunStart,
) -> Result<Self, CallRecordError> {
let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?;
if began.world_id != world.world_id {
return Err(CallRecordError::RunMismatch(RunField::WorldId));
}
if began.state_digest != world.state_digest {
return Err(CallRecordError::RunMismatch(RunField::StateDigest));
}
Ok(Self {
caller_runtime_id: caller_runtime_id.into(),
pending: Mutex::new(store.records(run)?.into()),
})
}
}
#[async_trait]
impl InferencePort for ReplayInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
PreparedInference::prepare(
&self.caller_runtime_id,
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
request,
)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
let mut pending = self
.pending
.lock()
.map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?;
let digest = content_digest(&request.invocation.request)?;
let Some(next) = pending.front() else {
return Err(InferenceFault::integrity_violation(
"replay has no recorded call left",
));
};
if next.content_sha256 != digest {
return Err(InferenceFault::integrity_violation(
"the request is not the next recorded call",
));
}
let output = next.output();
pending.pop_front();
Ok(output)
}
}
#[cfg(test)]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
//! The canonical Aetheria verse world's genesis.
//!
//! One intent lowers to one `CreateWorld`: a commons, every grammar verb
//! declared as an affordance, and each Aetheria faction as an institution
//! imported by its record key. The world is bound to the catalog's grammar, so
//! the kernel refuses any affordance the grammar does not carry.
//!
//! Phase A verbs record the act and change no component: an outcome band with
//! no effects, and the act carried as the utterance that makes an entry
//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling
//! `verb-effects-ghostlight`), and those arrive with phase B.
use crate::aetheria::AetheriaCatalog;
use crate::mailbox::{MailboxError, commons_declaration, commons_ref};
use crate::patch::{
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef,
FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration,
kernel_speak_entry, kernel_speak_grant,
};
use crate::{
CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind,
WorldPatch, WorldScaleIntentRef,
};
use std::collections::BTreeMap;
const COMMIT_LEN: usize = 40;
/// What a caller states to create the canonical verse world. Everything else a
/// world needs (the commons, the subjects, the affordances, their grants and
/// the provenance of what is known) is derived from the catalog.
#[derive(Clone, Debug)]
pub struct CreateVerseWorldIntent {
pub id: CommandId,
pub title: String,
pub brief: String,
pub catalog: AetheriaCatalog,
/// The Aetheria commit the catalog was read at: 40 lower-case hex digits.
/// It is the provenance of every fact the world holds about a faction.
pub aetheria_commit: String,
pub lens_weights: LensWeights,
pub targets: BTreeMap<SubjectKind, u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum VerseGenesisError {
#[error("aetheria_commit is not 40 lower-case hex digits")]
NotACommit,
#[error(transparent)]
Mailbox(#[from] MailboxError),
}
impl CreateVerseWorldIntent {
/// The one lowering. The kernel supplies the `speak` entry at genesis and
/// holds it to the grammar, so it is granted here and never declared.
pub(crate) fn lower(self, owner: PrincipalId) -> Result<CreateWorld, VerseGenesisError> {
let commit = &self.aetheria_commit;
if commit.len() != COMMIT_LEN
|| !commit
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(VerseGenesisError::NotACommit);
}
let speak = kernel_speak_entry().kind;
let mut grants = kernel_speak_grant();
let mut declarations = vec![commons_declaration()];
for (kind, verb) in self.catalog.grammar.verbs() {
if kind == speak.0 {
continue;
}
let handle = DraftHandle::new(format!("verb-{kind}"));
grants.insert(Ref::Draft(handle.clone()));
declarations.push(Declaration::Affordance(AffordanceDeclaration {
handle,
kind: AffordanceKindName(kind.to_owned()),
roles: verb.role_specs(),
preconditions: vec![Precondition::CanBroadcast {
via: AudienceSpec::Colocated,
}],
effect_slots: Vec::new(),
outcome_bands: vec![OutcomeBand {
weight: 1,
effects: Vec::new(),
}],
// The kernel refuses an entry that can neither change a
// component nor speak, and a speaking entry needs an audience,
// so a phase A verb is its own utterance to the room: the act
// is recorded as speech and nothing else changes.
carries_speech: true,
}));
}
let mut evidence = Vec::new();
for (position, faction) in self.catalog.factions.iter().enumerate() {
declarations.push(Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new(format!("faction-{position}")),
label: faction.name.trim().to_owned(),
kind: SubjectKind::Institution,
controller: NewController::OperationalAgent,
affordances: grants.clone(),
position: Some(commons_ref()),
import_key: Some(faction.key.clone()),
}));
// A faction with no description has nothing to state.
if let Some(statement) = Statement::new(faction.description.trim()) {
let receipt = EvidenceRef::new(format!(
"aetheria:{commit}:{}/{}",
faction.key.schema(),
faction.key.record()
));
evidence.push(receipt.clone());
declarations.push(Declaration::Fact(FactDeclaration {
handle: DraftHandle::new(format!("fact-{position}")),
label: faction.name.trim().to_owned(),
statement,
standing: FactStandingRef::Canonical { evidence: receipt },
}));
}
}
Ok(CreateWorld {
id: self.id,
owner,
title: self.title,
brief: self.brief,
patch: WorldPatch {
declarations,
operations: Vec::new(),
evidence,
},
scale_intent: WorldScaleIntentRef {
targets: self.targets,
jurisdictions: BTreeMap::new(),
},
lens_weights: self.lens_weights,
grammar: Some(self.catalog.grammar),
})
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,476 @@
use super::*;
use crate::aetheria::read_aetheria_catalog;
use crate::patch::{FactStanding, Mismatch};
use crate::tests::{auth_principal, owner, stock_weights};
use crate::{
CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb,
KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel,
WorldMailbox, WorldPhase,
};
use chrono::{Duration, Utc};
use std::collections::BTreeSet;
/// Aetheria master when the fixture catalog was written.
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
const FIXTURE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/aetheria-verse-catalog.cc"
);
fn catalog() -> AetheriaCatalog {
read_aetheria_catalog(FIXTURE).unwrap()
}
fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent {
CreateVerseWorldIntent {
id: CommandId::new(),
title: "The Verse".into(),
brief: "Aetheria.".into(),
catalog,
aetheria_commit: commit.into(),
lens_weights: stock_weights(),
targets: BTreeMap::new(),
}
}
/// A world created through the kernel from the lowering, for what the
/// snapshot hides: the facts' evidence.
fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let dir = tempfile::tempdir().unwrap();
let world = intent.lower(owner()).unwrap();
let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner()))
.map(|(kernel, _)| kernel);
(dir, result)
}
struct Verse {
_dir: tempfile::TempDir,
_task: tokio::task::JoinHandle<()>,
mailbox: WorldMailbox,
principal: VerifiedPrincipalEvidence,
receipt: crate::CreationReceipt,
}
async fn verse(intent: CreateVerseWorldIntent) -> Verse {
let dir = tempfile::tempdir().unwrap();
let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
let receipt = mailbox.create_verse(intent, &principal).await.unwrap();
Verse {
_dir: dir,
_task: task,
mailbox,
principal,
receipt,
}
}
#[tokio::test]
async fn verse_genesis_from_the_aetheria_fixture() {
let catalog = catalog();
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
// One institution per faction, keyed by its record, named by its Name.
let mut factions: Vec<_> = snapshot
.subjects
.iter()
.map(|subject| {
(
subject.import_key.clone().unwrap(),
subject.label.clone(),
subject.kind,
subject.controller_mode,
)
})
.collect();
factions.sort_by(|a, b| a.0.cmp(&b.0));
let expected: Vec<_> = catalog
.factions
.iter()
.map(|faction| {
(
faction.key.clone(),
faction.name.clone(),
SubjectKind::Institution,
Some(ControllerMode::OperationalAgent),
)
})
.collect();
assert_eq!(factions.len(), 2);
assert_eq!(factions, expected);
// One fact per faction, carrying its description.
let statements: BTreeSet<_> = snapshot
.facts
.iter()
.map(|fact| fact.statement.as_str().to_owned())
.collect();
assert_eq!(
statements,
catalog
.factions
.iter()
.map(|faction| faction.description.clone())
.collect::<BTreeSet<_>>()
);
// The world is bound to the catalog's grammar.
assert_eq!(snapshot.grammar, Some(catalog.grammar.identity()));
assert_eq!(verse.receipt.world_id, snapshot.world_id);
// Every faction stands in the one commons.
let commons: Vec<_> = snapshot
.places
.iter()
.filter(|place| place.label == "The Commons")
.collect();
assert_eq!(commons.len(), 1);
assert!(
snapshot
.subjects
.iter()
.all(|subject| subject.position == Some(commons[0].id))
);
}
#[test]
fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() {
for commit in [COMMIT, OTHER_COMMIT] {
let (_dir, kernel) = kernel_of(intent_of(catalog(), commit));
let kernel = kernel.unwrap();
let evidence: BTreeSet<String> = kernel
.state
.facts
.values()
.map(|fact| match &fact.standing {
FactStanding::Canonical { evidence } => evidence.text().to_owned(),
other => panic!("a verse fact is canonical, not {other:?}"),
})
.collect();
assert_eq!(
evidence,
catalog()
.factions
.iter()
.map(|faction| format!(
"aetheria:{commit}:aetheria.faction/{}",
faction.key.record()
))
.collect::<BTreeSet<_>>()
);
}
}
#[test]
fn a_commit_is_forty_lower_case_hex_digits() {
for bad in [
"",
&COMMIT[..39],
&format!("{COMMIT}0"),
&COMMIT.to_uppercase(),
&format!("{}g", &COMMIT[..39]),
] {
assert!(
matches!(
intent_of(catalog(), bad).lower(owner()),
Err(VerseGenesisError::NotACommit)
),
"{bad:?}"
);
}
assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok());
assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT));
}
#[test]
fn verse_genesis_refuses_a_duplicate_faction_record() {
let mut twice = catalog();
let first = twice.factions[0].clone();
twice.factions.push(first);
// The lowering does not decide: it declares all three.
let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap();
let subjects = world
.patch
.declarations
.iter()
.filter(|declaration| matches!(declaration, Declaration::Subject(_)))
.count();
assert_eq!(subjects, 3);
let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT));
let Err(KernelError::PatchRejected(set)) = kernel else {
panic!("a second faction under one record key was admitted");
};
assert!(
set.iter()
.any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })),
"{set:?}"
);
}
#[tokio::test]
async fn every_granted_affordance_is_a_grammar_verb() {
let catalog = catalog();
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
let mut kinds: Vec<_> = snapshot
.affordances
.iter()
.map(|affordance| affordance.entry.kind.0.clone())
.collect();
kinds.sort();
// Each verb once: the kernel's speak is the grammar's speak.
assert_eq!(
kinds,
catalog
.grammar
.verbs()
.map(|(kind, _)| kind.to_owned())
.collect::<Vec<_>>()
);
assert!(kinds.contains(&"speak".to_owned()));
// Each has its render path from the grammar, and phase A changes nothing.
for affordance in &snapshot.affordances {
let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap();
assert_eq!(affordance.render_path, Some(verb.render_path));
assert!(affordance.entry.effect_slots.is_empty());
assert_eq!(affordance.entry.outcome_bands.len(), 1);
assert!(affordance.entry.outcome_bands[0].effects.is_empty());
}
// Every faction is granted every verb.
let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect();
for subject in &snapshot.subjects {
assert_eq!(subject.affordances, all);
}
}
#[tokio::test]
async fn factions_get_opportunities() {
let verse = verse(intent_of(catalog(), COMMIT)).await;
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let revision = verse.mailbox.snapshot().await.unwrap().revision;
verse
.mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: verse.receipt.world_id,
expected_revision: revision,
body,
},
&verse.principal,
)
.await
.unwrap();
}
let snapshot = verse.mailbox.snapshot().await.unwrap();
assert_eq!(snapshot.phase, WorldPhase::Active);
assert_eq!(snapshot.opportunities.len(), catalog().factions.len());
let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect();
assert_eq!(scopes.len(), snapshot.opportunities.len());
for opportunity in &snapshot.opportunities {
assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent);
assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len());
}
}
#[test]
fn a_faction_without_a_description_holds_no_fact() {
let mut bare = catalog();
bare.factions[0].description = " ".into();
bare.factions[1].description = " Second. ".into();
let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT));
let facts: Vec<_> = kernel
.unwrap()
.state
.facts
.values()
.map(|fact| fact.statement.as_str().to_owned())
.collect();
assert_eq!(facts, vec!["Second.".to_owned()]);
}
#[test]
fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() {
let mut mute = catalog();
mute.grammar = GrammarBinding::new(
"aetheria.verse_grammar",
1,
[(
"haul".to_owned(),
GrammarVerb {
render_path: RenderPath::ShipAction,
roles: vec![GrammarRole {
role: "cargo".into(),
referent: GrammarReferentKind::Cargo,
}],
},
)],
)
.unwrap();
let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT));
let Err(KernelError::PatchRejected(set)) = kernel else {
panic!("a world whose grammar has no speak was admitted");
};
assert!(
set.iter()
.any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })),
"{set:?}"
);
}
#[tokio::test]
async fn the_dungeon_ingress_still_creates_an_unbound_world() {
let dir = tempfile::tempdir().unwrap();
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1));
mailbox
.create(
crate::CreateWorldIntent {
id: CommandId::new(),
title: "Dungeon".into(),
brief: String::new(),
human_subject_label: "Player".into(),
narrative_persona_label: None,
operational_agent_label: None,
targets: BTreeMap::new(),
jurisdictions: Vec::new(),
lens_weights: stock_weights(),
},
&principal,
)
.await
.unwrap();
let snapshot = mailbox.snapshot().await.unwrap();
assert_eq!(snapshot.grammar, None);
assert!(
snapshot
.places
.iter()
.any(|place| place.label == "The Commons")
);
}
#[tokio::test]
async fn a_faction_name_is_trimmed_into_its_institution_label() {
let mut padded = catalog();
let expected = padded.factions[0].name.trim().to_owned();
padded.factions[0].name = format!(" {expected}\t ");
let key = padded.factions[0].key.clone();
let verse = verse(intent_of(padded, COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
let subject = snapshot
.subjects
.iter()
.find(|subject| subject.import_key.as_ref() == Some(&key))
.expect("the padded faction was created");
assert_eq!(subject.label, expected);
}
#[tokio::test]
async fn genesis_twice_with_one_intent_is_one_creation() {
let dir = tempfile::tempdir().unwrap();
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
let intent = intent_of(catalog(), COMMIT);
let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap();
let before = mailbox.snapshot().await.unwrap();
let again = mailbox.create_verse(intent, &principal).await.unwrap();
let after = mailbox.snapshot().await.unwrap();
assert_eq!(again, first);
assert_eq!(after.revision, before.revision);
assert_eq!(after.state_digest, before.state_digest);
assert_eq!(after.subjects.len(), before.subjects.len());
}
#[tokio::test]
async fn genesis_under_another_command_id_finds_the_world_occupied() {
let verse = verse(intent_of(catalog(), COMMIT)).await;
let before = verse.mailbox.snapshot().await.unwrap();
let refused = verse
.mailbox
.create_verse(intent_of(catalog(), COMMIT), &verse.principal)
.await;
assert!(
matches!(
refused,
Err(VerseGenesisError::Mailbox(MailboxError::Kernel(
KernelError::CreationTargetOccupied
)))
),
"{refused:?}"
);
let after = verse.mailbox.snapshot().await.unwrap();
assert_eq!(after.revision, before.revision);
assert_eq!(after.state_digest, before.state_digest);
}
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
/// the lowering admits a ship verb only as an utterance,
/// and committing it asserts a claim fact the room hears.
#[test]
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
use crate::tests::{command, opportunity_for, spoken, submit_owner};
use crate::{
AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt,
Target,
};
let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT));
let mut kernel = kernel.unwrap();
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let snapshot = kernel.snapshot().unwrap();
submit_owner(&mut kernel, &snapshot, body);
}
let active = kernel.snapshot().unwrap();
let attack = active
.affordances
.iter()
.find(|a| a.entry.kind.0 == "attack")
.unwrap()
.id;
let [first, second] = [&active.subjects[0], &active.subjects[1]];
let opportunity = opportunity_for(&active, first.id);
let caller = CallerId::Controller(opportunity.controller_id);
let invocation = |speech: Option<&str>| DecisionInvocation {
affordance: attack,
bindings: vec![RoleBinding {
role: Role("target".into()),
target: Target::Subject(second.id),
}],
proposed: Vec::new(),
speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()),
display: None,
};
let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| {
kernel.submit(
command(
&active,
CommandId::new(),
caller.clone(),
CommandBody::ExerciseDecision {
opportunity: opportunity.clone(),
invocation: invocation(speech),
},
),
&AuthenticatedCaller::fixture(caller.clone()),
)
};
// Without an utterance the act is refused: the verb has no other way in.
assert!(try_it(&mut kernel, None).is_err());
// With one, the act commits and its words are a claim fact.
let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let event = kernel.state.events.last().unwrap();
assert_eq!(spoken(&kernel.state, event), Some("We strike."));
}

View file

@ -0,0 +1,19 @@
//! The Aetheria verse run's own organs. The verse binary sits above this
//! module; here sit the parts of a run that belong to the library: the record
//! of every provider call and the spend it implies, the genesis of the
//! canonical verse world, and the manual run that advances it with its record.
mod calls;
mod genesis;
mod run;
pub use genesis::{CreateVerseWorldIntent, VerseGenesisError};
pub use calls::{
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord,
RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,
};
pub use run::{
RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus,
TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
call_run_id, run_manual,
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,756 @@
//! Admission proofs for the kernel's seal, from outside the crate.
//!
//! The seal is unforgeable *admission*, not unconstructible values. External
//! code may hold a syntactically valid ID, digest, or `DecisionOpportunity` —
//! a consumer's own payloads carry these types over the wire, so they
//! deserialize — and the kernel must refuse every one it did not itself issue.
//! An integration test compiles as its own crate against the public API alone,
//! so these tests stand exactly where a consumer stands: every forged value
//! below is minted by `serde` from JSON, which is a path visibility does not
//! gate, and every rejection is asserted on the typed error rather than on a
//! message.
//!
//! Each rejection also asserts that nothing committed: revision, state digest,
//! last commit digest, and the operator log's length are unchanged. The
//! operator log is the widest committed-history surface a consumer can read;
//! the journal itself has no public accessor, so its length is proven only
//! through the digests and that log.
use chrono::{Duration, Utc};
use ghostlight::{
CommandBody, CommandId, ControllerMode, ControllerPort, CreateWorldIntent, DecisionInvocation,
DecisionOpportunity, KernelError, Lens, LensWeights, MailboxError, Mismatch, PlayPort,
PrincipalCommandIntent, Statement, SubjectId, SubmitReceipt, VerifiedPrincipalEvidence,
WorldMailbox, WorldPatch, WorldPhase, WorldSnapshot,
};
use serde_json::{Value, json};
use std::collections::BTreeMap;
/// A live world in Active phase, reached through the same public ingress a
/// consumer uses: create, approve, activate. Genesis grants speech to every
/// subject it declares, so the two model-controlled subjects each carry a live
/// opportunity with one granted affordance.
struct World {
_directory: tempfile::TempDir,
_owner: tokio::task::JoinHandle<()>,
mailbox: WorldMailbox,
principal: VerifiedPrincipalEvidence,
}
impl World {
async fn active() -> Self {
let directory = tempfile::tempdir().unwrap();
let (mailbox, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::new("external-admission-account", Utc::now() + Duration::hours(1));
let receipt = mailbox
.create(
CreateWorldIntent {
id: CommandId::new(),
title: "External Admission".into(),
brief: String::new(),
human_subject_label: "Operator".into(),
narrative_persona_label: Some("Persona".into()),
operational_agent_label: Some("Operational Agent".into()),
targets: BTreeMap::new(),
jurisdictions: Vec::new(),
// An external crate has no library fixture; it states its
// own weights, as every consumer must.
lens_weights: LensWeights::new(BTreeMap::from([(Lens::Patina, 1)])),
},
&principal,
)
.await
.unwrap();
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let revision = mailbox.snapshot().await.unwrap().revision;
mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: receipt.world_id,
expected_revision: revision,
body,
},
&principal,
)
.await
.unwrap();
}
let world = Self {
_directory: directory,
_owner: owner,
mailbox,
principal,
};
assert_eq!(world.snapshot().await.phase, WorldPhase::Active);
world
}
async fn snapshot(&self) -> WorldSnapshot {
self.mailbox.snapshot().await.unwrap()
}
fn port(&self) -> ControllerPort {
ControllerPort::new(self.mailbox.clone())
}
/// The first opportunity a model controller holds. The human subject's
/// opportunity is not one a controller port may exercise.
async fn controller_opportunity(&self) -> DecisionOpportunity {
self.snapshot()
.await
.opportunities
.into_iter()
.find(|opportunity| opportunity.controller_mode != ControllerMode::Human)
.expect("an activated world derives a model controller's opportunity")
}
/// Everything a commit would move, read through the public surface.
async fn committed(&self) -> (u64, String, Option<String>, usize) {
let snapshot = self.snapshot().await;
let log = self.mailbox.operator_log().await.unwrap();
(
snapshot.revision,
snapshot.state_digest,
snapshot.last_commit_digest,
log.len(),
)
}
}
/// Forging is a `serde` round trip: the value goes out as JSON, a field is
/// replaced, and it comes back as the sealed type. No field of
/// `DecisionOpportunity` needs to be nameable for this to work, which is the
/// whole reason the kernel cannot rely on visibility here.
fn forge(opportunity: &DecisionOpportunity, field: &str, value: Value) -> DecisionOpportunity {
let mut encoded = serde_json::to_value(opportunity).expect("a sealed opportunity serializes");
encoded[field] = value;
serde_json::from_value(encoded).expect("an external crate deserializes a sealed opportunity")
}
fn speak(affordance: ghostlight::AffordanceId, text: &str) -> DecisionInvocation {
DecisionInvocation {
affordance,
bindings: Vec::new(),
proposed: Vec::new(),
speech: Some(Statement::new(text).unwrap()),
display: None,
}
}
/// The control. Without it every rejection below could be an inert harness
/// rather than a refusal: this proves the same port, world, and invocation
/// shape do commit when the opportunity is one the kernel issued.
#[tokio::test]
async fn an_issued_opportunity_commits_through_the_same_port() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let (revision, _, _, events) = world.committed().await;
let receipt = world
.port()
.submit_controller(
CommandId::new(),
&opportunity,
speak(opportunity.affordance_ids[0], "The issued proposal stands."),
)
.await
.expect("an issued opportunity is admitted");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let after = world.committed().await;
assert_eq!(after.0, revision + 1);
assert_eq!(after.3, events + 1);
}
#[tokio::test]
async fn a_forged_scope_digest_is_refused_and_commits_nothing() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let before = world.committed().await;
let forged = forge(
&opportunity,
"scope_digest",
json!("sha256:forged-outside-the-crate"),
);
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "Let me in."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
),
"a forged scope digest was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// The value the crate's own deleted `ScopeDigest::fixture` used to produce.
/// A `ScopeDigest` is a transparent newtype over a string, so deserializing
/// that string is the whole of what the fixture did, and it is available to
/// any external crate whatever the type's visibility is. What it is not is
/// admission.
#[tokio::test]
async fn a_scope_digest_deserialized_from_a_string_commits_nothing() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let before = world.committed().await;
let forged = forge(&opportunity, "scope_digest", json!("sha256:not-a-scope"));
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "A fixture digest is not a scope."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
),
"a deserialized fixture digest was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// `ControllerId` is not even a nameable type outside the crate, and forging
/// one still needs no name: the field is a UUID on the wire. The port stamps
/// the caller from the opportunity it is handed, so a forged controller
/// speaks as itself — and the reducer refuses it because the scope's
/// assignment names someone else.
#[tokio::test]
async fn a_forged_controller_id_is_refused_and_commits_nothing() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let before = world.committed().await;
let forged = forge(
&opportunity,
"controller_id",
json!(uuid::Uuid::new_v4().to_string()),
);
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "I say I am the controller."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::ControllerMismatch))
),
"a forged controller ID was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// A `SubjectId` minted by deserialization — the kernel's `issue()` is
/// private, and this needs it no more than the wire does. The world derives
/// no opportunity for a subject it never declared.
#[tokio::test]
async fn a_command_naming_a_minted_subject_id_is_refused_and_commits_nothing() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let before = world.committed().await;
let minted: SubjectId = serde_json::from_value(json!(uuid::Uuid::new_v4().to_string()))
.expect("an external crate mints a syntactically valid SubjectId");
let forged = forge(&opportunity, "scope", json!({ "subject_id": minted }));
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "A subject who was never declared."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::OpportunityMismatch))
),
"a minted subject ID was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// The same for an `AffordanceId`: the opportunity here is entirely genuine,
/// so the forgery is only the affordance the invocation names. Admission is
/// checked against what the world granted, not against what the command
/// claims.
#[tokio::test]
async fn a_command_naming_a_minted_affordance_id_is_refused_and_commits_nothing() {
let world = World::active().await;
let opportunity = world.controller_opportunity().await;
let before = world.committed().await;
let minted: ghostlight::AffordanceId =
serde_json::from_value(json!(uuid::Uuid::new_v4().to_string()))
.expect("an external crate mints a syntactically valid AffordanceId");
assert!(!opportunity.affordance_ids.contains(&minted));
let result = world
.port()
.submit_controller(
CommandId::new(),
&opportunity,
speak(minted, "An affordance nobody granted."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::AffordanceDenied))
),
"a minted affordance ID was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// A whole opportunity built from JSON alone, naming a world, a scope, a
/// controller and an affordance the kernel never issued. This is the shape
/// the seal's wording is about: the value exists, and admission does not.
#[tokio::test]
async fn a_wholly_deserialized_opportunity_is_refused_and_commits_nothing() {
let world = World::active().await;
let before = world.committed().await;
let uuid = || json!(uuid::Uuid::new_v4().to_string());
let affordance = uuid();
let forged: DecisionOpportunity = serde_json::from_value(json!({
"world_id": uuid(),
"revision": 0,
"scope_digest": "sha256:not-a-scope",
"scope": { "subject_id": uuid() },
"controller_id": uuid(),
"controller_mode": "operational_agent",
"affordance_ids": [affordance],
}))
.expect("an external crate deserializes a whole opportunity");
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "Wholly invented."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::OpportunityMismatch))
),
"a wholly forged opportunity was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
}
/// A forgery indistinguishable in shape from a real digest, because it is a
/// real digest: one the kernel genuinely issued for this scope, gone stale
/// when the world moved under it. Only an exact comparison of the whole
/// digest refuses it; a check of its length or of any prefix would admit it.
/// A stale digest still differs early, so the test also submits the current
/// digest changed in one way each that a weaker comparison would admit: its
/// last digit changed (suffix skipped), one hex letter uppercased (case
/// ignored), and its hex bare or under another label (label ignored).
///
/// Genesis places every subject in one commons, so one controller's speech
/// lands as knowledge on the others, and knowledge is a scope component: the
/// listener's digest moves, the speaker's does not.
#[tokio::test]
async fn a_stale_issued_scope_digest_is_refused_and_commits_nothing() {
let world = World::active().await;
let controllers: Vec<_> = world
.snapshot()
.await
.opportunities
.into_iter()
.filter(|opportunity| opportunity.controller_mode != ControllerMode::Human)
.collect();
let [speaker, stale] = <[DecisionOpportunity; 2]>::try_from(controllers)
.expect("the world declares two model-controlled subjects");
world
.port()
.submit_controller(
CommandId::new(),
&speaker,
speak(speaker.affordance_ids[0], "Everyone in the commons hears this."),
)
.await
.expect("an issued opportunity is admitted");
let current = world
.snapshot()
.await
.opportunities
.into_iter()
.find(|opportunity| opportunity.scope == stale.scope)
.expect("the listener still holds an opportunity");
// The digest is not a public field; it is read the way a consumer reads
// it, off the wire.
let digest_of = |opportunity: &DecisionOpportunity| {
serde_json::to_value(opportunity).unwrap()["scope_digest"]
.as_str()
.expect("a scope digest is a string on the wire")
.to_owned()
};
let (stale_digest, current_digest) = (digest_of(&stale), digest_of(&current));
assert_ne!(
stale_digest, current_digest,
"the speech did not move the listener's scope digest"
);
assert_eq!(stale_digest.len(), current_digest.len());
let before = world.committed().await;
let result = world
.port()
.submit_controller(
CommandId::new(),
&stale,
speak(stale.affordance_ids[0], "Against a scope that has moved."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
),
"a stale issued scope digest was not refused: {result:?}"
);
assert_eq!(world.committed().await, before);
// A stale digest differs from the current one almost everywhere, so it
// cannot tell a whole-digest comparison from one that compares less. Each
// forgery below is the current digest changed in exactly one way that a
// weaker comparison would admit; each asserts that premise first.
let (label, hex) = current_digest
.split_once(':')
.expect("an issued digest is algorithm:hex");
assert_eq!(label, "sha256");
assert_eq!(hex.len(), 64);
assert!(hex.bytes().all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')));
// Only the final hex digit changed: a comparison that skips a suffix.
let mut near = current_digest.clone();
let last = near.pop().expect("a digest is not empty");
near.push(if last == '0' { '1' } else { '0' });
assert_eq!(near.len(), current_digest.len());
assert_ne!(near, current_digest);
// One hex letter uppercased: a case-insensitive comparison.
let letter = hex
.find(|c: char| matches!(c, 'a'..='f'))
.expect("the issued digest has a hex letter to uppercase");
let mut upper = hex.to_owned();
upper.replace_range(letter..=letter, &hex[letter..=letter].to_ascii_uppercase());
let upper = format!("{label}:{upper}");
assert_ne!(upper, current_digest);
assert!(upper.eq_ignore_ascii_case(&current_digest));
// The same hex without its label, and under another label: a comparison of
// the hex alone.
let bare = hex.to_owned();
let relabeled = format!("blake3:{hex}");
for forgery in [&bare, &relabeled] {
assert_ne!(forgery, &current_digest);
assert_eq!(forgery.rsplit(':').next(), Some(hex));
}
for (forgery, what) in [
(near, "one digit from"),
(upper, "one letter's case from"),
(bare, "the unlabeled hex of"),
(relabeled, "the hex under another label of"),
] {
let forged = forge(&current, "scope_digest", json!(forgery));
let result = world
.port()
.submit_controller(
CommandId::new(),
&forged,
speak(forged.affordance_ids[0], "Nearly the real scope."),
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::ScopeChanged { .. }))
),
"a digest {what} the current scope was not refused: {result:?}"
);
assert_eq!(world.committed().await, before, "{what}");
}
}
/// Every stock lens named, with real weights: the shape a consumer's weight
/// control produces, so a refusal below is about the caller and not the value.
fn real_weights() -> LensWeights {
LensWeights::new(BTreeMap::from([
(Lens::Patina, 3),
(Lens::Charter, 1),
(Lens::Ledger, 0),
(Lens::Hearth, 2),
(Lens::Tangle, 1),
(Lens::Veil, 1),
(Lens::Ember, 1),
(Lens::Numen, 1),
]))
}
/// A second verified principal holds a live session and valid weights and is
/// not the owner. The kernel refuses it and nothing commits; the same body
/// from the owner then commits, so the refusal is not an inert harness.
#[tokio::test]
async fn set_lens_weights_from_a_non_owner_is_refused_and_commits_nothing() {
let world = World::active().await;
let snapshot = world.snapshot().await;
let stranger =
VerifiedPrincipalEvidence::new("external-admission-stranger", Utc::now() + Duration::hours(1));
let before = world.committed().await;
let result = world
.mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: snapshot.world_id,
expected_revision: snapshot.revision,
body: CommandBody::SetLensWeights {
weights: real_weights(),
},
},
&stranger,
)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::Unauthorized))
),
"a non-owner replaced the lens weights: {result:?}"
);
assert_eq!(world.committed().await, before);
assert_ne!(world.snapshot().await.lens_weights, real_weights());
let receipt = world
.mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: snapshot.world_id,
expected_revision: snapshot.revision,
body: CommandBody::SetLensWeights {
weights: real_weights(),
},
},
&world.principal,
)
.await
.expect("the owner replaces the lens weights");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let after = world.snapshot().await;
assert_eq!(after.revision, before.0 + 1);
assert_eq!(after.lens_weights, real_weights());
}
/// The owner resubmitting the world's current weights changes nothing
/// canonical, so the kernel refuses it as `NoCanonicalChange` and nothing
/// commits. Admission runs first: the same identical set from a non-owner is
/// `Unauthorized`. A different set from the owner then commits, so neither
/// refusal is an inert harness.
#[tokio::test]
async fn identical_lens_weights_from_the_owner_commit_nothing() {
let world = World::active().await;
let current = world.snapshot().await.lens_weights;
let before = world.committed().await;
let intent = |snapshot: &WorldSnapshot, weights: LensWeights| PrincipalCommandIntent {
id: CommandId::new(),
world_id: snapshot.world_id,
expected_revision: snapshot.revision,
body: CommandBody::SetLensWeights { weights },
};
let snapshot = world.snapshot().await;
let result = world
.mailbox
.submit_principal(intent(&snapshot, current.clone()), &world.principal)
.await;
let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = &result else {
panic!("the owner's identical lens weights were not refused as no change: {result:?}");
};
assert_eq!(mismatches, &vec![Mismatch::NoCanonicalChange]);
assert_eq!(world.committed().await, before);
let stranger =
VerifiedPrincipalEvidence::new("external-admission-stranger", Utc::now() + Duration::hours(1));
let result = world
.mailbox
.submit_principal(intent(&snapshot, current.clone()), &stranger)
.await;
assert!(
matches!(
result,
Err(MailboxError::Kernel(KernelError::Unauthorized))
),
"a non-owner's identical lens weights were not refused as unauthorized: {result:?}"
);
assert_eq!(world.committed().await, before);
assert_ne!(current, real_weights());
let receipt = world
.mailbox
.submit_principal(intent(&snapshot, real_weights()), &world.principal)
.await
.expect("the owner replaces the lens weights with a different set");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let after = world.committed().await;
assert_eq!(after.0, before.0 + 1);
assert_ne!(after.1, before.1);
assert_ne!(after.2, before.2);
assert_eq!(world.snapshot().await.lens_weights, real_weights());
}
/// A lens a weight map does not name weighs zero, so padding a set with zero
/// weights, or dropping its zero weights, spells the same set. Through the
/// public mailbox, in both directions: the world's set is sparse and a padded
/// twin is refused as `NoCanonicalChange`; the world's set is padded and a
/// sparse twin is refused the same way. Each refusal leaves revision, digests
/// and operator log unchanged and the stored spelling as it was. A set that
/// really moves a weight then commits, so the refusals are not an inert harness.
#[tokio::test]
async fn a_lens_set_spelled_with_or_without_its_zero_weights_commits_nothing() {
let world = World::active().await;
let set = |entries: &[(Lens, u32)]| LensWeights::new(entries.iter().copied().collect());
let submit = |weights: LensWeights| {
let world = &world;
async move {
let snapshot = world.snapshot().await;
world
.mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: snapshot.world_id,
expected_revision: snapshot.revision,
body: CommandBody::SetLensWeights { weights },
},
&world.principal,
)
.await
}
};
let refused_as_no_change = |weights: LensWeights, stored: LensWeights| {
let world = &world;
let submit = &submit;
async move {
let before = world.committed().await;
let result = submit(weights.clone()).await;
let Err(MailboxError::Kernel(KernelError::PatchRejected(mismatches))) = &result else {
panic!("{weights:?} over {stored:?} was not refused as no change: {result:?}");
};
assert_eq!(mismatches, &vec![Mismatch::NoCanonicalChange]);
assert_eq!(world.committed().await, before, "{weights:?} committed");
assert_eq!(world.snapshot().await.lens_weights, stored);
}
};
// Sparse on the world, padded in the command. The world was created with
// `{patina: 1}`.
let sparse = set(&[(Lens::Patina, 1)]);
assert_eq!(world.snapshot().await.lens_weights, sparse);
refused_as_no_change(set(&[(Lens::Patina, 1), (Lens::Charter, 0)]), sparse.clone()).await;
let every = LensWeights::new(
Lens::ALL
.into_iter()
.map(|lens| (lens, u32::from(lens == Lens::Patina)))
.collect(),
);
assert_eq!(every.iter().count(), 8);
refused_as_no_change(every, sparse.clone()).await;
// A real change commits, and stores its own spelling: padded.
let before = world.committed().await;
let padded = set(&[(Lens::Patina, 0), (Lens::Charter, 3)]);
let receipt = submit(padded.clone()).await.expect("moving weight from patina to charter commits");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
assert_eq!(world.committed().await.0, before.0 + 1);
assert_eq!(world.snapshot().await.lens_weights, padded);
// Padded on the world, sparse in the command.
refused_as_no_change(set(&[(Lens::Charter, 3)]), padded.clone()).await;
refused_as_no_change(
set(&[(Lens::Charter, 3), (Lens::Veil, 0), (Lens::Numen, 0)]),
padded.clone(),
)
.await;
// A set that moves one weight by one is a change.
let before = world.committed().await;
let moved = set(&[(Lens::Charter, 3), (Lens::Numen, 1)]);
submit(moved.clone()).await.expect("adding weight to numen commits");
assert_eq!(world.committed().await.0, before.0 + 1);
assert_eq!(world.snapshot().await.lens_weights, moved);
}
/// Cut 3: `PlayPort` is the play authority's only door, and it is a public,
/// externally constructible type — Dungeon mints exactly one of these in its
/// `AppState`. This is a compile-pass use of the port from outside the crate:
/// it mints quantity through `Mint`, the spelling only `Play` may write, on a
/// resource it declares in the same patch, unconfined and with no answer.
#[tokio::test]
async fn play_port_is_the_only_play_minter() {
let world = World::active().await;
let play = PlayPort::new(world.mailbox.clone());
let holder = world
.snapshot()
.await
.subjects
.first()
.expect("genesis declares at least one subject")
.id;
let patch: WorldPatch = serde_json::from_value(json!({
"declarations": [
{
"type": "entity",
"handle": "loot",
"label": "Ruled Loot",
"kind": "resource",
"container": null,
},
],
"operations": [
{
"op": "mint",
"holder": {"ref": "existing", "value": holder},
"resource": {"ref": "draft", "value": "loot"},
"qty": 5,
},
],
"evidence": [],
}))
.expect("a mint operation, off the wire, decodes into a WorldPatch");
let receipt = play
.submit_patch(CommandId::new(), patch)
.await
.expect("the play authority mints with no answer, unconfined");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
/// A lens name the library does not know cannot be decoded into a command
/// body, beside names it does. This is a serde limit, stated as one: the body
/// never exists, so no kernel admission is being proven here.
#[test]
fn set_lens_weights_with_an_unknown_lens_does_not_decode() {
let refused = serde_json::from_value::<CommandBody>(json!({
"type": "set_lens_weights",
"weights": {"patina": 1, "veil ": 1},
}));
assert!(refused.is_err(), "an unknown lens decoded: {refused:?}");
let accepted = serde_json::from_value::<CommandBody>(json!({
"type": "set_lens_weights",
"weights": {"patina": 1, "veil": 1},
}))
.expect("known lens names decode");
assert!(matches!(accepted, CommandBody::SetLensWeights { .. }));
}

Binary file not shown.

View file

@ -0,0 +1,16 @@
aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight.
Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test
Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs),
which authors VerseGrammar, three VerseVerb and two Faction records through
Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH.
Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc
(contains aetheria-release cut verse-grammar, merged at 35af2930).
Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc
dotnet test tests/Aetheria.Shared.Tests
-p:CultLibRoot=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
--filter "FullyQualifiedName~Fixture_writer_is_deterministic"
run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10.
Content: six records; two writes of the writer agree on content, though not bytes
(CultCache stamps storedAt from the wall clock). Regenerate and diff on content,
never hand-edit.

Binary file not shown.

View file

@ -0,0 +1,3 @@
3
sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d
sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979

View file

@ -0,0 +1 @@
"a534b32a-4d00-429c-bf6c-ce1d55385380"

View file

@ -1,169 +0,0 @@
# Call Of The Void Brainstorm
Source: https://docs.google.com/document/d/1YWn5RWsciBxuaQ4l5TN2lAEz4okaQes-aiyx1E9NAxA/edit?tab=t.0
This is a local plaintext import of the scattered Call of the Void brainstorming doc. Treat it as raw source material, not canon. The current compact design note remains E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md.
## Current Canon Correction
This brainstorm predates a major Aetheria timeline migration.
The old version assumes a colony fleet or exploratory force was shunted into
Elysium and slowly discovers that nobody is going home. That is no longer the
live premise.
Current canon, summarized in
`E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\From Sol to Elysium.md`,
is that the FTL trigger sequestered the whole Solar system, with its fleets,
institutions, corporate blocs, uploaded minds, labor systems, histories, and
unresolved social machinery. Elysium is not a remote elsewhere that a fleet
cannot leave. It is the sealed domain into which Sol itself was displaced.
So the useful dramatic pressure is not "can the expedition return home?"
because home came with them. The better pressure is:
- there is no clean outside to return to
- the systems people hoped to escape are trapped in Elysium too
- the old order can no longer rely on the old universe remaining stable around
it
- every faction is trying to define what survival, legitimacy, personhood, and
ownership mean now that Sol's whole inheritance has been exported into the
sealed domain
- Cat's story should turn on the realization that starting over is not escape
when the machinery you fled arrived in the same disaster
The raw notes below remain useful for cases, characters, tones, and comic
cruelty. Treat their old "return home" twist as obsolete pressure, not active
continuity.
---
Potential ideas here, feel free to discuss/deplore/detour in comments.
Protagonist: Catastrophe “Cat” Marrigan, an out of work private investigator, driving a particularly failure-prone space taxi to make ends meet
Overarching plot: Episodic PI cases/random space taxi fares which lead, somewhat circuitously, towards the discovery/revelation that the “exploratory force” had better get used to being “locals”, because the universe is not offering return tickets, and all that follows from that.
What’s Cat’s role in this? And are we going to have alternate endings based on choices made? Ideas, in no particular order:
* She's the original founder/inspiration for the frontier renegades who reject corporate rule and live low-tech and tiny to avoid being crushed.
* The more noir-esque ending, where the disillusioned protagonist sees the way things are going and kills themselves/goes out in a blaze of explosive glory in a futile attempt to change the course of society.
* The out-for-what-you-can-get ending, where she allies with an up and coming power to become a behind the scenes force in future aetheria.
* The really unsatisfying but realistic ending, where the corps take over and life just goes on.
* I sort of like the idea of heading towards a “you can’t win against the capitalist machine, but it’s worth fighting anyway” theme. Thoughts?
case/fares/conversations that exemplify the different ways people react to the point of no return, so to speak, ie: denial, determination to find a way to get back, culty doomsdays types, the “we’re all doomed so let’s party” people, the ones who see great opportunity without old earth holding them back, the people who are determined to do things better this time around (in the ‘we royally screwed up earth, let’s not do it again” sense), etc.
Accidental AI ship whose buggy programming makes them the worst autopilot ever orrrrr…the inciting incident is that in one of her cases/fares gone wrong Cat gets mind-melded with said slightly buggy AI, and that’s how she finds out all those juicy government secrets. Some of them may be inaccurate (see: bad code/failsafes/sabotage), but there’s no way of knowing which without going digging. Plus AI is a one of a kind experimental prototype, which sets all kinds of interesting people on their tail. Also could give us some fun things to do with ship control and actual gameplay quests.
Over the course of the story, one way to show people’s reactions to events could be to have some dialogued characters talking about what they plan to do when they get back home, then use it as a major stress point once the truth is revealed.
Maybe Cat works for a small, kind of terrible PI company, in which case we could get some of our quests from a dispatcher/boss/whatever. I think it’d be a little funny if in true noir fashion, boss was a hard-drinking dude in a trenchcoat, only he’s a really giggly drunk/can’t hold his liquor.
First quest for PI company: find where the boss left a random object after drinking himself to the floor. Find out he left a whole-ass ship somewhere in his drunken stupor.
Another PI quest: investigate the disappearing foodstuffs of a concerned homeowner and find out that a whole nother person was living in the crawlspace above their home and stealing their food. Decide how to deal with Mr Crawlspace.
PI quest: Investigate “ghost” sightings in an old part of a station that’s been shut down. Find out a whole town’s worth of people are living there out of the station’s control slowly siphoning off resources so as not to be noticed. Get some slightly culty vibes from them.
A short quest from an old lady who just wants to have someone over for tea. Turns out if you tolerate her long enough she’ll come in handy during a critical time. Could be she’s either rich or well-connected.
A botanist looking for his daughter. Turns out instead of some sorta protomolecule conspiracy, she’s just wandered off and is being looked after by a kind lady who’s found her.
Get a quest from a mysterious person who is only contacting Cat online, find out eventually the quest giver is some sort of AI who’s either seeking the sweet release of deletion, is looking to find another AI, or is trying to steal some sort of drone so it can go around freely.
Possibly a character who we only see as a remote-controlled drone who helps out at times but we never see the person behind the drone because they use the drone as their public avatar or something. Could be someone who might not otherwise get the chance to go out and about in public because of reasons. This does give them a limited sphere of helpfulness since the drone has to be close enough to the controller to work effectively. Could have Cat mistake the drone for the character, believing it’s some sort of self-sufficient AI driven drone and be sad when it “dies” only to meet it again once it’s re-built and realise there’s a person behind it. Bit like the “pay no attention to the man behind the curtain” bit, could call the drone “Oz”.
Cat is tasked with investigating a mysterious and aggressive band of pirates. Upon finding them it is revealed that they are actually a community of rogue AIs who have no option but to loot technology transports in order to keep themselves alive.
Cat stumbles into a friendship with a shady character, earning the attention of the even shadier Space CIA, which recruits her as a potential double agent with instructions to infiltrate the organization.
Cat is sent to investigate a series of mysterious disappearances of ships sent to mine a gas giant’s belt. Locals consider the place haunted. Turns out it is, by a sentient particle system which considers itself the embodied spirit of the planet.
DLC Area: Cat falls afoul of some sort of anomaly which sends her into a parallel universe with an alternative history. Somehow despite a very different timeline and political situation, everyone she meets is a bizarro mirror of the people she knows from her dimension, and somehow the exact same cast of characters are present despite having very different roles (listen, we can’t afford any extra actors so we’re just gonna use the cast we already have)
Finch is destructively scanning recently deceased human brains and then performing extensive virtual surgery on emulated instances of them in order to create and sell packaged “AI” servitors for specific tasks to compete on the market against AGI products from competitors. The Crew manages to rescue one of the program’s victims while still mostly human, before the process is complete, who [joins the party / is installed on Cat’s ship].
Character: Reginald Moneybags III, inheritor of a massive family fortune, is looking to become a patron of the arts in order to impress his old-money friends. Perhaps they’re the one giving the player their ship?
Might could explore some of Cat’s motivations for taking the ship to Elysium in the first place. Was it by choice? Who has she left behind, if anyone? It would put her in a more conflicted position regarding her role as a double-agent.
* Seems natural that she would be escaping a maze of debt and obligation, only to end up terrifyingly close to falling into the same trap again. I kinda like this idea of completely dumping her whole life and trying to start over somewhere completely different.
* She was caught committing some felony/is railroaded/scapegoated for same/investigated someone way too powerful and was given a choice between prison and joining the colonists.
* Or combine both and bring back the idea of debtor’s prisons/workhouses and have the ship to Elysium be offered as a way out
* So, was she indentured when she arrived only to recently gain the freedom to make her own way, or has she been doing what she’s currently doing since arrival?
The faceless man: a guy? maybe. Wearing a spacesuit or some other obscuring disguise that pops up a little too often for random chance. Is he a red herring? Or is he 10 red herrings in a spacesuit pretending to be a guy? If he has any dialogue let it just be *splashing noises*.
Cat is sent to investigate an urban legend on one other stations--the simply and slightly erroneously historically named “Ripper Lee” who lives in the air vents and steals children. It actually turns out that the missing kids are just exploring the vents as children will and getting macheted by fans or getting stuck or getting pulled into the inertial compensator. [an act II continuation of this quest sees Cat investigating her suspicions that the kids didn’t necessarily go into the dangerous areas of their own impetus, but were lured there. The third act reveals an alien intelligence--though an animal one--a being of energy or something stranger who finds the particular pitch of childrens’ laughter unbearable. Maybe it destructively interferes with its phase, who knows]
The one perpetual drunk who always needs a lift to a different space bar. Cat can go on
a bender with him as a quest, and then have to figure out exactly what the hell happened and why there’s a crash test dummy in the front seat of her taxi.
The person who jumps into Cat’s cab and yells “follow that ship!”. A high octane space chase follows, at the end of which it is revealed they’ve been following the wrong ship, and the Minister of such and such is *very* displeased that he missed his Very Important Meeting for what turns out to be not even an actual assassination attempt.
A carjacking with several different options for resolution
Something strange is going on in the mining operation on PXR 6937, and Cat is sent to find out what: suggestions: the drilling released a substance from the rock that causes hallucinations and paranoia in human brains. Someone is deliberately sabotaging the operation as an environmental protest/act of general rebellion/the mining operation killed someone they cared about and now everyone must feel the same misery
Cat is forced to navigate the pointless bureaucracy of space offices to uncover an embezzlement scheme that conceals something much bigger. What is it? Fuck if I know.
* A furniture selling operation that appears to cover some financial wizardry but turns out to be a human trafficking operation where the names of the items are actually those of missing children OW MY HEART
* A fiendish plan to utilize the combined computing power of thousands of office servers to...mine future bitcoin? Solve something the megas don’t want solved?
Cat’s taxi breaks down somewhere off the beaten path, so to speak, and she finds herself drawn into an ongoing and very brutal feud between two families.
Have a random cab passenger speak in Nemsk and never explain it.
An entire district of a certain station orbiting a strange ocean planet begin to complain of seeing people who could logically never be there. Cat is asked to do a cursory look into it and remains sceptical until one day she starts seeing a person shaped figure always walking just barely out of her line of sight. We could either go full Lem, or subvert this reference by having the reveal be that the air or water are contaminated with a hallucinogenic compound.
A Captain Nemo like figure who flies his rather unusual ship on the far edges of the system. His purpose is unclear, and his identity more so.
Silica storage ruptured, spilling into common area. Now we’re doing the obligatory beach episode! Sand got into the gears of the docking bay so now everyone has to sit back for an afternoon and do anime tropes while it's getting repaired.
What organization is Cat infiltrating?
* People who are trying to go home, even if it costs them the ship or other people’s lives
* Believe the Megas are withholding research on the return trip, trapping everyone in Elysium for profit
* Name ideas
* Home (nice and simple; is it an acronym: H.O.M.E.?)
* A nickname could be the “homies” lmao love it “wassup homie?”
* Terra
* The Terra-rists or Terrists (like a hick would say) Get out no.
* Could be a media nickname (and everyone is really tired of explaining that “no, no, we’re the Alliance for Returning Swiftly Earthward, really” really? Butt jokes? I have never pretended to be above them. Neither have I.
* The Earth-Bound
* The Regressionists
* The Returners (sounds like a ya novel title) true
* The Retrograde Alliance
* Earthers
* The Genesis Project (Project Genesis? Sounds better.) Genesis Faction?
* Alliance for Returning Swiftly Earthward
* Gaia-ists possibly too many vowels
* The Followers of Gaia? Get that good greek mythology I kind of like this one. The Gaia Cult lmao
* Cult of Gaiains, but also yeah, the GaiaCult
* Hades (if we’re going Greek mythology, normal people go to Hades while demigods go to Elysium) Erebus
* Has the bonus “evil” connotation
* Hadesians Perhaps? Like people from there...Hadesites?
* Fits into the sentence “The CIA needs you to infiltrate Hades”
* Though if we’re going for the greek theme, Odysseans might be more apropos with the whole ‘returning home’ I like this for greek references
How do we handle double agent shenanigans?
* Cat grows to sympathise with their goals and philosophy, ends up rejecting the CIA
* Cat comes clean with her new friends, who begin reciting the typical cliche, only to have someone start to read a page that semi-describes events later in the story, like it's a script.

View file

@ -1,386 +0,0 @@
# Aetheria Agent Population Model
This note explains how a socially dense Aetheria region can feel inhabited
without simulating every implied person as a fully persistent agent.
The short answer is that population and agency are not the same thing.
A corridor can imply hundreds of thousands or millions of people while only a
few dozen individuals need to exist as full long-lived social nodes.
## Design Goal
We want a region that feels:
- crowded
- economically alive
- politically pressured
- socially stratified
- full of rumor, labor, traffic, and institutional drag
We do **not** want:
- ten thousand expensive little chatbots idling in a warehouse
- fake scale achieved by silent cardboard extras
- a world where only named characters seem to exist
So the population model needs to separate:
- who exists as a persistent social mind
- who exists as a grouped social force
- who exists only as local scene color
- how offscreen population pressure becomes visible
## Four Population Layers
The MVP should use four layers.
### 1. Persistent Agents
These are the full agents.
They have:
- identity
- memory
- relationships
- goals
- obligations
- changing stance toward institutions and people
These are the characters who accumulate history and can plausibly remember what
happened last week, who lied to them, who saved them, and which institution
just humiliated them.
Examples in an Aetheria content-generation slice:
- a faction liaison
- a ship officer
- a labor organizer
- a corporate handler
- a sanctuary coordinator
- a black-market broker
- a cutout vendor
- a technical specialist with inconvenient loyalties
These are not the population. They are the pressure points where
population-scale systems become legible.
### 2. Cohorts
Cohorts are grouped social bodies that matter but do not need full individual
cognition.
They should represent classes of people whose collective condition can change
over time.
Examples:
- dock labor shifts
- convoy deck crews
- insurer back-office clerks
- sanctuary residents
- clinic queues
- pirate route crews
- private security contractors
- debt pilgrims
- repair-bay technicians
- refugee transit waves
Each cohort should have state such as:
- size
- location
- stress
- trust in institutions
- rumor exposure
- faction sympathy
- fatigue
- scarcity pressure
- protest potential
- desertion / mutiny risk
This is how the world gets to have labor, crowds, queues, shortages, and
unrest without pretending every forklift operator needs a hundred memories and
a private speech cadence.
### 3. Ephemeral Scene Actors
These are temporary people instantiated only when a scene needs bodies,
witnesses, clerks, mechanics, medics, bystanders, or small bits of friction.
They should have:
- a role
- a scene goal
- local awareness of current events
- a little voice
- minimal short-term memory
They should **not** be treated as deep persistent minds by default.
Examples:
- a berth marshal waving traffic through a panic checkpoint
- a frightened passenger arguing with a clerk
- a dockhand spreading a fresh rumor
- a medic triaging one more body than the shift can really handle
- a sanctimonious insurer junior who disappears once the scene ends
If an ephemeral keeps mattering, the system can promote them into a persistent
agent later.
### 4. Ambient Population Pressure
This is the layer that makes the world feel bigger than the visible cast.
It is not represented by people at all. It is represented by signals.
Examples:
- berth queues growing or shrinking
- cargo backlogs
- corridor delay maps
- sanctuary occupancy
- clinic wait times
- food prices
- insurance repricing
- labor slowdown notices
- protest bulletins
- casualty lists
- rescue requests
- media chatter
- tribunal rulings
- route closures
This is where the implied millions live in practice.
## How The Layers Interact
The model should feel like this:
- ambient pressure changes cohort state
- cohort state changes the incentives around persistent agents
- persistent agents make decisions that alter ambient pressure
- ephemeral actors express the local scene consequences
That loop lets a single adjudicator decision or pirate rescue cascade outward
into visible population-level effects without simulating every single person
affected.
## Why A Few Dozen Persistent Agents Is Enough
Because the simulation is not trying to create a census. It is trying to create
a socially legible machine.
In a dense Aetheria slice, one persistent agent can sit at the hinge point for
a great many people:
- one dispatcher affects dozens of ships and hundreds of crew
- one sanctuary coordinator gates a network under pressure from thousands of vulnerable lives
- one corporate official can reshape access, risk, and reputation
- one faction delegate can legitimize or delegitimize a compromise
- one broker can redirect shadow traffic across many dependent cells
The persistent cast is therefore best understood as:
- decision-makers
- bottlenecks
- brokers
- organizers
- interpreters of institutional rules
- people with unusual leverage over larger flows
They are the knots in the rope, not every fiber.
## Cohort Model
Cohorts should be tractable and opinionated.
Each cohort record should include:
- `cohortId`
- `type`
- `sizeBand`
- `region`
- `factionAffinity`
- `currentPressures`
- `resourceDependency`
- `confidence`
- `grievance`
- `discipline`
- `mobilizationPotential`
- `recentIncidents`
Useful cohort types for Aetheria story and content generation:
- dock labor
- ship crews
- sanctuary residents
- corporate workforce
- shadow-market crews
- repair-yard workers
- shadow clinic clients
- private security detachments
- local merchant bodies
- transient passenger pools
Cohorts do not need rich dialogue. They need believable collective reactions.
## Ephemeral Actor Rules
Ephemeral actors should be generated from:
- scene location
- cohort state
- current world pressures
- factional tone
- time of day / shift
That means the same dock concourse can produce different incidental people
depending on whether the corridor is calm, panicking, starving, over-inspected,
or politically inflamed.
Ephemerals should support:
- local exposition
- scene texture
- small moral friction
- witness testimony
- short disputes
- visible consequences
They should expire cheaply unless the world keeps pulling them back into
relevance.
## Promotion Rules
Some ephemerals should be promoted into persistent agents when:
- a player or major agent forms an ongoing relationship with them
- they become central to repeated incidents
- they accumulate leverage or notoriety
- they are the surviving witness to something politically explosive
- they become the face of a cohort-level shift
Promotion is how the world grows specific people organically instead of
requiring the whole cast to be authored up front.
## Population Signals That Make A Place Feel Busy
If the corridor is meant to feel inhabited, the system should constantly
surface signs of offscreen life.
That means scenes, dashboards, and local UI should be fed with things like:
- arrivals and departures
- route advisories
- late manifests
- dockside rumor digests
- occupancy warnings
- strike whispers
- inspection delays
- tribunal alerts
- debt-claim notices
- casualty and rescue numbers
- commodity shocks
- public feeds and scandal bursts
The player does not need to meet everyone. They need to feel surrounded by
consequences.
## Content-Generation Population Recipe
For the first authoring slice, a sensible starting mix is:
- **20-40 persistent agents**
- **10-25 cohorts**
- **0-20 ephemerals per active scene**, generated on demand
Persistent clusters should be chosen from the story context, but the useful
categories are:
- primary faction representatives
- ship or station operators
- labor and logistics figures
- care, sanctuary, or rights workers
- criminal or deniable-economy brokers
- corporate cutouts
- technical specialists
- witnesses with leverage
Cohorts:
- dock labor
- crews
- sanctuary or civilian overflow
- clinic queues
- administrative or inspection staff
- security contractors
- shadow-market cells
- repair techs
- cargo handlers
- passengers / transients
That is enough to make generated scenes feel socially populated without turning
the authoring runtime into a screaming furnace.
## Simulation Tier Mapping
The population layers should map onto the simulation tiers cleanly.
### Tier 0
- dormant persistent agents
- slowly changing cohorts
- coarse ambient indicators
### Tier 1
- active persistent agents making strategic moves
- cohorts shifting under pressure
- route, labor, and sanctuary state updating
### Tier 2
- a handful of persistent agents in scene
- relevant ephemerals generated for the local moment
- cohort state informing mood, risk, and crowd behavior
### Tier 3
- full interactive dialogue for the most relevant persistent agents
- ephemerals only as needed for texture or interruption
- world pressure visible through feeds, queues, alerts, and consequences
This keeps expensive cognition focused where it matters.
## What Not To Do
Do not:
- simulate every resident as a full agent
- let cohorts become inert statistical wallpaper
- use ephemerals as consequence-free decoration
- make named agents behave as if the rest of the world does not exist
- make ambient scale purely visual with no mechanical pressure
That path leads to a world that looks crowded and feels dead.
## Success Criteria
The model is working if:
- the player can feel institutional pressure without being told about it in lore prose
- small decisions by key agents visibly affect larger populations
- cohorts can become restless, relieved, fearful, or politically active
- scenes feel crowded when they should
- the same corridor can feel different under calm, panic, shortage, scandal, or repression
- the named cast feels embedded in a larger society rather than posed in a diorama
The real test is simple:
If the player leaves the room, do they still believe thousands of people are
having a bad day because of what just happened?
If yes, the world is inhabited.

View file

@ -1,206 +0,0 @@
# Aetheria Content Generation Targets
This is the first concrete target note for applying Ghostlight to Aetheria.
The build target is not a standalone corridor simulation or local crisis game
slice. That older shape was fertile story terrain, but it is not the current
product direction. Do not let it shape the runtime unless the user explicitly
revives it.
Ghostlight should first serve two Aetheria uses:
1. content generation inside the **Call of the Void** slice described in the
`AetheriaLore` repository
2. dialogue scaffolding and procedural drama for narrated stories across the
Aetheria timeline
The goal is a story and content-generation organ: a machine that can hold
characters, factions, pressures, memories, masks, and conflict arcs in explicit
state, then project that state into usable scenes, dialogue beats, outlines,
and dramatic complications.
That organ must also hold tone. Aetheria should not be flattened into one dry
technical crisis register. The setting can carry Adams/Pratchett-style
wit-with-stakes, domestic comedy, noir suspicion, horror, wonder, dour poetry,
romance, workplace absurdity, and procedural systems prose, often inside the
same broad canon. The common rule is not "be grim." The common rule is that
tone must reveal character, setting, and consequence.
## Why This Helps Dialogue
Ghostlight should not ask one LLM prompt to "write good character drama" from
vibes. That path produces clever paste: fluent, energetic, and usually a little
dead behind the eyes.
The useful split is:
- Ghostlight builds the drama, narrative pressure, scene setup, and state.
- The dialogue renderer gives each character a constrained local context.
- The character speaks from who they are, what they have endured, what they
want, what they are feeling, what they are hiding, and how they perceive the
person in front of them.
This does not mean every line should sound like emergency procedure. Characters
also speak from routine, fatigue, affection, boredom, private jokes, little
status games, cultural rituals, and ordinary work. Those are not filler; they
are what make later pressure legible.
That means dialogue prompts should be character-local rather than omniscient.
They should include only the state that character plausibly has access to:
- identity and role
- current mood and pressure
- relevant memories
- active goals
- relationship stance toward the listener
- perceived stakes
- cultural or factional scripts
- mask, concealment, or presentation strategy
This lets characters breathe and act inside the scene instead of serving as
mouthpieces for a single plot-generating agent. The agent can shape the drama.
The character projection should preserve subjectivity.
## Primary Use: Call Of The Void Content
Call of the Void should be treated as the first consumer context for Ghostlight,
not as an excuse to build a full social MMO simulation immediately.
The local source of truth is:
- `E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md`
- `docs/aetheria/call-of-the-void-brainstorm.md` for raw imported brainstorm
material
The slice is a story-first Aetheria scope cut about Catastrophe "Cat" Marrigan:
an out-of-work private investigator making ends meet with a failure-prone space
taxi inside the aftermath of Sol's sequestration into Elysium.
The older brainstorm frames the core twist as a colony fleet or exploratory
force discovering that nobody is going home. That premise is obsolete. Current
Aetheria canon says the FTL trigger displaced the whole Solar system into the
sealed spacetime domain later called Elysium. Home did not stay behind. Home,
with every corporate bloc, labor regime, upload abuse, political failure, and
unresolved compromise, came through too.
So the active Call of the Void pressure should be refactored from "there is no
return ticket" into something meaner and more useful: there is no outside, no
clean restart, and no frontier innocent of Sol's history. Cat can still try to
outrun debt, coercion, legal pressure, or institutional rot, but Elysium proves
that escape exported the machinery instead of dissolving it.
Its useful shape is authored density:
- episodic investigations
- taxi fares as social x-rays
- station districts
- recurring characters
- faction contacts
- campaign consequence
- the social aftermath of realizing Sol's old failures are now Elysium's local
weather
Ghostlight should help generate and manage:
- character dossiers
- faction stances
- relationship tensions
- tonal mode and style targets
- ordinary-life texture before conflict
- scene premises
- dialogue scaffolds
- procedural complications
- continuity-aware reactions
- memory-shaped follow-up scenes
- local cultural and institutional pressure
- case premises and clue chains
- passenger encounters that reveal denial, opportunism, homesickness, fear, or reckless celebration
- continuity pressure from the sealed-domain premise
- cases that expose how old Sol institutions mutate when there is no larger
outside to appeal to
The important thing is that generated content should emerge from structured
state instead of a prompt asking for "dramatic dialogue" and hoping the machine
does not start juggling knives in the pantry.
## Secondary Use: Narrated Timeline Stories
Ghostlight should also support story generation across Aetheria's broader
timeline.
The existing Aetheria stories are tonal evidence. `When We Get Home` makes the
setting legible through jokes, domestic longing, debt, delays, and the slow
recognition that home has moved. `Rain` makes the setting legible through
ritual, memory, sensory repetition, and a patient reversal from wonder to
horror. Future fixtures should be able to choose similarly varied modes instead
of filing every event into one stern technical drawer.
That means it should be able to produce:
- scene outlines
- relationship escalation chains
- factional pressure maps
- alternate dialogue passes
- conflict beats
- character-specific reactions to historical events
- continuity summaries for later story work
This does not require every story to run as a live simulation. Some outputs can
be generated as authoring scaffolds: structured suggestions, scene cards,
argument maps, motive webs, and dialogue skeletons for a human author to shape.
## What Ghostlight Should Build For This
The first useful organs are:
1. canonical agent state schema
2. perceived-state overlay schema
3. relationship and faction stance records
4. event and scene records
5. prompt projection renderer for content generation
6. coordinator/story runtime driven by goals, values, pressure, memory,
continuity, branch flags, and author constraints
7. branch compiler that turns reviewed branch plans into Ink, sidecars,
callbacks, consequence variables, and visual prompt handles
8. IF artifact reviewer that catches decorative choices, fake folds, unused
variables, and missing consequence callbacks
9. dialogue scaffold renderer that preserves stance, mask, and relationship
asymmetry
10. character-local dialogue context packs that restrict each generated voice to
what that character knows, feels, wants, fears, and perceives
The output should be inspectable. The author should be able to see why the
machine thinks a character would deflect, confess, threaten, soften, bargain,
lie, leave, spend a resource, conceal evidence, or make a choice that changes a
later scene.
## What This Is Not
This is not yet:
- a standalone corridor crisis game
- a persistent corridor simulator
- a full MMO social runtime
- a requirement to simulate every offscreen person
- a replacement for authorial judgment
Ghostlight is an authoring and social-state engine first. The live game-shaped
organs can come later if they earn the trouble.
## Success Criteria
The first implementation is useful if it can:
- keep character state distinct from prompt prose
- render dialogue from character-local context rather than global plot vibes
- generate branching dialogue/action scaffolds that reflect relationship history
- create procedural drama from incompatible goals instead of random hostility
- preserve cultural and factional pressure without making clones
- produce story beats a human author can actually use
- make branch choices matter through later affordances, callbacks, visual
modifiers, costs, risks, and endings
- explain its choices in state terms rather than vibes in a rented coat
The real test is not whether the system can improvise a cool scene once. The
real test is whether it can help generate the tenth scene without forgetting
what the first nine did to everyone involved.

View file

@ -1,386 +0,0 @@
# Aetheria Lore Grounding Architecture
Ghostlight needs three different Aetheria modes:
- grounded historical training
- post-Rupture procedural future training
- procedural Elysium generation
Do not blur them. That is how a useful mystery becomes a filing cabinet full of
branch facts with the labels scraped off.
## Core Boundary
Planned Aetheria gameplay takes place in Elysium. What happens there is
intentionally not fully fixed in the lore vault because discovering and shaping
that history is part of the games themselves.
The Aetheria timeline begins when humanity is transplanted into Elysium. It can
restart when humanity destroys itself again through negligence, greed,
institutional capture, civilizational momentum, and the familiar psychological
trash fire wearing a crown.
That means Elysium-era gameplay scenes are not the best first source for
grounded Ghostlight training data. They are downstream procedural territory.
For grounded training data, Ghostlight should start from the authored historical
archive: late Sol, Pre-Elysium institutions, factional flashpoints, personhood
conflicts, labor regimes, political collapse, and the FTL trigger.
That does not mean Ghostlight should avoid Elysium futures. The trained models
will need to handle post-Rupture concepts: Aether, pseudospace, temporal
nonlinearity, spirits, altered substrates, necrotech, mutable bodies, and all
the new ways power learns to monetize miracles while pretending it found ethics
in a drawer.
Elysium future data belongs in a separate lane because its canon is conditional,
not because it is fake. Sol history is a single artifact. Elysium history is a
branching state-space: every possible future is canon inside the branch whose
prior conditions produced it. A generated future must therefore carry branch
lineage, dependencies, and local truth boundaries instead of being treated as
scratch material waiting for permission to matter.
## Revised Architecture
Grounded historical training flow:
```text
Aetheria authored historical lore
-> lore grounding digest
-> historical scene fixture
-> agent-state fixture
-> speaker-local input slice
-> projection artifact
-> prompt_text
-> generated dialogue or drama
-> reviewed training example
```
Procedural Elysium flow:
```text
grounded pre-Elysium priors
-> procedural Elysium branch state
-> Ghostlight agents and institutions
-> projected dialogue and drama
-> simulated branch history
-> branch-local canonical state
```
Post-Rupture future training flow:
```text
authored Elysium concepts and constraints
-> possible future premise
-> procedural branch fixture
-> coordinator plan and projected local contexts
-> generated action, dialogue, and event consequences
-> reviewed future-branch training artifact
-> branch-local canonical history indexed by lineage
```
The first flow teaches the machine what grounded social pressure looks like.
The second and third flows teach it how those pressures mutate under Elysium's
stranger conditions while keeping branch-local canon separate from the Sol
artifact and from sibling Elysium branches.
## Source Priority
For projection training, prefer sources in this order:
1. Authored historical lore with explicit institutional, social, and political
context.
2. Grounded transition material around the FTL trigger and displacement.
3. Historical fiction or scene fragments that remain canon-compatible.
4. Authored post-Elysium concept documents that define altered conditions,
technologies, species, metaphysics, or social pressures.
5. Elysium-era procedural fixtures marked with explicit branch lineage and
conditional truth boundaries.
Call of the Void and Cat/Oz can remain useful for testing projection mechanics,
speaker-local boundaries, and prompt shape. They should not be treated as the
primary linear-history training corpus because their facts belong to Elysium's
conditional branch space.
## Historical Flashpoint Candidates
Good first fixtures should come from parts of Aetheria that are already
pressure-dense and source-grounded:
- contract citizenship and debt regimes
- labor-system enforcement and infrastructure dependency
- upload abuse and contested personhood
- uplift or nonhuman personhood conflicts
- algorithmic governance and predictive policing
- corporate blocs turning infrastructure into leverage
- the FTL program as a political escape hatch from Sol's failure
- the moment around the FTL trigger, where institutions and people discover
that escape did not mean absolution
The best scenes are small, local, and socially loaded:
- a worker negotiating dangerous contract terms
- an upload arguing over continuity, ownership, or service denial
- a technician asked to sign off on an unsafe FTL dependency
- a debtor deciding whether help is relief or capture
- an institutional agent performing compassion while enforcing a machine
- a family, crew, or workplace discovering that political collapse has become
intimate
## Lore Grounding Digests
Before producing training projection examples from a historical flashpoint,
create a lore grounding digest.
The digest format is defined in:
- `docs/architecture/lore-grounding-digest-format.md`
- `schemas/lore-grounding-digest.schema.json`
- `examples/lore-grounding/historical-flashpoint.template.json`
A digest should include:
- source files and relevant line or section notes
- time period
- location or institution
- involved factions, classes, and roles
- material constraints
- local norms, taboos, and scripts
- vocabulary or speech-register cues
- what each speaker plausibly knows
- what each speaker misreads
- author-only context that must not enter prompt text
- open questions needing lore elaboration
It should also include cultural and factional pressure fields:
- internal virtues
- internal shames
- prestige markers
- taboo triggers
- emotional display norms
- trust defaults
- speech register cues
- outsider stereotypes
- factional misread patterns
- role obligations and failure modes
The digest is not a replacement for the lore vault. It is a compiled working
slice for one fixture, so projection examples can cite grounded pressure
without dragging the whole archive into every prompt.
## Fixture Types
Use explicit fixture status:
- `historical_grounded`
- `transition_grounded`
- `procedural_branch`
- `future_branch`
- `branch_lineage_canon`
`historical_grounded` and `transition_grounded` examples can become primary
single-history training data.
`procedural_branch` and `future_branch` examples are canonical inside their own
branch lineage. They are useful for testing, generation, responder training,
coordinator training, and post-Rupture concept coverage, but must not be treated
as facts that also apply to sibling branches or to the Sol artifact.
`branch_lineage_canon` examples are reviewed branch histories whose lineage,
prior conditions, and local state have been made explicit enough to serve as
stable training or gameplay continuity.
Use `future_branch` when the point of the fixture is to explore possible
post-Rupture Elysium outcomes and weird-concept behavior. Use
`procedural_branch` for ordinary gameplay-era branch fixtures whose main job is
scene/runtime testing.
## Projection Rules
Grounded projection examples should prove:
- the prompt text is self-sufficient for the dialogue model
- setting and institutional context are speaker-local
- historical pressure appears as lived constraint, not exposition paste
- the speaker does not know author-only future outcomes
- the generated behavior follows from state, culture, memory, relationship, and
material pressure
If the lore source is too vague for a convincing scene, stop and elaborate the
lore slice first. Do not train the model to pave over missing social reality
with confident fog.
## Post-Rupture Future Branches
Future-branch generation should be deliberate, not a slush pile of glittering
possibilities with no handles.
Each possible future fixture should declare:
- which authored post-Elysium concepts it exercises
- which facts are fixed source constraints
- which facts are generated branch assumptions
- which branch facts are local to this possible future
- which prior conditions make this branch true
- which facts belong to this branch lineage and not to sibling branches
- which generated facts must not leak back into single-history Sol training
- what new social pressure the weird concept creates
- what old pre-Elysium pressure is returning in a new costume
## Attractors, Rails, And Injections
Elysium is a branching state-space, not an undifferentiated fog. Some events may
happen in most branches because the pressures almost always produce them. Some
events may be authored constraints because the game needs a stable enough world
to design around. Some events may be injected by quests or scenario logic when
an active branch reaches a compatible shape.
Use separate labels:
- `branch_attractor`
- `fated_event`
- `tech_order_constraint`
- `quest_injection`
- `branch_local_event`
`branch_attractor` means an event is highly likely under broad pressures but
not absolutely forced. The founding of a faction born inside Elysium may start
here if the social, economic, metaphysical, and institutional gradients make it
show up again and again with local variation.
`fated_event` means the authorial hand is on the scale. The event should occur
unless the branch has explicitly disabled it. Use this sparingly, because too
much fate turns the quantum superposition back into a hallway with rude
wallpaper.
`tech_order_constraint` means discovery order matters. Some technologies,
metaphysical practices, industrial dependencies, or social adaptations should be
unavailable until their prerequisites exist. This is not merely lore flavor; it
is a runtime gate for what quests, economies, factions, and character actions
can plausibly do.
`quest_injection` means authored content enters the active timeline at a branch
point. A quest may require prerequisites, forbid certain prior states, create
new local facts, or push an attractor toward one concrete manifestation.
`branch_local_event` means the event is canonical inside its lineage but has no
special pull across other branches.
Future branch fixtures should record:
- event type from the labels above
- probability or pull strength, for attractors
- required branch prerequisites
- excluded branch states
- technology prerequisites
- faction or institution prerequisites
- quest ids or content hooks, if injected
- local variation allowed
- hard facts created
- branch facts that remain conditional
Future branch fixtures should also emit technology and item manifest candidates
when exploration changes the material economy:
- new item families
- new item variants
- component and subassembly breakdowns
- assembly upgrade surfaces
- technology prerequisites
- faction or manufacturer access
- supply bottlenecks
- salvage, counterfeit, maintenance, and substitution paths
- quest hooks created by scarcity or discovery
See `docs/architecture/technology-item-manifest-plan.md`.
Factions born in Elysium should be modeled through this lens. Adrasteia or Miss
Terri's Sugarrific Snack Company can be represented as pressure-born attractors,
fated institutions, quest-injected branch content, or some combination. The
point is not to decide that once in prose and hope the game remembers. The point
is to make the founding mechanism explicit enough for Ghostlight, the active
timeline, and later game systems to agree on what kind of inevitability they are
dealing with.
Good future branches should stress the model with conditions it cannot learn
from late-Sol history alone:
- Aether as infrastructure, ecology, religion, weapon, medicine, or status good
- pseudospace as transit, concealment, displacement, exile, or labor terrain
- temporal nonlinearity as memory, debt, evidence, grief, insurance, or law
- spirits as persons, tools, contaminants, witnesses, ancestors, or frauds
- necrotech as care, extraction, continuity, punishment, or labor capture
- mutable bodies and species boundaries as liberation, class marker, liability,
taboo, or supply chain
- post-Rupture institutions rebuilding old hierarchies with new miracles
These branches should feed:
- Aetheria responder training, so the model learns post-Rupture assumptions
- coordinator training, so the story runtime can handle weird scene premises
- appraiser and relationship training, so characters misread new phenomena in
socially grounded ways
- institution/faction/consumer training, so economic and political behavior can
respond to miracles as markets, threats, privileges, and dependencies
Keep source provenance strict. A branch can invent a local future, but it should
say what it invented and what branch conditions make it true. The machine may
generate futures under supervision; it does not get to erase the branch index
while everyone is looking at the shiny ghost.
## Operating Doctrine
Ghostlight will generate a lot of Aetheria dialogue, branching scenes, and
training data. That means it will also be the first tool to stress-test many
timeline details at room scale. Treat that as part of the job, not an
exception.
Before writing an Aetheria scene or generating data from one, do a source pass:
1. Check AetheriaLore for the factions, social movements, institutions, species
or body types, location, and time period present in the scene.
2. Extract the material constraints that shape behavior: habitat form,
infrastructure, law, money, route access, labor pressure, class position,
surveillance, body affordances, communication channels, and local failure
costs.
3. Distill those details into the lore grounding digest, agent-state fixture,
projected local context, or Ink sidecar before prompting a response model.
4. Keep the prompt character-local. Source grounding tells Ghostlight what the
room and culture make possible; it does not grant characters omniscience.
5. If the lore does not support a needed concrete detail, do not fill the gap
with confident furniture. Mark the gap, elaborate the AetheriaLore source,
then regenerate or revise the Ghostlight artifact from the patched source.
6. Commit the lore patch in AetheriaLore and cite the source path from
Ghostlight so the training artifact has a clean provenance trail.
The output goal is not only clean training data. It is also playable, readable,
fun interactive fiction that discovers where the worldbuilding is thin by
leaning on it. If Ghostlight needs to know what a sanctuary intake board looks
like to a cetacean Navigator, that is not trivia. That is worldbuilding under
load.
## Lore Gap Policy
When a gap appears, classify it before writing:
- `blocking`: generation would produce wrong bodies, wrong institutions, wrong
authority, or wrong material constraints without the missing detail.
- `safe_to_stub`: the scene can proceed with a clearly marked provisional
detail that does not affect canon-critical behavior.
- `defer`: the detail is decorative and should not enter the prompt yet.
Blocking gaps should be patched in AetheriaLore first. Safe stubs must be
tracked in the digest or sidecar and revisited before promotion. Deferred gaps
should stay out of prompt text so they do not harden by accident.
## Training Boundary
Ground the machine in the past, then generate possible futures under
supervision with branch labels sharp enough that infinity does not show up
wearing one nametag.
Historical fixtures teach single-history social pressure. Future-branch
fixtures teach conditional canon. Cat/Oz-style procedural fixtures teach
runtime mechanics. Those lanes can compare against each other, but they should
not collapse into one unlabeled corpus.

View file

@ -1,586 +0,0 @@
# Aetheria Persistent Agent Architecture
This is the companion document to
[Things To Steal](../research/things-to-steal.md).
For the first concrete target, see
[Aetheria Content Generation Targets](./aetheria-content-generation-targets.md).
For the scaling model that explains how a crowded region can be simulated with
a relatively small persistent cast, see
[Aetheria Agent Population Model](./aetheria-agent-population-model.md).
For the deeper note on how culture, personality distributions, memory, and
perceived state should project into prompts, see
[Agent State Distributions And Prompt Projection](./agent-state-distributions-and-prompt-projection.md).
That document is the theft list. This one is the machine we are trying to build
out of the stolen parts.
The long-horizon target is a persistent world full of in-universe agents who:
- remember what happened
- want different things
- collide with each other because their values and interests do not line up
- interact with humans without collapsing into quest dispensers or therapy plushies
The important bit is that the world should not exist solely to flatter the
player. Humans are participants, not the sun around which every synthetic
little bastard must orbit.
## Design Goals
The near-term target is content generation and story scaffolding, not a
standalone game slice. Ghostlight should first help Aetheria authors generate
dialogue, scene pressure, procedural drama, and character reactions from
explicit social state.
We want a world where:
- agents persist across sessions
- relationships evolve over time
- off-screen life continues
- conflict emerges from incentives
- the same character remains recognizably themselves under pressure
- human intervention can redirect events without erasing everyone else's agency
We do **not** want:
- a thin layer of LLM chat stapled onto static NPC behavior
- every character becoming a slightly differently dressed assistant
- a world that only animates when a human looks at it
- fake drama generated by random hostility instead of meaningful friction
## World Model
The world should be represented as a persistent simulation with five main state
layers:
1. **Canonical world state**
- locations
- factions
- resources
- institutions
- active conflicts
- world-level events
2. **Agent identity state**
- role
- background
- loyalties
- values
- aversions
- appetites
- speech style
3. **Agent dynamic state**
- current location
- current obligations
- current mood / pressure / fatigue
- current social exposure
- short-horizon plans
4. **Relationship state**
- trust
- respect
- resentment
- fascination
- fear
- obligation
- debt
5. **Memory state**
- episodic incidents
- scene summaries
- semantic profile conclusions
- relationship summaries
- active inferred goals and concerns
## Agent Stack
Each agent should be built from explicit layers instead of one giant persona
prompt.
### 1. Identity Layer
Relatively stable facts:
- who they are
- what they believe
- what they want to be seen as
- what they cannot tolerate
- what they are drawn toward
This should be treated as durable but revisable. Identity is not immutable, but
neither should it drift because the model got bored.
### 2. Relationship Layer
Every important other should have a relationship record, including:
- another agent
- a human player
- a group or faction
The record should track:
- trust
- respect
- resentment
- dependence
- attraction or fascination where relevant
- leverage
- recent unresolved incidents
The point is not to create one friendship slider like a life sim from 2004. The
point is to track the actual shape of a social bond.
### 3. Goal Layer
Agents need active goals, not just a biography.
Goals should be:
- private or public
- immediate or long-range
- strategic or emotional
- revisable in response to new information
A goal object should include:
- description
- priority
- time horizon
- emotional stake
- blockers
- dependencies
- known rivals
- likely next moves
### 4. Memory Layer
Memory should be split into:
- **working memory**
- current scene
- immediate context
- active inferences
- **episodic memory**
- timestamped incidents
- where, who, what, consequence
- **scene memory**
- distilled summaries of interactions
- **semantic memory**
- generalized beliefs about self, others, and world
- **relationship memory**
- remembered incidents and inferred patterns tied to a specific other
This is where the current interaction-memory work is directly useful.
### 5. Theory-of-Mind Layer
Each active interaction should have a lightweight scratchpad:
- what the other party likely wants
- what they fear
- what they think is happening
- what they are trying to get from me
- how confident we are about those guesses
This should be ephemeral and revisable. First impressions are useful, but
worshipping them is how systems become smug and stupid.
## Simulation Tiers
A persistent world cannot run every agent at full dialogue fidelity all the
time unless we want to immolate the hardware.
So the world should run on tiers.
### Tier 0: Dormant Persistence
For agents far away from any relevant action:
- maintain location, obligations, and long-horizon goals
- advance the clock coarsely
- update minimal state
### Tier 1: Strategic Tick
For agents in active regions but not in a live scene:
- update goals
- choose broad actions
- move, trade, coordinate, prepare, avoid, investigate
- write compact event records
### Tier 2: Scene Simulation
For agents inside a socially or narratively relevant scene:
- model who is present
- what each actor knows
- what each actor wants
- who is hiding what
- who is under pressure
- resolve short exchanges, negotiations, or conflicts
### Tier 3: Live Interactive Presence
For agents currently engaged with a human or central dramatic scene:
- full dialogue generation
- richer perception and recall
- fine-grained ToM updates
- explicit action selection tied to the live interaction
This tiered model is how we get a world that feels alive without paying for a
small national grid.
## Core Loop
At a high level, each active agent loop looks like:
1. Perceive
- scene state
- nearby actors
- new events
- human input
2. Interpret
- what matters
- what changed
- what this implies for goals and relationships
3. Update internal state
- working memory
- ToM scratchpad
- emotional pressure / stance
4. Retrieve memory
- relevant episodes
- relationship incidents
- semantic conclusions
5. Evaluate goals
- what is threatened
- what is newly possible
- what should take precedence now
6. Choose action
- speech
- movement
- trade
- alliance
- concealment
- refusal
- violence
- delay
7. Commit result
- world state update
- event log
- memory writes
8. Reflect later
- consolidate incidents
- revise relationship state
- revise semantic beliefs
- revise goals
## Relationship Engine
The relationship system should be treated as first-class infrastructure, not
decorative metadata.
Each relationship record should have:
- scores or categorical state for trust, respect, resentment, obligation, fear, fascination
- a short summary of the current relationship
- the unresolved incidents driving that summary
- known pressure points
- current access level
Example consequences:
- low trust + high obligation = brittle alliance
- low respect + high dependence = contemptuous reliance
- high fascination + high fear = unstable orbit
- high resentment + high access = sabotage risk
That is where drama comes from.
## Goal Engine
Goals should be generated and updated from:
- identity
- current needs
- memory
- faction context
- scene opportunities
- rival actions
We should distinguish between:
- **maintenance goals**
- survive
- maintain station
- avoid exposure
- **strategic goals**
- secure influence
- gain a resource
- undermine a rival
- **emotional goals**
- seek vindication
- restore dignity
- protect someone
- punish betrayal
Emotional goals matter because otherwise every character becomes a boring little
optimization engine with no pulse.
## Scene Engine
A scene should be an explicit object, not just "some chat happened."
A scene record should contain:
- location
- participants
- public stakes
- hidden stakes
- triggering event
- current tension axes
- known facts by actor
- unknown facts by actor
- scene outcome
Useful tension axes:
- status
- safety
- secrecy
- leverage
- affection
- ideology
- scarcity
The scene engine should decide:
- whether to escalate
- whether to de-escalate
- whether to fork into violence, bargain, confession, retreat, or deception
## Human Participation
Humans should be one force in the world, not the privileged audience every
character was born to adore.
That means:
- agents should remember human actions
- agents should form durable impressions of specific humans
- agents should change how much they reveal, trust, or tolerate
- agents should be willing to refuse or redirect
- agents should sometimes try to use the human for their own ends
Humans should be able to:
- build alliances
- ruin them
- become symbols
- become liabilities
- become prey
- become confidants
That is much more interesting than "click NPC, receive lore paragraph."
## Narrative Layer
We should not hard-script drama, but we should summarize it.
The narrative layer should:
- generate personal chronicles from actual events
- generate regional or faction news from high-impact changes
- generate rumor propagation when information is partial or distorted
The crucial rule:
- narrative is downstream of simulation
- not a replacement for simulation
## How The Current Supporting Stack Maps To This
The nice part is that some useful scaffolding already exists elsewhere.
Current useful pieces:
- Postgres-backed persistent state
- Qdrant-backed semantic retrieval
- interaction memory with behavioral dimensions
- source and lore indexing
- provider-side tool use
- owner-safe MCP patterns
What needs to be added:
- multi-agent world state store
- relationship graph
- explicit goal objects
- scene scheduler
- reflection/consolidation jobs for cast state
- tiered simulation runtime
- world-facing tools for observation and action
In other words, the existing conversational stack is already halfway to being a
mind-and-memory service. What it lacks is the world model and the cast
scheduler.
## Suggested Data Model
At minimum, the persistent store should grow tables or equivalents for:
- `world_regions`
- `world_events`
- `agents`
- `agent_identity_snapshots`
- `agent_dynamic_state`
- `agent_goals`
- `agent_relationships`
- `agent_memories_episodic`
- `agent_memories_scene`
- `agent_memories_semantic`
- `scene_instances`
- `scene_participants`
- `human_agent_interactions`
- `rumors`
Qdrant or equivalent can remain the retrieval layer for:
- episodic recall
- lore retrieval
- source grounding
- scene similarity
But the canonical social and world state should stay relational and
inspectable.
## Suggested Runtime Services
The world runtime should likely split into:
- **world scheduler**
- advances ticks
- activates scenes
- downgrades inactive agents
- **agent executor**
- runs an agent turn
- retrieves memory
- updates goals and relationships
- **scene resolver**
- handles active multi-party interactions
- **reflection worker**
- consolidates events into longer-term memory
- **narrative worker**
- writes chronicles and reports
That keeps the system legible instead of letting one monstrous worker do all
the thinking and all the plumbing at once.
## Failure Modes To Watch For
### 1. Everyone Becomes The Same Person
The agent voices flatten and all conflict becomes cosmetic.
Countermeasure:
- strong identity layer
- explicit value hierarchy
- personality consistency evaluation
### 2. Memory Becomes Sludge
Everything is stored, nothing is distinguished, retrieval turns to soup.
Countermeasure:
- tiered memory
- write-path filtering
- periodic consolidation
- clear forgetting policy
### 3. Drama Becomes Random Meanness
Characters feel chaotic rather than motivated.
Countermeasure:
- tie conflict to values, history, incentives, and pressure
### 4. Humans Break The World By Existing
Every character over-orients around the nearest player.
Countermeasure:
- keep off-screen goals live
- preserve non-human social gravity
- let humans matter without becoming the axis mundi
### 5. Sycophancy Wears A Costume
The bot sounds spicier but still validates whatever frame the user wants.
Countermeasure:
- explicit anti-sycophancy evaluation
- persistent relationship memory
- boundary-aware response guidance
## Build Order
### Phase 1: Authoring Cast Spine
- explicit agent identity dossiers
- explicit goal objects
- explicit relationship objects
- episodic + semantic memory split
### Phase 2: Dialogue And Drama Scaffolding
- prompt projection for scene and dialogue drafts
- relationship-aware dialogue skeletons
- conflict beat generation from incompatible goals
- author-visible rationale for generated moves
### Phase 3: Social Simulation
- scene objects
- scene scheduler
- relationship updates from scenes
- goal revision from scenes
### Phase 4: Persistent World
- region state
- off-screen strategic ticking
- rumor/news propagation
- faction and economy hooks
### Phase 5: Human Integration
- live player-facing interactions
- persistent human reputation
- access, trust, and consequence systems
### Phase 6: Rich Narrative Surface
- chronicles
- regional reports
- replay and spectator tooling
## The Real Ambition
The ambition here is not "better NPC dialogue."
It is:
- a world with inhabitants
- inhabitants with memory
- memory that changes what they want
- wants that collide
- collisions that produce story
That is the actual prize. Everything else is just wiring and smoke.

View file

@ -1,461 +0,0 @@
# Agent State Distributions And Prompt Projection
This note describes how personality, culture, memory, and social context should
be represented in structured state, and how that state should be projected into
prompts for dialogue generation.
The central rule is simple:
- the prompt is not the source of truth
- the prompt is a temporary projection of deeper state for one turn or one scene
If we try to store the whole person in prompt prose, the system turns back into
theatrical mush. The real machine has to live in explicit state.
For the related notes on personality structure and dialogue classification, see:
- [Personality Model Roadmap](../research/personality-model-roadmap.md)
- [Personality Signal Classifier](../research/personality-signal-classifier.md)
## Core Principle
We should model agents through:
1. canonical latent state
2. perceived state for each agent
3. prompt projection from perceived state into live generation
That gives us:
- stable world truth
- subjective misunderstanding
- efficient live prompting without pretending the prompt is the database
## State Families
Each agent should have explicit state families, not one giant personality blob.
### 1. Underlying Organization
These are deep structural variables:
- self-coherence
- contingent worth
- shame sensitivity
- reciprocity capacity
- mentalization quality
- authenticity tolerance
- mask rigidity
- external-regulation dependence
These are slow-changing and often heavily shaped by early development and
repeated relational history.
### 2. Stable Dispositions
These are relatively persistent tendencies:
- novelty-seeking
- conformity
- status hunger
- risk tolerance
- sociability
- baseline threat sensitivity
- aesthetic appetite
- ideological rigidity
### 3. Behavioral Dimensions
These are first-class, not optional garnish. They belong in the rebuild from
the start:
- interpersonal warmth
- drive
- grandiosity
- validation-seeking
- anxiety
- control pressure
- hostility
- suspicion
- rigidity
- withdrawal
- volatility
- attachment-seeking
- distance-seeking
These are the main handles for social behavior, institutional pressure, and
consumer or factional drift.
### 4. Presentation Strategy
These describe how the self is performed:
- charm
- compliance
- superiority
- detachment
- seductiveness
- competence theater
- moral theater
- strategic opacity
- cultivated harmlessness
- abrasive boundary
- ironic distance
These are not just decorative style. In some agents they are survival
machinery.
### 5. Voice Style
These describe how the performed self comes out in language:
- dryness
- verbal warmth
- formality
- verbosity
- pace
- plainspoken directness
- lexical precision
- technical density
- technical compression
- figurative language
- lyricism
- narrative detail
- emotional explicitness
- pointedness
- self disclosure
- hedging
- certainty marking
- politeness
- coded politeness
- ritualized address
- register switching
- dialect marking
- theatricality
- humor
- conversational dominance
- listening responsiveness
- question asking
- profanity
### 6. Derived Verbal Responses
Defensive verbal moves are derived during projection. The canonical state stores
the pressures and speech mechanics that make those moves likely.
Target emergent behaviors:
- sarcastic deflection can emerge from shame sensitivity, ironic distance,
exposure pressure, suspicion, and a dry or pointed voice
- sincerity evasion can emerge from mask rigidity, low authenticity tolerance,
strategic opacity, and low emotional explicitness
- emotional evasion can emerge from distance-seeking, withdrawal, acute shame,
or control pressure with low self-disclosure
- banter as boundary can emerge from humor, ironic distance, attachment
pressure, and distance-seeking
- verbal aggression can emerge from hostility, perceived status threat,
grievance activation, and abrasive boundary
These are behaviors Ghostlight should be able to produce and test for without
storing them as authored canonical traits.
### 7. Situational State
These are transient activations:
- exhaustion
- scarcity pressure
- humiliation
- panic
- triumph
- grief
- overstimulation
- grievance activation
- acute shame
- perceived status threat
### 8. Relationship State
Per target person, faction, group, product domain, or institution:
- trust
- resentment
- dependence
- fear
- fascination
- obligation
- envy
- moral disgust
- perceived status gap
- expectation of care
- expectation of betrayal
### 9. Goals And Values
- active goals
- protected values
- emotional stakes
- tradeoffs
- unforgivables
## How To Represent Variables
Do not represent most of these as naked scalars if we want culture and history
to shape them properly.
A practical representation is:
- `mean`
- `plasticity`
- `current_activation`
So instead of:
- `status_hunger = 0.72`
use something like:
- `status_hunger.mean = 0.72`
- `status_hunger.plasticity = 0.22`
- `status_hunger.current_activation = 0.89`
This gives us:
- stable baseline personality
- different rates of change
- scene-sensitive activation without rewriting the whole person every turn
Canonical authored state should not include epistemic uncertainty about itself.
If a value is only an observer's read, store it as perceived state rather than
canonical state. Perceived dimensions should not expose canonical `mean` or
`plasticity`; they should track observed activation, attributed disposition,
and confidence. This lets the model represent
fundamental attribution bias: someone can behave a certain way under pressure
and be perceived as simply being that way.
## Culture As A Prior Field
Culture should not create clones. Culture should shape distributions.
For each culture, faction, class stratum, or institution, define:
- prior means over variables
- covariance structure
- reward gradients
- penalty gradients
- preferred masks
- taboo map
- prestige map
- default relationship posture to insiders, outsiders, rivals, inferiors, and superiors
- favored narrative scripts
This means a culture is not "people here are high in trait X."
It is more like:
- this culture rewards controlled presentation
- penalizes spontaneous vulnerability
- elevates status anxiety
- normalizes suspicion of low-status outsiders
- attaches prestige to procedural fluency and emotional composure
That is how a Dominion or Lucent style environment should work: as a field that
shapes what people become profitable or safe enough to be.
## Personal History As A Transform Layer
Culture is only the starting pressure.
Then the prior is transformed by:
- attachment history
- class position
- faction indoctrination
- educational shaping
- labor regime
- remembered humiliations
- remembered betrayals
- remembered rescue, admiration, abandonment, and obligation
So agent state should be read as:
- cultural prior
- transformed by developmental history
- transformed by remembered episodes
- currently activated by scene pressure
## Cohorts And Populations
The same representation can be lifted from individuals to populations.
A colony, district, labor bloc, or consumer cohort can have:
- shared priors
- institutional conditioning
- repeated collective incidents
- relationship state toward factions, employers, media ecosystems, and product classes
This is much better than Big Five-style population sliders because it lets us
model things like:
- prestige hunger
- fear-state compliance
- grievance accumulation
- attachment to institutions
- validation loops induced by media
- suspicion induced by audit or betrayal
That is much closer to Aetheria than a broad trait scaffold alone.
## Canonical State Vs Perceived State
The world should not only track what is true. It should track what each agent
thinks is true.
So we need:
- canonical world state
- canonical agent state
- per-agent perceived state
Perceived state includes:
- what this agent believes about others
- what motives they attribute
- what traits they think they are seeing
- what mask they think someone is wearing
- what threats or opportunities they think are present
This is where emotional intelligence, suspicion, attachment hunger, status
anxiety, and cultural priors distort interpretation.
The acting agent should mostly be prompted from perceived state, not canonical
truth. That is how misunderstanding becomes organic instead of hand-authored.
## Prompt Projection
Prompt rendering should be treated as projection, not storage.
For a live scene, build the prompt from:
### 1. Identity And Role
- who the agent is
- what role they occupy
- what they want to be seen as
### 2. Cultural Default Frame
- prestige rules
- taboo zones
- expected forms of control or intimacy
- factional posture
### 3. Current Presentation Strategy
- how they are trying to appear right now
- what they are concealing
- what they must not look like
### 4. Current Situation
- scene pressure
- public exposure
- scarcity
- humiliation risk
- safety risk
### 5. Relationship Context
- what they think of the other party
- what they need from them
- what wounds or debts are active
- what status relation they perceive
### 6. Relevant Memory
Not full history. Only:
- the few most relevant episodes
- relationship summaries
- current active fears, loyalties, or grievances
- currently salient cultural scripts
### 7. Goals And Constraints
- what they are trying to achieve in this scene
- what they cannot afford
- what values they will not betray
### 8. Listener Model
If the scene involves another agent, include:
- what this agent thinks the other wants
- what they fear the other is doing
- where they may be misreading them
## What The Prompt Should Not Do
Do not make the prompt:
- the sole canonical store of identity
- a full dump of all memories
- a taxonomy recital
- a fake omniscient summary that erases subjective misreading
The prompt should be compact, selective, and scene-bound. The state store
carries the rest.
## Economic Consequences
This model gives us much more useful demand and cultural behavior than old
trait-only systems.
For example:
- high status hunger plus validation-seeking plus fragile self-worth leads toward prestige goods, symbolic upgrades, and visible luxury
- high threat sensitivity plus conformity plus shame reactivity leads toward safety goods, procedural systems, insurance, and respectable signaling
- high drive plus novelty-seeking plus low institutional attachment leads toward frontier gear, gray-market experimentation, and risky migration
- high mask rigidity plus high impression-management intensity leads toward corrective aesthetics, social camouflage, and reputation-maintenance consumption
That is how culture, labor, media, and institutions become market-shaping
forces instead of mere flavor text.
## Recommended Implementation Path
1. Define the state schema with first-class support for:
- underlying organization
- stable dispositions
- all core behavioral dimensions, including `volatility`, `attachment-seeking`, and `distance-seeking`
- presentation strategies
- voice styles
- situational state
- relationship state
- goals and values
2. Represent those variables as priors plus activation, not just fixed scalars.
3. Define cultural and institutional prior templates.
4. Add history transforms from remembered episodes.
5. Add per-agent perceived state overlays.
6. Build prompt renderers that project perceived state into live generation.
7. Keep canonical state, perceived state, and prompt projection cleanly separated.
## Bottom Line
The right machine is not:
- a persona prompt with some adjectives taped to it
- a canonical trait sheet with no cultural shaping
- a direct line from "personality value" to dialogue
The right machine is:
- latent state distributions
- shaped by culture
- reshaped by history
- distorted by perception
- projected into prompts for one scene at a time
That gives us agents who can feel culturally situated, personally scarred,
socially biased, and dynamically alive without making the prompt carry the
whole civilization on its back.

View file

@ -1,273 +0,0 @@
# Agent State Variable Glossary
This document explains the current v0 Ghostlight state variables. It is the
human-facing companion to:
- `schemas/agent-state.schema.json`
- `schemas/agent-state.required-fields.json`
- `examples/agent-state.call-of-the-void.json`
The schema is not pretending this vocabulary is final. It is a first stable
core: enough shared axes to compare characters, build prompt projection, and
avoid turning every new scene into a bespoke little fog machine.
## Variable Components
Canonical scalar-like state variables use three numeric components.
| Field | Meaning |
| --- | --- |
| `mean` | The baseline tendency or long-run level for this variable. A high `mean` means this trait, strategy, pressure, or stance is normally strong for the agent. |
| `plasticity` | How easily this variable can change. High plasticity means the value can move quickly under events, relationships, culture, or scene pressure. Low plasticity means it is entrenched. |
| `current_activation` | How active the variable is right now, in this scene or current state. This can spike above or fall below the baseline without rewriting the agent. |
Example:
```json
{
"mean": 0.35,
"plasticity": 0.2,
"current_activation": 0.9
}
```
This means the agent is not generally high in the variable, the baseline is not
very easy to change, but the variable is strongly activated right now.
Translation: this is a situation, not a personality transplant.
Canonical character state does not include `certainty`. If a generated or
imported profile is provisional, track that in workflow provenance, draft
status, imported-source notes, or review status.
Perceived state uses a different shape because observers do not know canonical
baseline or plasticity. They see behavior, then often treat that behavior as
evidence of what the target is like.
```json
{
"observed_activation": 0.9,
"attributed_disposition": 0.7,
"confidence": 0.4
}
```
| Field | Meaning |
| --- | --- |
| `observed_activation` | How strongly the observer thinks the target expressed the dimension in the observed event or scene. |
| `attributed_disposition` | How much the observer generalizes from the observed behavior into "this is what the target is like." |
| `confidence` | How strongly the observer trusts this read. This is where evidence strength, emotional investment, prejudice, relationship stakes, prior memory, and social reinforcement show up. |
This models fundamental attribution bias directly. A character can see someone
behave coldly during a crisis and conclude "they are cold," not "they were
activated toward distance under exceptional pressure." Correcting that
impression takes work because the observer's confidence is supported by memory,
emotion, stakes, prior assumptions, and social reinforcement.
Perceived overlays may use structured belief metadata such as subject, claim
type, evidence events, visibility, and emotional charge. That metadata is not
canonical certainty. It describes why a specific observer trusts a specific
read.
## Label Set Policy
The labels in `agent-state.required-fields.json` are the required v0 core,
not per-character tags. They cover the axes Ghostlight currently treats as
first-class.
The validator requires each canonical agent fixture to include the full core
set so agents can be compared on the same axes. That means Cat's example does
not contain only labels that apply strongly to Cat. It contains all required
labels, with low values for weak or inactive axes and high values for strong or
activated axes.
Variable maps may still grow extra labels where a character, culture, or story
needs them. Do not promote a new label into the required core until it has
proved broadly useful across multiple characters or scenes. Otherwise the
schema becomes a junk drawer with a badge.
## Underlying Organization
| Label | Meaning |
| --- | --- |
| `self_coherence` | How integrated and stable the agent's sense of self is under pressure. |
| `contingent_worth` | How much the agent's felt worth depends on approval, utility, status, purity, or external validation. |
| `shame_sensitivity` | How painful exposure, correction, failure, or diminishment feels. |
| `reciprocity_capacity` | Ability to recognize and sustain mutual obligations rather than purely extractive or avoidant relations. |
| `mentalization_quality` | Ability to model other minds with nuance instead of flattening them into crude threat, need, or utility categories. |
| `authenticity_tolerance` | Capacity to be known without immediately retreating into performance, control, or concealment. |
| `mask_rigidity` | How necessary and inflexible the performed self is. High values mean the mask is load-bearing. |
| `external_regulation_dependence` | How much emotional stability depends on other people, institutions, substances, scripts, or external structures. |
## Stable Dispositions
| Label | Meaning |
| --- | --- |
| `novelty_seeking` | Attraction to new experience, strange opportunities, and unproven paths. |
| `conformity` | Preference for accepted norms, procedures, and group expectations. |
| `status_hunger` | Desire for rank, recognition, prestige, or visible superiority. |
| `risk_tolerance` | Willingness to accept danger, uncertainty, or loss for gain or freedom. |
| `sociability` | Baseline draw toward company, conversation, networks, and social energy. |
| `baseline_threat_sensitivity` | Default readiness to detect danger, exploitation, betrayal, or humiliation. |
| `aesthetic_appetite` | Sensitivity to beauty, style, taste, symbolic form, or expressive environment. |
| `ideological_rigidity` | Resistance to revising beliefs, myths, doctrines, or explanatory frames. |
## Behavioral Dimensions
| Label | Meaning |
| --- | --- |
| `interpersonal_warmth` | Tendency to express care, friendliness, affection, or social welcome. |
| `drive` | Goal pressure, persistence, ambition, and forward force. |
| `grandiosity` | Inflated self-importance, entitlement, exceptionalism, or self-mythologizing. |
| `validation_seeking` | Need for reassurance, approval, recognition, or confirmation of worth. |
| `anxiety` | Anticipatory fear, worry, nervous vigilance, or instability under uncertainty. |
| `control_pressure` | Urge to manage people, situations, information, or outcomes tightly. |
| `hostility` | Readiness toward anger, contempt, attack, punishment, or antagonism. |
| `suspicion` | Expectation that others hide motives, threats, traps, or bad faith. |
| `rigidity` | Difficulty adapting behavior, interpretation, or plans under new evidence. |
| `withdrawal` | Tendency to disengage, retreat, shut down, or reduce availability. |
| `volatility` | Speed and intensity of emotional swings or escalation. |
| `attachment_seeking` | Pull toward closeness, reassurance, fusion, privileged access, or being chosen. |
| `distance_seeking` | Push toward space, opacity, autonomy, and reduced emotional claim from others. |
## Presentation Strategy
Presentation strategy describes the self being performed. This is not identical
to voice style. It is the social face, mask, posture, or survival presentation
the agent is trying to maintain.
| Label | Meaning |
| --- | --- |
| `charm` | Performs warmth, ease, charisma, or pleasantness to influence or smooth contact. |
| `compliance` | Performs agreement, submission, helpfulness, or nonthreatening cooperation. |
| `superiority` | Performs being above others, more competent, more refined, or less vulnerable. |
| `detachment` | Performs cool distance, emotional unavailability, or not-needing. |
| `seductiveness` | Performs desirability, invitation, or intimate leverage. |
| `competence_theater` | Performs capability and control, sometimes beyond what is actually secure. |
| `moral_theater` | Performs righteousness, purity, sacrifice, or ethical authority. |
| `strategic_opacity` | Controls what others can know; hides motives, pain, dependency, or plans. |
| `cultivated_harmlessness` | Performs being safe, small, useful, cute, or beneath concern. |
| `abrasive_boundary` | Performs difficulty, bite, or unpleasantness to keep others from pressing closer. |
| `ironic_distance` | Performs irony, detachment, or joking distance to avoid naked sincerity. |
## Voice Style
Voice style describes how the performed self comes out in language. The v0
required vector covers broad communication mechanics, not only the most obvious
quirks in the current examples.
| Label | Meaning |
| --- | --- |
| `dryness` | Understated, deadpan, low-sentiment phrasing. |
| `verbal_warmth` | Inviting, affectionate, reassuring, or socially soft phrasing. |
| `formality` | Uses formal structure, titles, careful address, or institutionally correct phrasing. |
| `verbosity` | Tends toward longer turns, elaboration, qualification, or verbal sprawl. |
| `pace` | Moves quickly through turns, interruptions, replies, or topic shifts. |
| `plainspoken_directness` | Speaks plainly, concretely, or bluntly rather than ornamenting or obscuring. |
| `lexical_precision` | Chooses exact words, distinctions, definitions, or careful terms. |
| `technical_density` | Packs speech with specialist terms, systems language, procedure, or analysis. |
| `technical_compression` | Compresses technical meaning into terse expert shorthand. |
| `figurative_language` | Uses metaphor, image, analogy, poetic compression, or symbolic framing. |
| `lyricism` | Uses musical, poetic, sensuous, or rhythmically heightened language. |
| `narrative_detail` | Explains through context, sequence, anecdote, or concrete scene detail. |
| `emotional_explicitness` | Names feelings, needs, wounds, affection, fear, or attachment directly. |
| `pointedness` | Cuts directly, sharply, or with barbed precision. |
| `self_disclosure` | Volunteers personal history, inner state, motives, or private stakes. |
| `hedging` | Softens claims with uncertainty markers, caveats, deference, or exits. |
| `certainty_marking` | Signals confidence, finality, authority, or refusal to leave claims open. |
| `politeness` | Uses courtesy, mitigation, face-saving language, or social smoothing. |
| `coded_politeness` | Uses polite phrasing to imply criticism, threat, refusal, hierarchy, or hidden meaning. |
| `ritualized_address` | Uses formulaic greetings, titles, honorifics, oaths, prayers, or ceremonial phrases. |
| `register_switching` | Shifts between speech registers depending on audience, status, danger, or role. |
| `dialect_marking` | Shows regional, class, occupational, subcultural, or community-specific speech markers. |
| `theatricality` | Performs speech with heightened drama, staging, persona, flourish, or rhetorical display. |
| `humor` | Uses jokes, wit, absurdity, teasing, or comic framing as a regular speech tool. |
| `conversational_dominance` | Takes, holds, redirects, or controls conversational floor and agenda. |
| `listening_responsiveness` | Reflects, tracks, validates, or adapts to what the other person just said. |
| `question_asking` | Uses questions to probe, invite, corner, teach, test, or keep the other person talking. |
| `profanity` | Uses taboo, vulgar, sacred, or deliberately coarse language. |
## Derived Verbal Responses
Defensive verbal moves are projection outcomes, not canonical labels. A renderer
can derive sarcasm, sincerity evasion, emotional evasion, banter-as-boundary, or
verbal aggression from the interaction between active state families.
Examples:
| Response | Common active-state pattern |
| --- | --- |
| Sarcastic deflection | High shame sensitivity, ironic distance, threat sensitivity, and exposure pressure through a dry or pointed voice. |
| Sincerity evasion | Low authenticity tolerance, high mask rigidity, strategic opacity, and low emotional explicitness. |
| Emotional evasion | High distance-seeking, withdrawal, acute shame, or control pressure with low self-disclosure. |
| Banter as boundary | High ironic distance, humor, attachment pressure, and distance-seeking in the same scene. |
| Verbal aggression | High hostility, perceived status threat, grievance activation, and abrasive boundary. |
Treat these as target emergent behaviors for projection tests. They are useful
because the system should produce them under the right pressures, not because
they deserve their own canonical storage slot.
## Situational State
| Label | Meaning |
| --- | --- |
| `exhaustion` | Current depletion, fatigue, overuse, or low reserve. |
| `scarcity_pressure` | Pressure from money, supplies, access, time, space, safety, or opportunity being scarce. |
| `humiliation` | Current felt diminishment, exposure, embarrassment, or status wound. |
| `panic` | Acute fear, urgency, overwhelm, or threat response. |
| `triumph` | Current victory, vindication, high confidence, or emotional lift. |
| `grief` | Active loss, mourning, sorrow, or ache. |
| `overstimulation` | Current sensory, social, cognitive, or emotional overload. |
| `grievance_activation` | Active resentment, injustice memory, or retaliatory moral charge. |
| `acute_shame` | Immediate shame flare, exposure pain, or self-disgust. |
| `perceived_status_threat` | Current sense that rank, dignity, competence, or face is under threat. |
## Relationship Stance
Relationship stance is directional. A `source_id -> target_id` record describes
how the source feels and predicts the target, not a shared relationship object.
Reciprocal records can disagree.
| Label | Meaning |
| --- | --- |
| `trust` | Expectation that the target will not exploit vulnerability or act in bad faith. |
| `respect` | Recognition of competence, integrity, status, or seriousness. |
| `resentment` | Accumulated grievance, bitterness, or unpaid emotional debt. |
| `dependence` | Need for the target's resources, care, access, protection, or recognition. |
| `fear` | Expectation that the target can or will cause harm. |
| `fascination` | Captivation, curiosity, attraction, fixation, or unresolved interest. |
| `obligation` | Felt debt, duty, promise, or responsibility toward the target. |
| `envy` | Pain around what the target has, is, receives, or represents. |
| `moral_disgust` | Aversion rooted in perceived violation, corruption, cruelty, or wrongness. |
| `perceived_status_gap` | Felt difference in rank, leverage, dignity, or social position. |
| `expectation_of_care` | Belief that the target may protect, soothe, support, or choose the source. |
| `expectation_of_betrayal` | Belief that the target may abandon, expose, exploit, or turn. |
## Cat Fixture Policy
Cat's example fixture currently includes all required core labels for Cat and
Oz. It is not a sparse profile of only traits that apply to them.
That is deliberate for v0:
- complete vectors make comparison and validation easier
- low `mean` values are meaningful
- low `current_activation` values tell the renderer not to emphasize a variable
Later, Ghostlight may support sparse authoring overlays for convenience. The
canonical fixture should remain explicit until we have enough tooling to avoid
silent defaults becoming accidental canon.
## Event Interpretation
Events may carry three optional interpretation layers:
| Field | Meaning |
| --- | --- |
| `observed_exchange` | What was externally observable: who acted or spoke, what they seemed to do, and the visible cues. |
| `private_interpretations` | How each participant appraised the event from their own partial state. |
| `event_effects` | The proposed consequences: state deltas, new beliefs, or memory updates caused by the event. |
These fields are immediate scaffolding for projection. They do not yet define a
complete transition engine. For now, they make important scene history legible
enough that a prompt projector can explain why a later line bends toward trust,
evasion, curiosity, fear, or repair.

View file

@ -1,244 +0,0 @@
# Canonical Agent State Schema
Ghostlight's first runtime organ is a state contract, not a generator.
The schema lives in:
- `schemas/agent-state.schema.json`
- `schemas/agent-state.required-fields.json`
The first example lives in:
- `examples/agent-state.call-of-the-void.json`
The label and numeric glossary lives in:
- `docs/architecture/agent-state-variable-glossary.md`
## Objective
The schema defines the minimum state Ghostlight needs before it can safely build
prompt projection, dialogue scaffolding, classifier records, or procedural
drama.
The core rule is unchanged:
- canonical state is what the world treats as true
- perceived state is what a specific observer thinks is true
- dialogue context packs are character-local projections for one speaker in one
scene
If these layers collapse into each other, the machine turns back into one
omniscient narrator with a pocket full of vibes. This is a known industrial
hazard. We are wearing goggles now.
## State Families
Each agent has:
- `identity`
- `canonical_state`
- `goals`
- `memories`
- `perceived_state_overlays`
`canonical_state` is split into:
- `underlying_organization`
- `stable_dispositions`
- `behavioral_dimensions`
- `presentation_strategy`
- `voice_style`
- `situational_state`
- `values`
Each canonical scalar-like dimension is represented as:
```json
{
"mean": 0.5,
"plasticity": 0.5,
"current_activation": 0.5
}
```
This keeps baseline, change rate, and scene activation separate.
A person can be dispositionally suspicious without being maximally suspicious
in every scene. A person can be highly activated right now without rewriting
their whole soul because someone coughed near a status wound.
Canonical variables do not include confidence or certainty. Perceived variables
do not expose canonical `mean` or `plasticity`; they store observed activation,
attributed disposition, and confidence.
For full meanings of canonical `mean`, `plasticity`, `current_activation`,
perceived attribution fields, and every required v0 label, see
`docs/architecture/agent-state-variable-glossary.md`.
## Required First-Class Dimensions
`schemas/agent-state.required-fields.json` names the required core variable
families. The validator enforces those fields for canonical agent state and
relationship stance.
The required core is not exhaustive. It is the shared comparison surface. A
character fixture currently includes all required labels, even when many are low
or inactive, so downstream tooling can compare agents without guessing whether a
missing value means "not applicable," "forgotten," or "quietly eating the
floorboards."
The behavioral dimensions include the known must-not-drop set:
- `volatility`
- `attachment_seeking`
- `distance_seeking`
Those are not garnish. They are load-bearing social handles.
## Presentation, Voice, And Response
`presentation_strategy` describes the self being performed.
That can include charm, detachment, competence theater, cultivated
harmlessness, or deliberately performing someone who is abrasive or difficult.
Sometimes that abrasive performance is not random meanness. It is a boundary,
a threat display, a way to prevent intimacy, or a test to see who flinches.
Human beings are regrettably efficient little porcupines.
`voice_style` describes how that performed self comes out in language.
The required voice handles are:
- `dryness`
- `verbal_warmth`
- `formality`
- `verbosity`
- `pace`
- `plainspoken_directness`
- `lexical_precision`
- `technical_density`
- `technical_compression`
- `figurative_language`
- `lyricism`
- `narrative_detail`
- `emotional_explicitness`
- `pointedness`
- `self_disclosure`
- `hedging`
- `certainty_marking`
- `politeness`
- `coded_politeness`
- `ritualized_address`
- `register_switching`
- `dialect_marking`
- `theatricality`
- `humor`
- `conversational_dominance`
- `listening_responsiveness`
- `question_asking`
- `profanity`
Behavioral warmth and verbal warmth are intentionally separate:
- `behavioral_dimensions.interpersonal_warmth` is the tendency to offer care,
welcome, or affiliation.
- `voice_style.verbal_warmth` is the language surface that can sound soft,
reassuring, affectionate, or socially inviting.
So Cat's prickliness is not only a dialogue prompt instruction. It is backed by
canonical state:
- high `presentation_strategy.abrasive_boundary`
- high `presentation_strategy.ironic_distance`
- high `underlying_organization.shame_sensitivity`
- high `underlying_organization.mask_rigidity`
- high `behavioral_dimensions.distance_seeking`
- high `behavioral_dimensions.suspicion`
- high `situational_state.acute_shame` when exposed
The renderer derives defensive speech from those intersections plus voice
style. A dry, pointed voice under shame and exposure pressure may produce
sarcastic deflection; the schema does not store sarcasm-as-defense as its own
canonical organ.
Sarcastic deflection, sincerity evasion, emotional evasion,
banter-as-boundary, and verbal aggression remain target emergent behaviors for
projection tests.
## Perceived State
Perceived overlays are observer-specific. They do not replace canonical state.
An overlay records:
- observer
- target
- perceived dimensions, including observed activation and attributed disposition
- beliefs
- distortions
This is where misunderstanding lives. Suspicious agents can overread threat.
Attachment-hungry agents can overread interpersonal warmth. Avoidant agents can
underread care. Fundamental attribution bias also lives here: an observer can
mistake exceptional behavior for stable character. None of that requires
corrupting the canonical state.
Relationship stance is directional. If Cat has a relationship record toward Oz,
Oz needs a separate record toward Cat. Mutual presence does not imply symmetric
trust, fear, obligation, dependence, or expectation of betrayal.
## Event Interpretation
Events can include optional interpretation scaffolding:
- `observed_exchange` for externally visible action or dialogue
- `private_interpretations` for each participant's appraisal of what happened
- `event_effects` for proposed state deltas, belief creation, and memory updates
This is not a full social transition engine yet. It is enough structure for the
projection layer to know what an event changed without asking later prompts to
infer everything from a one-line summary and a bad attitude.
## Dialogue Context Packs
Scenes contain `dialogue_context_packs`.
A pack is a character-local prompt payload for one speaker. It includes:
- speaker-local truth
- speaker beliefs
- active memories
- active goals
- presentation constraints
The point is not to make the dialogue generator smarter by shouting plot at it.
The point is to keep each character inside their own partial reality.
Packs are positive-context payloads: if the speaker should not know something,
it is not included. Secrets the speaker does know but wants to withhold belong
in goals, memories, beliefs, and presentation constraints.
Ghostlight can build the trap. The character projection decides how the person
inside the trap breathes.
## Current Validation
The first validator is intentionally small and dependency-free:
- `tools/validate_agent_state.py`
It does not implement all of JSON Schema. It checks the invariants Ghostlight
currently cares about:
- example files parse as JSON
- schema versions match
- agent IDs are unique
- required canonical variable groups are present
- required relationship stance fields are present
- state variable components stay in `0..1`
- goals, memories, relationships, scenes, and dialogue context packs reference
existing IDs
- dialogue packs carry speaker-local context without requiring negative
author-only fences
This gives us a real seam before we build the prompt renderer. Not perfect.
Useful. We are allowed to be both.

View file

@ -1,81 +0,0 @@
# Corpus Coverage Ledger
The corpus coverage ledger is the project-level index for accepted and planned
Aetheria training fixtures.
Its job is not to store story content. Its job is to keep the corpus honest:
which factions, movements, flashpoints, timeline lanes, tonal modes, collision
axes, review states, and item/economy hooks are represented, and which are still
missing.
## Live Files
- Ledger: `state/corpus-coverage.json`
- Schema reference: `schemas/corpus-coverage.schema.json`
- Validator/status tool: `tools/validate_corpus_coverage.py`
- Command: `npm run coverage:status`
## Entry Contract
Each fixture entry records:
- `fixture_id`: stable fixture handle.
- `status`: `planned`, `draft`, `accepted_as_draft`, `accepted`,
`needs_revision`, `rejected`, or `superseded`.
- `timeline_lane`: `historical_grounded`, `transition_grounded`, or
`future_branch`.
- `canon_status`: single-history, branch-local, promoted branch, template, or
draft status.
- `story_type`: founding-era, day-in-the-life, flashpoint, movement,
future-branch, or trade-life story.
- `primary_subjects` and `secondary_subjects`: factions, movements,
institutions, species/body groups, or other represented subjects.
- `collision_axes`: the pressures the fixture exercises, such as law, labor,
care, class, logistics, species/body affordance, trade, memory, personhood,
technology, legitimacy, or infrastructure.
- `tonal_modes`: prose modes the fixture contributes to the corpus.
- `training_targets`: organs or reference surfaces this fixture is relevant to.
- `training_usability`: explicit per-stage declaration of whether the fixture
is `training_ready`, `reference_only`, or `not_training_data`.
- `artifacts`: paths to fixture files.
- `review`: status of schema, narrative, lore, spatial, visual, and IF review.
- `economy_hooks`: item, assembly, trade-good, supply-chain, or tech hooks.
- `open_lore_gaps`: blocking or useful gaps discovered during generation.
## Acceptance Rule
An accepted or accepted-as-draft fixture must have the core artifact refs:
- lore digest
- agent state
- coordinator artifact
- Ink file
- training sidecar
It must also carry review fields for schema, narrative, lore, spatial, visual,
and IF artifact review. A fixture may still be accepted-as-draft with review
caveats, but the caveats should be visible instead of hidden under the nice rug.
Coverage status is not training readiness. A fixture that looks good as a story
can still be unusable for a model stage if it lacks exact input/output receipts,
raw outputs, reviewer decisions, hidden-context audits, or mutation records in
the shape that stage requires. The ledger should be rude about this. If a
fixture is only a reference for story shape or grounding, mark it
`reference_only`; do not promote it by implication.
## Report Shape
`npm run coverage:status` validates the ledger and prints:
- total entries and accepted count
- progress toward the 100-150 broad target
- status, timeline lane, story type, collision-axis, tonal-mode, and subject
counts
- training-usability counts
- major-power founding/day-in-life candidates when present
- basic gap signals, such as missing future-branch coverage or no day-in-life
stories yet
This is deliberately a coarse instrument. It should tell us where to point the
next fixture without pretending to be a corpus scientist in a lab coat made of
spreadsheet tabs.

View file

@ -1,102 +0,0 @@
# CultNet Shared Persona-State Contract
Ghostlight no longer treats `ghostlight.agent_state.v0` as the cross-runtime
person-state contract. That schema still belongs here, but it owns scene,
world, relationship, event, and dialogue-context state for storytelling.
The portable person-shaped contract is now `gamecult.persona_state.v0`.
## Canonical Payload
The shared payload contract is:
- schema id:
`https://gamecult.dev/cultnet/gamecult.persona_state.v0.schema.json`
- schema version:
`gamecult.persona_state.v0`
- Ghostlight mirror:
`schemas/gamecult.persona_state.v0.schema.json`
The same schema is mirrored by Epiphany and VoidBot so Faces, repo
representatives, and Ghostlight characters can cross runtime boundaries without
each repo inventing its own local person-shaped blob.
## Ownership
Ghostlight keeps owning the story machine:
- world state
- scenes
- events
- relationships
- perceived overlays
- dialogue context packs
PersonaState owns the portable public-person projection:
- presentation
- values
- activation profile
- thought memory
- agency pressure
- candidate actions
- affect
- source provenance
That projection can be generated from Ghostlight scene agents, VoidBot repo Face
state, or Epiphany Face state. The projection does not erase native state. It is
the wire shape, not a throne.
## Wire Envelope
CultNet should carry PersonaState as a typed document replication message.
Current expected document envelope:
- `documentType`: `gamecult.persona-state`
- `documentKey`: stable runtime-defined persona key
- `payloadSchemaVersion`: `gamecult.persona_state.v0`
- `payload`: exact PersonaState document
Native Ghostlight scene payloads may still travel separately with:
- `documentType`: `ghostlight.agent-state`
- `payloadSchemaVersion`: `ghostlight.agent_state.v0`
Those are different contracts. One carries a story world. One carries a
portable Persona.
## Epiphany Boundary
Epiphany public Faces use PersonaState when they need full persistent
personhood. Epiphany work organs do not. A work organ such as Imagination can
have a light organ state without affect, social bonds, public presentation, or
candidate actions.
This prevents the old muddle where every useful subsystem was tempted to dress
up as a whole person. Useful machines do not all need faces.
## Contract Discipline
The schema lives in multiple repos for local validation, but the contract must
move as one:
- Epiphany keeps the primary CultNet schema copy.
- VoidBot mirrors it and emits PersonaState from repo Face reads.
- Ghostlight mirrors it and validates PersonaState examples.
- Native storage may stay richer than PersonaState.
- Importers must preserve unknown extension data when possible.
- Consumers must treat `provenance.authority` seriously.
Prompt projection may differ. Storage engines may differ. Story scenes may
carry more structure. The shared person-state payload does not drift just
because a runtime got clever at midnight.
## Immediate Consumers
The first intended consumers are:
- Ghostlight character projections for scene/story tooling
- VoidBot repo Face MCP reads and social memory interop
- Epiphany public Face state
- future CultNet replication and inspection tools

View file

@ -0,0 +1,102 @@
# DELVE/HOLD Consumer Profile for the Ghostlight World API
## Status
This document is a consumer binding and conformance profile. It records how
Delvehold maps its authored world and owned state onto Ghostlight's generic API;
it does not create a Delvehold organ, schema family, subject ID, or authority
branch inside Ghostlight core.
## Objective
Use Ghostlight's live external-world simulation as an ordinary API consumer
without making it the owner of DELVE/HOLD players, workshops, dungeons, civic
state, or quantitative economy. DELVE/HOLD provides an authored ontology and
fixed seed for one canonical outside world through consumer-neutral Ghostlight
operations. A consumer seed does not pass through the seed lane; it enters as
a consumer document, and this bypasses none of `WorldKernel`, typed
validation, the mutation algebra, or atomic CultCache commits.
## Core and consumer cut
Ghostlight admits generic world seeds, externally owned subject projections,
consumer-supplied domain effect schemas, proposals, and receipts. Delvehold's
adapter owns the names `delvehold.greathold_boundary_state.v0`,
`delvehold.greathold_effect_batch.v0`, `ghostlight.external_response_batch.v0`,
`delvehold.boundary_receipt.v0`, and the configured subject ID `greathold`. It
lowers and raises those documents at the API boundary. Ghostlight never
switches on those names or that ID.
## Authority map
- **Delvehold owner:** Greathold players, workshops, parties, civic seals, graph, dungeon ecology, and its own quantitative economy: recipes, facilities, capacity, inventories, orders, prices, exchange rates, contracts, expeditions, and local consequences. Custody of Greathold-owned resources changes only through Delvehold.
- **Ghostlight owner:** external regions, actors, institutions, Gestalts, their knowledge, relationships, goals, posture, pressures, strategic choices, external events, and news.
- **Configured boundary subject:** Delvehold configures stable ID `greathold`
through Ghostlight's generic external-subject authority contract. The ID has
no privileged meaning in Ghostlight core.
- **Ghostlight API:** consumer-neutral CultMesh operations admit authored seeds,
externally controlled subject observations and realized effects, and return
attributed intents, news, state projections, and receipts. They own no
consumer ontology or decision.
- **Delvehold adapter:** Delvehold owns translation between its detailed
Greathold state and the generic Ghostlight API. The adapter owns no truth and
cannot repair either canonical domain after rejection.
- **Forbidden writers:** Ghostlight Personas and strategic cells cannot mutate `greathold`; Delvehold cannot mutate foreign private state; the adapter cannot repair either owner after rejection.
The economy owner is Njörðr, the GameCult economy daemon (`gamecult-economy-daemon.md`), for both games and for the Greathold's interior as well as its exterior; Njörðr is itself a Ghostlight consumer and owns the market mirrors. The Delvehold-owner line below predates that decision (2026-09-11) where it names recipes, facilities, capacity, inventories, orders, prices, exchange rates, and contracts; those are Njörðr's, and Delvehold's civic organs author policy over them. Ghostlight owns a narrative-scale conserved quantity for the subjects it simulates: `Custody` holds a unitless `Quantity` per subject and resource, `Transfer`/`Consume`/`Transform` conserve it, and `Admit` creates it only with evidence (`docs/architecture/ghostlight-world-ontology.md`, Invariant 5). That is a ledger, not an economy. `Transform` is one-to-one with no unit, rate, yield, facility, or capacity field anywhere on a Ghostlight type, so prices, production, orders, and contracts are unrepresentable in Ghostlight rather than merely unwritten; they remain Delvehold-owned, and any resource an external mirror holds changes only through its admitted owner.
## Greathold boundary subject
`greathold` is externally controlled. It is not a person, sovereign government, averaged population will, or alternate player-state store.
- It receives no Persona turn or autonomous institution effect.
- Only generic API operations lowered by the Delvehold adapter from an accepted
`delvehold.greathold_boundary_state.v0` or
`delvehold.greathold_effect_batch.v0` document may update its projection.
- External subjects may observe it, relate to it, and direct attributed actions toward it.
- Any proposed change to Greathold-owned truth exits as `ghostlight.external_response_batch.v0` and remains pending until Delvehold returns `delvehold.boundary_receipt.v0`.
The projection excludes player identities, private workshop state, pending votes, unaccepted contracts, private communications, and inferred collective desire. It reports committed macro effects and authorized institutional acts.
## Consumer flow
```text
Delvehold commit
-> Delvehold-owned boundary projection or realized-effect batch
-> Delvehold adapter lowers to a generic Ghostlight API operation
-> WorldKernel validation and atomic commit
-> strategic resolution over external subjects
-> generic attributed intent/news/state response
-> Delvehold adapter raises to its consumer contract
-> Delvehold validation and atomic commit
-> admission receipt
```
Each document carries world ID, UTC epoch ID, source revision, effective time, causal refs, provenance, payload digest, and idempotency key. `delvehold.boundary_receipt.v0` is direction-neutral: the receiving domain returns it to the sender with sender and recipient IDs, per-item results, reason codes, resulting local revision, and committed event refs. Revisions remain local. The source may retry; the recipient must return the same receipt for the same key and digest.
Malformed envelopes, stale revisions, digest conflicts, and invalid causal structure reject the whole document without mutation. After envelope validation, semantically independent items may resolve to an explicit accepted subset, but that subset commits atomically or not at all. The receipt records every item result.
`greathold_boundary_state.v0` is an absolute replacement of its declared projection fields at a Delvehold source-revision watermark. `greathold_effect_batch.v0` carries deltas over explicit preceding and resulting revisions. A snapshot supersedes deltas at or below its watermark; delayed deltas below either accepted watermark are stale. Greathold ingress access and capacity are Delvehold-owned. External route topology and status remain Ghostlight-owned even when Delvehold mirrors their IDs in local admitted state.
Major committed events cross promptly. Ordinary material flow closes hourly. Recovery replays at most eight missed epochs and represents older time as one explicitly coarse horizon. A summarized horizon may reduce detail; it cannot invent player action or hide an unprocessed interval.
## Required generic Ghostlight API capabilities
Ghostlight needs:
1. one seed admission primitive, `AdmitPatch`, shared by the world owner, the
Draft seed lane acting as the owner, the Active elaborator, and the
external consumer;
2. generic external-subject ownership metadata enforced by selection and
mutation gates;
3. generic consumer effect-schema registration or supply;
4. generic proposal and idempotent receipt envelopes.
All four delegate to existing WorldKernel validation and atomic persistence.
The adapter lives in Delvehold and owns all Delvehold-specific projection,
economy translation, document names, and configured IDs. No Delvehold-specific
economy, civic, dungeon, or contract schema belongs in Ghostlight core. No
second kernel, economy store, Session Zero compatibility fiction, or direct
campaign-row writer is admitted.
Acceptance requires negative proof that ordinary strategic waves cannot select `greathold` as an acting subject, malformed or stale batches commit nothing, and foreign effects become local consequences only after a Delvehold receipt.

View file

@ -0,0 +1,12 @@
# Njörðr, the GameCult economy daemon
The specification moved to its owning repository on 2026-09-11:
`GameCult/Njordr`, file `docs/architecture/njordr.md`
(https://github.com/GameCult/Njordr). This file is a pointer so Ghostlight's
map and integration notes keep resolving; it carries no authority.
What Ghostlight owes Njörðr is unchanged and lives here: the outbound consumer
batch shaped as Njörðr's signal surface (custody, pressure and dependency,
route, and event deltas), market mirrors as externally controlled subjects,
several per world, and consumer patches carrying prices as witnessed facts,
shortages as pressures, and realized trades as custody with a receipt.

View file

@ -0,0 +1,671 @@
# Ghostlight authority architecture
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
This document is the adopted rebuild target for the Ghostlight runtime. It is
the authority map for implementation work. What it describes is built in
`crates/ghostlight` except where it says otherwise here: bounded co-op and the
`archived` phase with read/export-only archival are design intent, and nothing
in `crates/` implements them (`WorldPhase` has `Draft` and `Active` only).
Ghostlight is a persistent, source-grounded narrative world. Its job is to
preserve people, institutions, places, knowledge, material consequence, and
autonomous motion across time. It is not a pipeline for passing prose among a
committee of models until one model permits another model's text to exist.
## Objective
Build one sparse, open-world causal machine that supports solo play (bounded
co-op is design intent, unbuilt), autonomous subject decisions, source-grounded expansion, external
world owners, and actor-private experience.
The world grows when committed pressure reaches a typed boundary: a new
destination becomes relevant, an existing polity or population enters causal
range, a person must be individuated, or an effect needs a missing primitive.
A world also grows toward an authored `WorldScaleIntent`: how many goal-bearing
subjects it should hold at each level and realm. Elaborators work that deficit
down; structural validity never waits on it. Cover budget is the deliberate
choke: with subjects several times the cell budget, the scheduler must choose
where attention goes, and actors pursuing their own goals at every level is
what makes the world feel alive. Neither number gates activation or admits a
mutation.
## Prime invariants
1. One per-world `WorldKernel` owns the complete revisioned `WorldState`, from
draft through active play and archive.
2. Every accepted change crosses one `CommandEnvelope`, one deterministic
authority derivation, one ontology reducer, and one atomic CultCache CAS.
3. Structural identity, reference integrity, containment, topology, custody,
jurisdiction, knowledge scope, lineage, and external ownership are typed
code invariants.
4. Every autonomous authority scope names one decision controller. A
`NarrativePersona` uses the Projector-Persona-Interpreter prose membrane; an
`OperationalAgent` receives typed state and tools directly. Neither can
validate, reconcile, schedule, repair, or commit another model's proposal.
5. Projection, attention plans, transcripts, news, summaries, dashboards,
checkpoints, and model receipts do not own world truth.
6. Qualitative interest, political diversity, prose quality, name repetition,
and actor counts are evaluation evidence. They are never mutation gates.
7. A failed or interrupted inference (infrastructure interruption of an
inference; a world-scope interruption between intent and commit is
narrated, not discarded — the narrative lane re-lowers the same prose once
against the fresh scope) leaves no half-owned world state. Recovery starts
from the last committed revision and derives pending work again.
## Canonical owner
### Owner
One mailbox-backed `WorldKernel` owns a world. The mailbox serializes commands;
the CultCache compare-and-swap protects the same invariant across restart or
multiple process attempts.
`WorldKernel` owns:
- lifecycle phase and revision (`WorldState.phase`, `revision`);
- the owner and the draft approvals (`owner`, `draft_approvals`); membership,
a contract, and negotiated boundaries are design intent and no field of
`WorldState` holds them;
- the typed world ontology (the partitions listed under "Canonical world
state");
- committed events and fictional time (`events`, `now`);
- commitments and pressures;
- command idempotency and the commit digest chain.
There is no separate Session Zero owner, component-world owner, aggregate
campaign owner, elaboration committer, scheduler writer, or recovery writer.
### Inputs
The only mutation input is a `CommandEnvelope` containing:
- a stable command ID (`id`);
- the exact world ID and expected revision (`world_id`, `expected_revision`);
- an authenticated principal or internal system capability (`caller`);
- one closed command body (`body: CommandBody`).
`CommandEnvelope` (`crates/ghostlight/src/lib.rs`) has exactly these five
fields. Evidence references travel inside `AdmitPatch`'s `WorldPatch`, not on
the envelope.
The caller supplies identity evidence, not an authority verdict. For a patch
command the kernel derives the caller's authority itself:
`require_patch_author` reads the caller and committed state to decide who may
submit `AdmitPatch` and what ground it holds (the world owner unconfined, an
elaborator confined to a jurisdiction, or a consumer confined to the subjects
it controls), and `confine_to_ground` checks every declaration and operation
in the candidate patch against that ground before it is admitted. Callers
cannot grant themselves scope by serializing a persuasive envelope; the
kernel derives it from state it already committed.
Interpreters and operational agents submit the same typed commands through
narrower schemas. Narrative Personas never see those schemas or tools. No
controller receives a privileged mutation path.
### Outputs
An accepted command produces one `WorldCommit` (`crates/ghostlight/src/lib.rs`)
whose fields are:
- `schema` and `world_id`;
- `command` (the committed command);
- `previous_revision` and `resulting_revision`;
- `previous_state_digest` and `resulting_state_digest`;
- `previous_commit_digest` and its own `digest`;
- `effect` (a `WorldEffect`: the admitted patch, decision, or time step, with
its committed events);
- `committed_at`.
The next `WorldState` and `WorldCommit` are persisted atomically. A rejection
returns typed invariant failures and changes nothing.
### Derived state
The following are recalculated from committed state and may be discarded:
- subject-local projections;
- due-owner queues and attention plans;
- population covers, cells, cell ids, resolution, tick index, the agency
graph, and the cover budget (the only durable trace of a tick is the
controller-work row, custody-separate from world custody);
- action-owner counts and causal-capacity summaries;
- transcript, news, narration, and operator summaries;
- Eve/CultUI and CultMesh documents;
- model prompts, outputs, receipts, latency, and provider telemetry;
- qualitative evaluations and load-test reports.
Derived data may guide a later proposal. It may not admit a mutation, repair a
commit, or declare the world complete.
### Forbidden writers
The following cannot decide canonical state:
- HTTP, native, Eve, CultMesh, or chat handlers;
- Projectors, Personas, Interpreters, assessors, verifiers, reconcilers,
narrators, and copy desks; the elaborator decides nothing directly and
writes only through its confined `AdmitPatch` capability;
- schedulers, attention planners, initiative selectors, and resolution covers;
- acceptance drivers, smoke binaries, checkpoints, resume journals, caches,
and compaction summaries;
- Vault retrieval, evidence reranking, external consumers, and provider
telemetry;
- import, reload, fork, and repair helpers.
Each is either a read-only projection or a producer of one ordinary typed
command.
## Canonical world state
`WorldState` is one aggregate with these owned partitions:
```text
WorldState (crates/ghostlight/src/lib.rs, the fields of the struct)
identity: schema, world_id, revision, phase, state_digest, last_commit_digest
authorship: owner, title, brief, draft_approvals
ontology:
subjects, entities (place, resource, fact, channel), edges (routes)
positions, holdings, dependencies
facts, channels, knowledge
authority, selection, redress
commitments, pressures, last_opportunity_at
persona_material
controller_assignments, affordance_catalog, affordance_grants
time: now
intent: scale_intent, lens_weights
events: events (decision events)
```
There is no `members`, `contract`, or boundaries field and no
`applied_commands` ledger in `WorldState`. Command idempotency is decided from
the commit rows keyed by command id. `WorldCommit` rows, not `WorldState`,
carry the digest chain.
The lifecycle is data inside the aggregate:
`WorldPhase` has two variants, `Draft` and `Active`:
- `Draft`: `ApproveDraft`, `ActivateWorld`, `AdmitPatch` (seed and ontology
construction under draft authority, which answers nothing), and
`SetLensWeights`;
- `Active`: `ExerciseDecision`, `DeclineDecision`, `AdvanceTime`, `AdmitPatch`
that answers a derived boundary or a jurisdiction deficit, and
`SetLensWeights`.
An `archived` phase with read/export-only archival is design intent; no variant
exists.
Activation changes `phase` in place after the current revision has the required
member approvals. It does not copy a preview into another store or hand truth
from one kernel to another.
## Ontology and relational algebra
The component mutation algebra is the foundation. The aggregate `Campaign`
shape is not a second canonical representation.
### Runtime-issued identity
Stable IDs are issued by the reducer. Creation proposals use local draft
handles plus exact existing IDs. Lowering resolves the complete local reference
graph before allocating canonical IDs. Display names never function as keys.
Unknown IDs, cross-kind references, dangling endpoints, and ambiguous handles
are rejected without inference.
### Subjects
A subject is a stable identity with a declared kind:
- person;
- institution;
- autonomous population.
An externally controlled mirror is not a fourth kind. It is an ordinary
subject of one of these three whose `ControllerAssignment` is
`ExternallyControlled { consumer }`: no controller ID, no controller mode, no
decision opportunity, and no affordance, mutable only through its own
consumer and the world owner.
Demographic descriptors and statistical aggregates are not automatically
subjects. A canonical action owner is derived structurally from an active
autonomous subject, one non-overlapping decision controller, explicit decision
authority, and at least one executable typed affordance. Similar names, motives,
or cultures are legal; labels alone cannot create causal capacity.
Population partitions use disjoint typed slice keys. Individuation is an
explicit causal mutation. There is no fission quota and no active-leaf
completion metric.
### Components
Subject and world behavior is composed from narrow typed components:
- position and jurisdiction;
- decision controllers, authority scopes, and executable affordances;
- resource quantity, custody, and dependency;
- knowledge of exact fact IDs and communication access;
- commitments, pressures, and dependency exposure;
- Persona material: values, voice, goals, memories, and relationship reads;
- source provenance and external ownership.
Persona text can enrich lived meaning. It cannot substitute for an authority,
custody, topology, or knowledge component.
### Relations
`EdgeKind` (`crates/ghostlight/src/patch.rs`) has one variant, `Route`, so the
kernel has no general relation record yet. What the kernel carries, each in
its own `WorldState` partition with typed endpoints:
- containment (`EntityRecord.container`) and occupancy (`positions`);
- topology and access (`Route` edges with `AccessKind`);
- custody (`holdings`) and dependency (`dependencies`);
- authority and representation (`authority`, `selection`, `redress`);
- knowledge and communication (`knowledge`, `channels`);
- obligation (`commitments`) and causal pressure (`pressures`);
- control (`controller_assignments`).
Membership, lineage, supply and service, and alliance or opposition as
relations are design intent and have no record shape.
Civic order is a typed subgraph of authority, selection or succession,
resource access, representation, and redress relations. It is not a prose
manifest with fact-ID lanes and does not require a model's civic verdict.
### Structural admission
The reducer validates only structural promises:
- every referenced entity exists or is created in the same closed batch;
- containment is acyclic;
- routes have exact place endpoints and valid costs;
- relation endpoint kinds match the relation kind;
- custody quantities and transfers conserve declared resources;
- authority and affordance scope covers the proposed mutation;
- knowledge additions cite an existing accessible fact or a newly admitted
evidenced fact;
- population slices are disjoint beneath their declared parent;
- an externally controlled subject's components can change only through its
own consumer, confined by `PatchGround::Consumer`;
- a batch either reduces completely or not at all.
Interestingness, novelty, ideological distribution, prose style, and counts do
not appear in this validator.
## One command and commit path
All user and system operations lower through the same path:
```text
authenticated intent or model tool call
-> CommandEnvelope
-> mailbox ordering
-> load exact WorldState revision
-> derive MutationAuthorityEnvelope
-> lower command to WorldMutationBatch
-> validate and reduce the complete candidate
-> atomic WorldState + WorldCommit CAS
-> publish invalidation
-> derive projections and next attention plan
```
The closed command vocabulary is `CommandBody` (`crates/ghostlight/src/lib.rs`),
seven variants: `ApproveDraft`, `ActivateWorld`, `ExerciseDecision`,
`DeclineDecision`, `AdmitPatch`, `AdvanceTime`, `SetLensWeights`. Speech is an
`ExerciseDecision` effect, ontology construction and evidence admission are
`AdmitPatch`, and world creation is a separate genesis (`CreateWorld`).
Membership, contract and boundary changes, external snapshots, and archive are
not commands. Every command lowers to the same mutation vocabulary. There is no
`commit_elaboration`, separate component-batch ingress, or reload repair path.
## Inference boundary
`ControllerMode` (`crates/ghostlight/src/lib.rs`) has three variants, `Human`,
`NarrativePersona`, and `OperationalAgent`; an externally controlled subject
has no mode. Ghostlight supports two first-class model decision interfaces
(`DecisionControllerMode` in `crates/ghostlight-persona-projection/src/lib.rs`:
`NarrativePersona` and `OperationalAgent`). The ontology assigns
each exact authority scope to one `DecisionController`; the scheduler does not
choose a mode opportunistically. The scheduler does choose resolution: a
`NarrativePersona` controller receives its prose membrane in a singleton cell
and is represented operationally, at coarse resolution, when grouped. That is
a budget decision, not a mode change; the controller, its scope, and its
authority are unchanged. A controller changes representation and model
ergonomics, never permission or commit authority. A `NarrativePersona` in a
grouped cell receives its typed view and nothing from the membrane; it mints
no `PersonaTurn`, so the immutable-turn-receipt rule does not apply to it.
The membrane is not weakened; it is not entered. The agency graph is
scheduler-only and is reachable from no prompt builder; its mailbox request
is port-narrowed (absent from the controller and elaboration ports), not
principal-authenticated.
An opportunity is issued to exactly one controller. An untranslated intent or
infrastructure fault cannot fall through to the other mode, and the same
opportunity cannot be exercised twice.
### NarrativePersona controller
The narrative controller keeps one deliberate three-organ membrane around
roleplay. This is not a verifier chain: each organ owns a different
representation and none can commit.
### Projector
Deterministic code first constructs a permissioned `SubjectView` from exact
occupancy, knowledge, memories, relationships, pressures, contract boundaries,
and currently executable affordances. Private views omit unavailable facts and
other members' private state by construction.
The Projector receives that view and the visible stimulus. It emits one lived
narrative stream: perception, memory, uncertainty, desire, bodily circumstance,
and available possibility in prose. It cannot choose an action, emit a schema,
or claim a consequence. The Projector is a semantic renderer across the typed
state-to-lived-experience boundary; its prose is private derived context, not
world truth.
### Persona
The Persona receives only identity guidance and the lived narrative stream. It
does not see canonical field names, IDs, JSON, tool definitions, mutation
vocabulary, effect schemas, or kernel errors. It emits only natural roleplay
prose and may remain silent, speak, decide, hesitate, or attempt something in
character.
This isolation is an explicit quality invariant. Coding-agent training creates
a powerful schema and tool-use attractor. Ghostlight does not assume that a
model can preserve equivalent roleplay while simultaneously operating tools.
### Interpreter
The Interpreter receives the Persona prose, the lived stream, and the exact
permissioned typed context. Interpretation is a total operation. It always
finishes with an `InterpretationReport` containing:
- the immutable Persona turn receipt and exact prose preserved as noncanonical
source evidence;
- every typed proposal it could faithfully lower using existing IDs and
available affordances;
- zero or more `TranslationGap` records for meaningful material it could not
encode;
- the exact source spans supporting both proposals and gaps.
Before interpretation, the NarrativePersona runner persists one immutable
`PersonaTurn` receipt binding controller, decision opportunity, world
revision/state digest, Projector receipt, Persona inference receipt, exact
source prose, and its digest. The report carries that source receipt rather
than reconstructing provenance from prompt text. Report fields are produced by
the accumulator; arbitrary deserialization is not an admission path.
A translation gap identifies ambiguity, a missing reference, a missing
affordance, a missing mutation primitive, or source left unresolved by the
current attempt. It records what the Interpreter believes the Persona was
trying to express without pretending that the effect occurred. Gaps are
non-fictional inference evidence. They may inform evaluation or an explicit
later design decision; recording one does not request elaboration or mutate the
world.
Interpreter tools provide local structural feedback and a `record_gap` action.
An invalid proposal span is not admitted, but the harness records the complete
source as an unresolved gap. An invalid gap span is likewise rebound to the
complete exact source rather than discarded. Raw tool arguments that cannot be
decoded into either typed contract enter the same total fallback, with their
payload digest retained as attempt evidence. If the step budget ends, the
harness finalizes the valid proposals and accumulated gaps and adds an exact
unresolved-source gap instead of returning semantic failure.
The Interpreter cannot add motivation to the Persona turn, rewrite its source
prose, infer unavailable knowledge, or commit an effect. Spoken words become a
typed speech proposal; wondering, deciding, attempting, and narration do not
become audible speech merely because the Persona wrote them. The kernel remains
the final structural guard for typed proposals. A stale revision or persistence
fault is a command/infrastructure outcome, not an interpretation failure, and
does not summon a semantic verifier or reconciler.
Any model transport or dispatch fault before explicit semantic finalization
produces no report. The immutable Persona turn remains pending for a fresh
interpretation attempt; partial captures are discarded as attempt telemetry,
not committed as a half-report. `StepBudgetExhausted` means the harness reached
its configured semantic stopping point normally. Infrastructure interruption is
unavailable execution, not rejected meaning.
World authoring uses a separate typed proposal surface because it is not
roleplay. It does not pass through Persona or borrow Persona authority.
### OperationalAgent controller
An operational controller receives a permissioned typed view and exact tool
schemas directly. It is suitable when the simulated mind is itself an
operator: an institution, political or administrative Gestalt, logistics organ,
market participant, or other subject whose meaningful cognition is explicit
state reasoning rather than embodied dramatic experience.
The operational agent may inspect only the state and affordances granted to its
controller and may submit zero or one typed decision proposal. It may record an
unrepresented operational need, but it cannot emit a NarrativePersona turn as
fallback, claim facts outside its view, or commit.
Subject kind does not secretly choose the interface. A person normally carries
a narrative controller and an administrative institution often carries an
operational controller, but authored ontology makes the assignment explicit.
If one institution needs both an operational decision organ and a person-shaped
public representative, they are separate controllers with disjoint authority
scopes. Awkward overlap is rejected rather than resolved by precedence.
### Presentation inference
Optional narration renders committed events for a particular audience. It is
display-only. The exact typed events remain available beside the prose, and a
rendering failure cannot block, alter, or repair a commit.
Inference receipts are operational telemetry in a separate store. The kernel
does not require a provider, model tier, stage name, output hash, or verifier
receipt to authorize world state.
Every controller lane reaches one `InferencePort`, but that port now routes
to one of three transports chosen by the lane's configured model name: the
CodexConnector daemon peer, unchanged; a local OpenAI-compatible loopback
port (the `local/` prefix, Bonsai in production); or `SdkInferencePort`, a Node child
process running the Claude Agent SDK against the Claude subscription while
no Codex or Anthropic API budget exists. The SDK route is a named stopgap,
not a peer design: its receipt attests only that this process's own child
produced this session, not against a party Ghostlight does not control, and
a prior round's conversation reaches the model as rendered prose rather than
typed turns because the SDK owns the assistant side of its own transcript.
Its exit condition is deletion, not extension — once an API budget exists, a
Messages-API port is a closer structural match to `InferencePort` (inert
`tool_use` blocks, a caller-appended `tool_result`, a real request id, no
subprocess) and replaces it behind the same routed port.
## Action and consequence
An action proposal names one subject, one executable affordance, exact targets,
and typed desired effects. The kernel derives authority and feasibility from
the current ontology. If uncertainty matters, server-owned entropy selects a
bounded outcome band. Only the typed effects for that band enter the mutation
batch.
Speech is an event and may accompany an action. Generated speech cannot imply
that an uncommitted effect occurred. Social appraisal and private memory are
ordinary scoped mutations proposed by the affected subject; they do not gain a
separate appraisal or verifier authority.
Player, NPC, institution, population, import, and strategic actions share this
primitive.
## Autonomous scheduling
The scheduler is a pure planner over one revision. It orders eligible subjects
using unresolved pressure and time since the last opportunity, with subject id
as the final tiebreak. Causal exposure is derivable from `dependencies` and is
not an ordering input. It emits `DecisionOpportunity` values and cannot commit.
Dungeon runs no turn on its own: no wall-clock tick, cover cell, elaboration
sweep or owner-requested controller turn. The clock advances only through the
owner's `world.advance_time`. The controller pool
(`GHOSTLIGHT_CONTROLLER_MAX_CONCURRENT`, default 4) is read at open; nothing
draws from it yet, and runtime readiness reads it. Changing it is a restart.
The five
controller models are configuration too (`GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL`,
`_PERSONA_MODEL`, `_INTERPRETER_MODEL`, `_OPERATIONAL_MODEL`,
`_ELABORATOR_MODEL`), each with a default when absent.
The kernel commits one decision at a time through the mailbox. After each
commit the planner derives a fresh queue. Parallel inference may speculate on
one snapshot; a proposal binds to the scope digest of the components its
verification reads, commits at any later revision where that digest is
unchanged, and is rejected with a typed `ScopeChanged` when it is not. Nothing
is rebased and nothing is discarded merely because the world moved elsewhere:
for the narrative lane, `ScopeChanged` on submit is one re-lowering, not a
lost turn. The runner re-lowers the same prose once through the Interpreter
against a fresh opportunity, with the delta the subject could perceive since
its bound revision (`Overheard` rows plus the typed component diff); the
Persona is never re-run, and the renewed binding carries the one it replaced
(`interrupted_from`) so a second interruption of the same turn ends it rather
than lowering it again.
Resolution covers and grouping are compute budgets for projection. They do not
create, merge, fission, or qualify identities. Every active subject is in the
cover every tick; the budget decides at what resolution. Singleton cells give
detail focus and the prose membrane; grouped cells represent their constituents
operationally at coarse resolution with per-constituent attribution. Debt
rotation guarantees every subject reaches detail focus within bounded ticks.
The 2,400-subject, 240-cell profile is a design target for a living world, not
only a load fixture: it tests the elaborators and the simulation for quality
under scarcity of attention.
## Source grounding and open-world expansion
Vault retrieval returns exact evidence receipts. One author inference may fill
semantic fields in a typed seed or boundary-expansion proposal. The reducer
issues IDs and validates the closed structural graph.
Seed construction, destination expansion, locality detail, causal
individuation, and external import all use the same ontology mutation
vocabulary. Expansion is requested only for a named causal boundary. A sparse
world with enough structure for its current horizon is valid.
Qualitative evaluators may report implausibility, monotony, missing social
texture, or poor narrative leverage after a run. Those reports guide a future
author proposal; they do not become a hidden admission tribunal.
## Persistence and recovery
One world `.cc` store (`crates/ghostlight/src/journal.rs`) contains:
- one current `world_state.elaboration.v1` row, schema
`ghostlight.world_state.consumer.v6`;
- immutable `world_commit.elaboration.v1` rows, schema
`ghostlight.world_commit.consumer.v6`, forming a digest chain. Evidence
receipts ride inside the commits' patches; there is no separate evidence row.
Service authentication and model telemetry use separate service-owned stores.
They cannot participate in world authority.
Recovery verifies `(world_id, revision, state_digest, last_commit_digest)`,
starts the mailbox, derives due work, and continues. In-flight inference is
disposable. A repeated command ID is idempotent only when its full digest
matches the recorded commit. Physical filenames, smoke checkpoints, prose
summaries, and status files are not recovery truth.
Pre-rebuild campaign stores remain immutable evidence. The new runtime does not
load them into live authority through a compatibility adapter. Any future
migration must be an explicit, audited import that emits ordinary commands.
## External boundaries and surfaces
- Heimdall owns public identity; Ghostlight stores only the app-local principal
needed to derive membership authority.
- VoidBot or another Vault provider owns retrieval; evidence receipts grant
provenance, not mutation authority.
- External world consumers retain sovereignty over their declared subjects.
Ghostlight imports only owner-signed components through the ordinary command
path and cannot invent internal state for them.
- Idunn owns deployment and daemon continuity; Odin owns discovery. Neither can
write a world.
- Eve/CultUI and CultMesh expose typed projections of the same `WorldState` and
command catalog. Renderers do not own parallel dashboard truth.
## Deletion line
The rebuild removes these authorities before replacing behavior:
- `SessionZeroKernel`, its registry/director state, and publication handoff;
- canonical aggregate `Campaign` and the component-to-Campaign projection
writer;
- `legacy_transition` and every special construction or repair commit path;
- duplicate or cell-specific Projector-Persona-Interpreter implementations;
implicit mode switching or fallback between narrative and operational
controllers; effect verifier, assessment verifier, outcome verifier, civic
verifier, and reconciliation roles;
- destination identity inference and name-based structural binding;
- model resolution demand, Nemesis selection, and identity invention;
- titled elaborator quotas, complexity qualification, semantic qualification,
fission completion counts, and model compaction memory;
- newspaper selection/editor/copy-desk authority and checkpoint recovery;
- acceptance-driver gates that decide world validity;
- checkpoints, resume files, caches, and model receipts used as fictional truth.
The retained foundations are the typed component mutation algebra, one mailbox,
CultCache CAS and immutable receipts, exact evidence, deterministic topology and
scope validation, actor-private projection, and the declared service ownership
boundaries.
## Verification contract
Focused tests prove the machine under fixture inference ports. No live smoke
exists: the play agent's Cut 1 (`d69e9d4`) deleted the tick driver, cover and
Active elaboration sweep it exercised, along with the ignored test itself, to
clear the ground for the play agent's own owner. `notes/local-live-smoke.md`
now documents only connector and Claude SDK sidecar bring-up, which the seed
lane still uses. The real-model gate that exists today is the Idunn recipe's
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`), which
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
against a real model; exercising a whole play turn against one is not gated
by this contract.
`world.begin` is the one route that makes a world. Its payload is
`world_begin.v1`: title, brief and subject_label, nothing else; the route
decides everything else a world is born with (no targets, no roots, no persona
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
goes. `world.seed` runs one
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
Focused tests must prove:
1. Draft and active commands mutate the same aggregate through the same commit
primitive.
2. No alternate input can write canonical state.
3. Unknown IDs, dangling routes, invalid relation endpoints, custody creation,
authority escape, private-knowledge leakage, and external-owner violation
fail before persistence.
4. Interpreter semantics are total: representable material becomes typed
proposals, unrepresentable material becomes exact translation gaps, and no
semantic path terminalizes the interpretation or calls a verifier or
reconciler model.
5. Scheduler, transcript, news, and surfaces can be discarded and rebuilt from
the same committed state.
6. Restart resumes from the commit digest chain with no checkpoint or repair
pass.
7. A sparse structurally supported world activates successfully.
8. A synthetic 1,200-owner fixture exercises scheduling and projection without
changing production completeness rules.
9. Direct user, model, scheduler, import, and reload paths all reach the same
reducer and CAS.
10. Every autonomous opportunity resolves through one exact controller; its
authority scopes do not overlap another controller, and gaps or faults
cannot trigger cross-mode fallback or double action.
The Projector-Persona-Interpreter membrane has its own behavioral evaluation.
Operational-agent success in its admitted scopes is not evidence that tools are
safe for a person-shaped role. Any proposal to replace a `NarrativePersona`
controller with a tool-using agent must compare both designs on the same
sequential fixtures, model, visible facts, and token budget. It must show no
material regression in voice, embodiment, uncertainty, tonal range, schema
leakage, tool-shaped diction, or continuity while matching typed-effect
fidelity. Until that evidence exists, narrative Personas remain prose-only.
Qualitative world and narrative evaluation is a separate post-run artifact. It
may be severe. It may not become a writer.

View file

@ -0,0 +1,886 @@
# Ghostlight library extraction — cut map (plan step 15, L0)
Status: cut map. Ends are owned by `ghostlight-library-extraction.md`; this
document owns the means. Written 2026-09-15 against `d83534c` on
`codex/ghostlight-dungeon-mvp`; first version committed at `02bbf62`;
refreshed 2026-09-16 with the operator's rulings.
Cut 1 landed at Ghostlight `108b691..f859d2c`. `108b691` is 13 renames with
zero content change; `f859d2c` builds. Verified by Soul's own runs, not the
Hands report: library 419 passed + 1 ignored with test names identical to the
base `world::` set; 10 compile_fail doc-tests pass, also under
`--all-features`; Dungeon 50 + 1 ignored, all 49 base names present and the
+2 exactly the two new soul tests; no `[features]`, `cfg(feature`, or
`cfg(any(test` in the library; no schema string, `_NAMESPACE`, `derive_id`,
serde attribute, or `Preimage` changed; the rewritten tests fail under
mutation of what they pin. Windows only; the Linux release remains Idunn's.
Soul found six defects. Triage:
- **F1 seal is name-based (high, open):** every `pub` ID derives
`Deserialize` (`crates/ghostlight/src/lib.rs:186-200`), as do `ScopeDigest`
(`:532`) and `DecisionOpportunity` (`:559`), so an external crate mints IDs,
reproduces `ScopeDigest::fixture`, and forges a `DecisionOpportunity` into
`derive_cover` from JSON. The reducer still rejects forged opportunities
(`lib.rs:4192-4201`), so kernel integrity holds and the invariant's wording
does not. Dungeon's own Eve payloads deserialize these types, so removing
the derives is not available. Operator question Q1-9 below.
- **F2 single-minter test is bypassable (medium, fix):** the count at
`crates/ghostlight-dungeon/src/app_session.rs:530-555` greps a literal and
misses `<VerifiedPrincipalEvidence>::new(`, an alias, a renamed test module,
or a subdirectory.
- **F3 surface wider than bought (low, fix):** 71 `pub` identifiers are
unreferenced by Dungeon; `ScopeDigest`, `WorkLane`, and `ScopeComponents`
compile as `pub(crate)` and were opened only to quiet `private_interfaces`.
- **F4 "Eve" named in library error strings (low, fix):**
`crates/ghostlight/src/controllers.rs:1494,2576,2582,2584`. Pre-existing;
invariant 3 is still unmet.
- **F5 two Hands claims false as stated (cosmetic, recorded):** `Cargo.lock`
did gain a `ghostlight` package entry (no new external package), and
`fixture_[a-z]` has 8 benign hits in Dungeon.
- **F6 recipe schema drift (pre-existing, recorded):**
`deployment/idunn/recipe.toml` declares `world_state.foundation.v0` and
`controller_work.v3` against code at `consumer.v4` and
`controller_work.v15`. Outside L0; it changes no behavior here.
Cut 2 landed at gamecult-ops `6aba281` (text only; nothing live was run).
The library test step is in the runbook, the deploy script, and the wiring
test, ordered as `recipe.toml` orders it. Two spec defects Hands reported
rather than redesigned: the assertion this map specified was vacuous
(`require_text` is `grep -Fq`, and the string given is a substring of the
persona-projection line, so it passed with the library step deleted), so
Hands used the file's existing exact-line helper and proved it fails under
deletion; and the acceptance test path lives in `acceptance_test=` at
`scripts/deploy-ghostlight-yggdrasil.sh:34`, a fourth site this map did not
list, pinned by the wiring test and repeated in the runbook prose. Operator
consequence: changing `acceptance_test` changes the acceptance binding, so
sealed witnesses under `/srv/ghostlight/acceptance/<binding-sha>/` from
before this commit will miss and regenerate.
Soul passed on Cut 2 and found the assertions pin text, not invocations:
- **C2-F1 (high, fixing):** deleting the two lines above the pinned cargo
line in `scripts/deploy-ghostlight-yggdrasil.sh` leaves it byte-identical
as trailing argv on the *persona-projection* container, so the kernel
tests never run and the wiring test still exits 0. Prefixing the
`docker run` line with `: ` does the same.
- **C2-F2 (medium, fixing):** nothing pins the order of the four cargo
steps; the library block moved after the release build and the wiring
test passed, which would seal an artifact before the kernel tests ran.
- **C2-F3 (medium, fixing):** the wiring test never reads the runbook, so
only three of the four acceptance sites are enforced to agree.
- **C2-F4 (medium, pre-existing, recorded):** `deploy-…sh:937` takes a
reuse branch when the release directory for a commit already exists, so a
redeploy of a sealed commit runs no cargo steps at all. This bounds "the
deploy runs the kernel tests" to a commit's first build. Operator owns
whether that is right.
- **C2-F5 (low, recorded):** `docs/repo-census-2026-09/*` still shows
`src/world/...`. Dated census; no deploy effect.
Held: the step sits in the same branch, image and mounts as its
neighbours, and `set -euo pipefail` with `trap` means a library-test
failure does abort the deploy. The acceptance test exists at
`crates/ghostlight/src/controllers.rs:8980` under `-p ghostlight --lib`.
C2-F1, C2-F2 and C2-F3 are fixed at gamecult-ops `803f2c7` (wiring test
only). A new `require_invocation` helper pins each of the four cargo steps
as a whole contiguous docker invocation and returns its start line; three
numeric comparisons assert the recipe order; the runbook became a checked
file, tying all four acceptance sites together. All four of Soul's
mutations now exit 1 where they previously exited 0, and every
pre-existing assertion still passes. `require_order` was unusable here
because its `last_line` resolves a substring and would have bound the
library step to the acceptance invocation.
Soul passed on the fix batch and confirmed it does what it claimed:
whole-invocation pinning survives image swaps, line swaps, re-indentation,
continuation splits and appended flags; the order checks catch a move into
the reuse branch; the runbook is tied in both directions; `fail` inside a
command substitution does abort, because `set -e` fires on the
assignment's status; nothing was weakened. It also found the structural
limit, recorded and not fixed:
- **C2-F6 (high, recorded):** an exact-line pin proves the lines exist,
not that they run. Wrapping the block in `if false`, prefixing the
opener with `true || \`, or swallowing it in a heredoc keeps every
pinned line byte-identical and the checker exits 0.
- **C2-F7 (medium, recorded):** the four steps carry no explicit `||
exit`; abort depends entirely on `set -euo pipefail` at
`deploy-…sh:2`, which nothing pins. Removing the `e` leaves the checker
green, so a failing kernel suite would be followed by a sealed release.
- **C2-F8 (low, recorded):** a duplicated block passes, since the helper
takes the first match and never asserts a count.
These stay recorded rather than fixed: that script is a legacy actuator
under Ghostlight's do-not-invoke warning and is retired at the deployment
gate, and the authority that actually runs the tests is Ghostlight's own
`deployment/idunn/recipe.toml`. Closing them means control-flow analysis
in a bash checker for a script we intend to delete. Revisit only if the
deployment gate keeps the script.
Follow-ups outside this migration, from the fix batch:
- `scripts/deploy-ghostlight-yggdrasil.sh:974`, the web container, is
pinned by substring and survives a `: ` prefix that neuters it.
- The Codex Connector's four cargo steps are bare substring pins with no
invocation or order assertion: the connector-side C2-F1 and C2-F2.
- `require_command_options` searches a fixed 18-line window, so a block
that grows past it stops being checked silently.
Cut 1 fix batch landed at Ghostlight `7c1d1e9..2865ea7`: `a8d3377` external
rejection tests (Q1-9), `fac90e6` single-minter check by name (F2),
`7cbb9e3` Eve removed from library text (F4), `2865ea7` public surface
262 → 258 (F3). Hands' correction to F3 is confirmed by Soul: Dungeon calls
`bound_scope_digest` and `fresh_scope_digest`, and `ScopeDigest`, `WorkLane`
and `ScopeComponents` are held public by `KernelError::ScopeChanged`,
`ControllerWorkCustody::Uncertain::lane` and `NarrativeCheckpoint` variant
fields; closing them means reshaping public enums, which F3 does not
license. `fac90e6`'s commit message describes a replaced brace-counting
scanner; the landed code and its doc comment are correct.
Soul held: the rejection tests are a true external crate with no `#[path]`
or `include!`, driving `ControllerPort::submit_controller` through the
mailbox into `reduce` with no pre-validation; every commit moves `revision`
and `state_digest`, so the unchanged-state assertions are sound; the
positive test goes red when issued opportunities are refused; counts,
the 14 distinct warnings, and both manifests are unchanged.
Soul found:
- **S1 (medium, fixing):** both forged digests differ in length from a real
`sha256:` + 64 hex, so comparing only lengths at
`crates/ghostlight/src/lib.rs:4203` leaves all seven tests green. The
realistic forgery is a stale same-length digest from an earlier revision.
- **S4 (medium, open, Q1-10):** the minter scanner at
`crates/ghostlight-dungeon/src/app_session.rs:523-668` misses a `pub type`
alias, a multi-line alias, a `} // tests` closing brace, non-rustfmt
indentation, and `::new` taken as a function value.
- **S7 (low, fixing):** `controllers.rs:122 provider_model` closes to
`pub(crate)` with no error and no warning change.
- **S8 (cosmetic, fixing):** `mailbox.rs:580` names `AppSessionOwner` and
`controllers.rs:2606` names AppSession.
- **S2 (low, recorded):** neutering the world_id check at `lib.rs:4193`
leaves the suite green because `ScopePreimage` includes `world_id`, so the
scope lookup refuses first. Redundant by construction; not isolable from
outside the crate.
- **S3 (low, recorded):** the decline path (`lib.rs:1603`) is `pub(crate)`
and unreachable from an integration test.
- **S5 (low, recorded, pre-existing):** `runtime.rs:3584`'s credential-name
test splits on one literal `#[cfg(test)]` marker and is evadable the same
way the minter test was.
- **S6 (medium, recorded):** stable rustc does not enforce `compile_fail`
error codes; `E0603` changed to `E0999` still passes. All ten doc-tests
prove "does not compile", not privacy, and the three `#[cfg(test)]`
items (`SubjectId::issue`, `ScopeDigest::fixture`, the `fixture_*` fns)
prove non-existence. Enforcing codes needs a snapshot tool such as
trybuild; recorded, not added.
**Q1-10 how the single minter is enforced (defaulted, 2026-09-16).** Asked
how much it mattered, the operator said not at all. The scanner stays as a
tripwire with stated limits: it guards only against the project's own
Dungeon code, and what evades it is deliberate. S4 is a recorded follow-up,
with Clippy's `disallowed-methods` for
`ghostlight::VerifiedPrincipalEvidence::new` noted as the fix if a second
minter ever becomes plausible.
S1, S7 and S8 landed at Ghostlight `bde05de..af9c95a`. S1 submits a
genuinely issued digest made stale by another controller's speech, plus the
current digest with its last digit altered. Soul confirmed the premise
check fails when staleness does not happen and that no earlier check
intercepts the forgery, then found two surviving comparison mutants
(case-insensitive, hex-only after the label). Those, and "player"
vocabulary in library tests, landed at `b14507c` (uppercased-letter,
bare-hex and relabelled forgeries, all six comparison mutants killed) and
`ab95d78` (test-only rename to "human", one test name changed). Soul's
final pass found nothing to fix.
**L0 closed, 2026-09-16.** Postmortem:
`ghostlight-library-extraction-postmortem.md`. Follow-ups are listed there.
**Q1-9 what sealing means (ruled, operator, 2026-09-16): option A.** The
invariant is unforgeable admission, not unconstructible types. External
code may hold a syntactically valid ID, digest, or opportunity; the kernel
must reject any it did not issue, and that is proven by rejection tests
written from outside the crate. The compile_fail set stays for the
genuinely private items. The question as first posed: A: restate invariant 1 as unforgeable
*admission* — external code may hold a syntactically valid ID or opportunity,
and the kernel must reject any it did not issue — and prove it with rejection
tests from an external crate plus the existing compile_fail set. B: make the
types unconstructible from outside, which means Dungeon stops deserializing
`DecisionOpportunity` in its Eve payloads and receives an opaque token
instead, a design change L0 excludes. **Recommended: A**, because the kernel
already enforces it and B moves work into L0 that the target puts out of
scope. Whichever is chosen, F2 and F3 are fixed in the same Hands batch.
Rulings (operator, 2026-09-15):
- **Q1-1 crate name:** "crate name is `ghostlight`."
- **Q1-2 adapters:** "`vault.rs` and `sdk_inference.rs` both go in the
library."
- **Q1-3 principal seam:** "option A, library-owned
`VerifiedPrincipalEvidence` with a public `new`, plus the Dungeon count
test pinning the single minter."
- **Q1-4 fixtures:** "NOT the `test-support` feature. The operator takes the
alternative: the sealed ID `issue()` constructors and `ScopeDigest::fixture`
stay unreachable from outside the library under every configuration,
including feature-enabled builds, and the Dungeon tests that currently
need them move into the library instead. Rationale: a sealed constructor
exposed behind a feature is still reachable by any crate that turns the
feature on, which contradicts invariant 1." (Supersedes the `test-support`
design in the `02bbf62` map; that text is history and is not repeated
here. The classification below found that none of the affected tests is
a kernel test, which raises Q1-7 and Q1-8.)
- **Q1-5 persona projection:** "`ghostlight-persona-projection` stays a
separate crate."
- **Q1-6 gamecult-ops:** "edit the five gamecult-ops cargo lines in Cut 2;
do not retire the scripts (retirement belongs to the deployment gate)."
Correction to the `02bbf62` map: Ghostlight's handoff does call those
helpers unsafe — `notes/fresh-workspace-handoff.md:389-391`: "Do not
invoke the current bounded Connector or Ghostlight redeploy helpers; they
fail at the root/Idunn Git boundary and still embody duplicate target
deployment authority." The earlier claim that no such doc existed was
wrong; the ruling to edit rather than retire stands on the deployment
gate owning retirement, not on the scripts being live.
Rulings (2026-09-16), taken by Self under the operator's standing "Go"
after Q1-4's classification found no kernel test to move. Either may be
reversed by the operator; both are recorded here as the live design:
- **Q1-7 port value constructors:** option A. `PreparedInference::prepare`
(renamed from `prepare_invocation`), `InferenceOutput::new`, and
`InferenceFault::{retryable, recovery_required, integrity_violation}`
become the port's public contract; the five `fixture_*` items are
deleted and `TracingInferencePort` becomes an ungated decorator. A
public trait whose return types only the library can construct is not a
port, none of these touches a sealed item, and the change is a net
deletion.
- **Q1-8 cover source:** option A. The tick-ordering test derives its
cover from a real world through `derive_cover` and asserts 2 cells, with
its ordering and shared-tick assertions unchanged;
`fixture_controller_opportunities`, the only outside caller of a sealed
`issue()`/`ScopeDigest::fixture`, is deleted.
Together these satisfy Q1-4's intent: the sealed ID issuers and
`ScopeDigest::fixture` stay private `#[cfg(test)]` items with no feature
gate anywhere, unreachable from outside the library under every
configuration.
Open: nothing blocking Cut 1. Operator checks remain on the Linux release
(Idunn's recipe run), which no Windows probe can prove.
Follow-ups outside this migration: see "Findings not assigned to a cut".
Probe names versus real names: probes 1–4 built the crate as
`ghostlight-world` (`use ghostlight_world::…`); probe 5 rebuilt it as
`ghostlight` (`use ghostlight::…`). The difference is the package name, the
directory `crates/ghostlight`, and the path prefix; every mechanism finding
held in both. This map uses the real name throughout.
## What the probes established
All probes ran in scratch worktrees at `d83534c` (both removed, repo clean)
on the Windows workstation with `cargo check` and `cargo test --doc`; they
prove the crate graph and visibility on Windows only. The Linux proof is
Idunn's recipe run on Yggdrasil. Logs are in the session scratchpad
(`probe1*-check.log`, `probe3*-visibility.log`, `probe5.log`,
`base-test-list.log`); they are not repo artifacts.
- **Base test census** (`cargo test -p ghostlight-dungeon --bin
ghostlight-dungeon -- --list`, repo target dir): 469 unit tests in the bin,
of which 420 are under `world::` (action 34, clock_tests 45, consumer 30,
controllers 81, cover 18, custody_tests 17, elaboration 14, journal 24,
knowledge_tests 21, mailbox 15, patch 34, sdk_inference 23,
soul_knowledge_tests 9, tests 32, vault 6, witness_tests 17) and 49 are
Dungeon's own (app_session 7, eve 5, heimdall 5, idunn_health 4, mesh 4,
runtime 24). `tests/build_provenance.rs` has 1. `ghostlight-persona-
projection` has 13 unit, 0 doc. Two tests are `#[ignore]`:
`world::controllers::tests::real_codex_connector_cognition_modes_commit_speech`
and `runtime::tests::live_smoke_seeds_then_ticks_a_world_against_the_connector`.
- **Base warning**: `mod.rs:28-32` re-exports nine names nobody in the crate
uses (`ControllerOpenError`, `NarrativeCapture`, `NarrativeDecision`,
`NarrativePending`, `OperationalCapture`, `OperationalDecision`,
`OperationalPending`, `SourceRange`, `TranslationGapSummary`). Their
deletion is inside Cut 1.
- **The move compiles** (probe 1d). Moving `src/world/*` to the new crate
(`mod.rs` → `lib.rs`), rewriting `crate::world::` → `crate::` inside it and
→ `ghostlight::` in Dungeon, and moving `VerifiedPrincipalEvidence` into
the library root: `cargo check` on the library, its tests, the Dungeon
bin, and the Dungeon tests all pass. Two mechanical faults: the 94
`pub(super)` sites in the former `mod.rs` are illegal at a crate root
(become `pub(crate)`), and `consumer.rs:62` needs `serde_bytes` in the
library manifest.
- **What Dungeon needs public** (probe 3, four compiler rounds from every
item `pub(crate)`): 54 production items, 22 test-only items, 38 fields on
10 structs, 36 methods, the opaque IDs `AffordanceId`/`CommandId`/
`SubjectId`, and `FictionalMinutes(u64)` (one Dungeon test). Nothing
sealed appears: `WorldKernel`, `WorldState`, `reduce`, `apply_effect`,
`CommandEnvelope`, `AuthenticatedCaller`, the journal, `*::issue()` and
`ScopeDigest::fixture` are never named by Dungeon. Under the narrowed
library `cargo check -p ghostlight` emits 646 dead-code warnings — a
second enumeration of the same boundary (an item used only by Dungeon is
dead inside the library until it is `pub`).
- **The sealed set under the Q1-4 ruling** (probe 5, crate `ghostlight`, no
cargo features, fixtures left `#[cfg(test)]`): ten `compile_fail`
doc-tests on the library root pass under `cargo test -p ghostlight --doc`
and again under `cargo test -p ghostlight --all-features --doc` (the crate
declares no features, so the second run is the same build; it is the
command Soul re-runs if a feature is ever added). `cargo check -p
ghostlight-dungeon --bin ghostlight-dungeon` passes: production needs no
fixture. `cargo check -p ghostlight-dungeon --tests` fails on exactly four
errors — `runtime.rs:3081` (`ControllerWork…` family, `Inference…`
types, three `fixture_inference_*`), `:3570`
(`fixture_controller_opportunities`), `:2582` (`TracingInferencePort`),
`:2581` (`InferencePort`, because the base re-export at `mod.rs:66` is
`cfg(test)`). Those four sites are the whole cross-boundary test surface;
they are classified under Cut 1.
- **Compile-fail mechanism**: doc-tests compile as an external crate, which
is exactly the boundary; no `trybuild`, no scratch crate. The ten
(`use ghostlight::WorldKernel`, `reduce`, `WorldState`,
`CommandEnvelope`, `AuthenticatedCaller`, `journal::WorldJournal`,
`SubjectId::issue()`, `ScopeDigest::fixture("x")`,
`fixture_controller_opportunities(&[])`, `fixture_inference_output(..)`)
are pinned to `E0603`, `E0599`, `E0599`, `E0425`, `E0425`.
- **Negative-grep collisions** checked in `src/world/`: `axum`, `tower_http`,
`jsonwebtoken`, `heimdall`, `eve::`, `idunn_health`, `Session Zero` have
zero legitimate hits. `Dungeon` collides with the vault fixture path
`Spoilers/Dungeons/Provenance.md` (`vault.rs:489,581`): use `\bDungeon\b`.
`player` collides with `tests::player()` (`consumer.rs:966`): do not
publish a bare `player` pattern. `app_session` has 5 hits, all the
principal seam, and must reach 0.
- **Line endings**: index and working tree are LF (`git ls-files --eol`
reports `i/lf w/lf`); `core.autocrlf=true` rewrote every fresh scratch
worktree to CRLF. Hands edits in `F:\Projects\Ghostlight` itself, never in
a fresh worktree; if a worktree is unavoidable, run `git ls-files --eol
crates` after the move and refuse any `w/crlf`. `sdk_inference.rs:1831`
normalizes `\r\n` before scanning source.
## Cut 0. Captures
- **Repo/branch:** Ghostlight `codex/ghostlight-dungeon-mvp` at `02bbf62`
(source identical to `d83534c`). No commit; output lives in the session
scratchpad.
- **First:**
- Test census: `cargo test -p ghostlight-dungeon --bin ghostlight-dungeon
-- --list > base-tests.txt` (PowerShell `>` writes UTF-8 BOM + CRLF;
compare post-cut with the same redirection and `Compare-Object` on
trimmed lines after stripping the `world::` prefix). Expected 469.
- A `world.cc` written at base. No store exists on disk
(`F:\Projects\Ghostlight-smoke\ghostlight` holds no `world.cc`), so
produce one: in a scratch copy at `d83534c`, add an ignored test beside
`mailbox.rs:966 fixture()` that opens `WorldMailbox::open(<capture>/world.cc)`,
creates through `create_fixture`, approves, activates, and ticks three
times through `submit_clock`; record `snapshot().revision`, the
`state_digest`, and the last `commit_digest` from `operator_log` to
`capture.txt`. Copy `world.cc` and `capture.txt` to the scratchpad.
After Cut 1 the same test body, in the library's `mailbox` tests,
reopens that file and asserts the three values are equal. The
`controller-work.cc` layout is pinned by the untouched
`CONTROLLER_WORK_SCHEMA`/`CONTROLLER_WORK_ROW` constants
(`controllers.rs:75-76`) and the existing v7/v8 refusal tests
(`controllers.rs:6487,10654`); no separate capture.
- `Get-ChildItem target -Recurse | Measure-Object Length -Sum`: 7.5 GiB at
the start of this pass.
## Cut 1. Extract the library crate
- **Repo/branch:** Ghostlight `codex/ghostlight-dungeon-mvp` from
`02bbf62`. One commit; the crate graph does not build half-moved. Depends
on Cut 0 and rulings Q1-7, Q1-8.
- **First:** Cut 0 captures in hand; `git status` clean; working in the
main checkout (LF).
- **Deletes first:**
- `crates/ghostlight-dungeon/src/world/mod.rs:28-32` — the nine unused
re-exported names above (leave `CellRun`, `ConnectorBinding`,
`ControllerError`, `ControllerModels`, `ControllerPendingReason`,
`ControllerRunner`, `ControllerWorkCustody`, `NarrativeRun`,
`OperationalRun`, `SubmissionDisposition`, `open_controller_work`,
`open_inference`).
- `crates/ghostlight-dungeon/src/world/mod.rs:61-72` — the `#[cfg(test)]`
re-export block (12 lines). The port traits and their value types in it
return as ungated production `pub use` under Adds; the three
`fixture_inference_*` names do not return.
- `crates/ghostlight-dungeon/src/world/mod.rs:483-508` —
`fixture_controller_opportunities` (26 lines), per Q1-8 A; and
`controllers.rs:372-382,409-432` — `fixture_prepared_inference`,
`fixture_inference_output`, `fixture_inference_events` (about 40
lines) and `controllers.rs:221-237` — `InferenceFault::fixture_*`
(17 lines), per Q1-7 A. Under Q1-7 B / Q1-8 B these stay `#[cfg(test)]`
inside the library and the Dungeon tests that used them are deleted
instead (see the classification).
- `crates/ghostlight-dungeon/src/app_session.rs:54-91` — the
`VerifiedPrincipalEvidence` struct, accessors, and `fixture` (38 lines).
- `crates/ghostlight-dungeon/src/main.rs:7` — `mod world;`.
- `crates/ghostlight-dungeon/Cargo.toml:27` — the direct
`ghostlight-persona-projection` dependency (`controllers.rs:35` is the
only importer and it moves).
- **Keeps and moves:**
- `git mv crates/ghostlight-dungeon/src/world/{action,clock,consumer,
controllers,cover,elaboration,journal,mailbox,patch,sdk_inference,
tool_schema,vault}.rs crates/ghostlight/src/` and
`git mv .../world/mod.rs crates/ghostlight/src/lib.rs`. Verify with
`git diff --cached -M --stat` that all 13 show as renames.
- Inside the moved files: `crate::world::` → `crate::` (199 sites;
`super::super::` paths need no change); `pub(super)` → `pub(crate)` in
`lib.rs` only (94 sites; submodules keep theirs);
`crate::app_session::VerifiedPrincipalEvidence` → `crate::VerifiedPrincipalEvidence`
(`mailbox.rs:10`, `controllers.rs:10714,10790,10988,11473`).
- `vault.rs` and `sdk_inference.rs` move with the rest (Q1-2). Their
fixture writes (`sdk_inference.rs:1900,1983`) resolve from
`crates/ghostlight` at the same depth.
- `build.rs`, `tests/build_provenance.rs`, `runtime.rs:375` (web/dist)
stay in Dungeon; `CARGO_MANIFEST_DIR/../..` still resolves.
- No test moves between crates. Every test under `world::` was already a
library test and moves with its file (420); every Dungeon test stays in
Dungeon (49). See the classification.
- **Adds:**
- `Cargo.toml` (workspace): member `crates/ghostlight`.
- `crates/ghostlight/Cargo.toml`: package `ghostlight`; no `[features]`;
dependencies `async-trait`, `chrono`, `codex-connector`, `cultcache-rs`,
`ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }`,
`rmp-serde`, `serde`, `serde_bytes = "0.11"`, `serde_json`, `sha2`,
`thiserror`, `tokio`, `tracing`, `uuid`, `zeroize` (all `.workspace =
true` except `serde_bytes`, which the workspace does not declare); dev
`tempfile = "3"`. Every one is already in `Cargo.lock`; no new package.
- `crates/ghostlight-dungeon/Cargo.toml`: `ghostlight = { path =
"../ghostlight" }` in `[dependencies]`; nothing in dev-dependencies.
- `Cargo.lock`: the new `[[package]] name = "ghostlight"` entry. Commit
it; Idunn builds `--locked`.
- `crates/ghostlight/src/lib.rs`, root: `pub struct VerifiedPrincipalEvidence
{ account_subject_hash: String, valid_until: DateTime<Utc> }` with `pub fn
new`, `pub fn account_subject_hash`, `pub fn valid_until`, and
`#[cfg(test)] fn fixture` delegating to `new` (Q1-3). Doc comment: the
consumer verifies; the library bounds by `valid_until` at
`mailbox.rs:231`.
- `crates/ghostlight/src/lib.rs`, the public boundary, one ungated list.
`pub use clock::{FictionalMinutes, TickMinutes}`;
`consumer::{CONSUMER_BODY_LIMIT, CONSUMER_CREDENTIALS_ENVIRONMENT,
CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, ConsumerRegistry,
admit_document, encode_receipt}`;
`controllers::{CellRun, ConnectorBinding, ControllerError,
ControllerModels, ControllerPendingReason, ControllerRunner,
ControllerWork, ControllerWorkCustody, ControllerWorkLookup,
ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite,
InferenceEvent, InferenceFault, InferenceOutput, InferencePort,
InferencePurpose, InferenceRequest, NarrativeRun, OperationalRun,
PreparedInference, SubmissionDisposition, ToolResultOracle,
open_controller_work, open_inference}` (plus `TracingInferencePort`
under Q1-7 A);
`cover::{AgencyGraph, Cell, Cover, CoverBudget, TickIndex, derive_cover}`;
`elaboration::{SeedOutcome, select_row}`;
`mailbox::{ConsumerPort, ControllerPort, MailboxError, SeedPort, WorldMailbox}`;
`patch::{JurisdictionKey, Statement}`;
`sdk_inference::{DEFAULT_SDK_MODEL_PREFIX, SdkBinding}`;
`vault::VaultEvidenceSource`. Root items made `pub`: `STATE_SCHEMA`,
`state_schema_compatibility_tag`, `AffordanceId`, `CommandId`,
`SubjectId` (the `opaque_uuid!` macro at `mod.rs:98-106` emits
`pub(crate) struct`; give it a visibility parameter and emit `pub` for
these three only — the `issue()` impls stay private and `#[cfg(test)]`),
`PrincipalId`, `WorldPhase`, `SubjectKind`, `ControllerMode`,
`CreateWorldIntent`, `CreateJurisdictionIntent`, `DecisionOpportunity`,
`DecisionInvocation`, `PrincipalCommandIntent`, `CommandBody`,
`WorldSnapshot`, `OperatorEvent`, `CommitReceipt`, `SubmitReceipt`,
`KernelError`, `ScaleDeficitRow`, and the snapshot row types Dungeon
reads through `WorldSnapshot` fields (`SubjectSnapshot`,
`PlaceSnapshot`, `AffordanceSnapshot`, and whichever others the
compiler names; probe 3 stopped at the `WorldSnapshot` field layer).
`GroupedRun` (`controllers.rs`) is reached through `ControllerRunner`
return values and needs `pub` with its five fields.
- How the ports stay public without the fixture constructors becoming
reachable: `InferencePort` (`controllers.rs:259`) and
`ControllerWorkStore` (`:1541`) are traits whose method signatures name
`InferenceRequest`, `PreparedInference`, `InferenceOutput`,
`InferenceFault`, `ToolResultOracle`, `ControllerWork`,
`ControllerWorkLookup`, `ControllerWorkWrite`, `ControllerWorkCustody`,
`ControllerWorkStoreError`. Those types become `pub`; their fields stay
private except where listed below. Nothing about them reaches an ID
`issue()` or `ScopeDigest::fixture`, which are private free functions
and `#[cfg(test)]` impls (`mod.rs:200-226,448`) with no `pub` path of any
kind and no cargo feature — reachable only from inside the library's
own test build. The `fixture_*` functions are deleted (Q1-7 A / Q1-8 A)
or stay `#[cfg(test)]` (B), and the ten doc-tests pin both outcomes.
- Fields that become `pub` (38, from probe 3): `CommitReceipt.{command_id,
commit_digest, resulting_revision, resulting_state_digest}`,
`Constituent.subject`, `Cover.{cells, oversubscribed, tick}`,
`CoverBudget.{cells, constituent_cap, urgency_slots}`,
`DecisionOpportunity.{controller_mode, world_id}`, `GroupedRun.{cell,
needs, pending, resolution, submissions}`, `OperatorEvent.{revision,
speech}`, `PreparedInference.purpose`, `ScaleDeficitRow.{deficit,
jurisdiction, kind, target}`, `WorldSnapshot.{affordances,
draft_approvals, now, opportunities, owner, phase, places,
required_approvers, revision, scale_deficit, subjects, title, world_id}`.
`FictionalMinutes.0` stays `pub(crate)`: under Q1-8 A the one Dungeon
use (`runtime.rs:3579`) is replaced by `snapshot.now`. Fields of the row
structs reached through `WorldSnapshot` follow in the next compiler
round.
- Methods that become `pub` (36, from probe 3, by definition site):
`WorldMailbox::{open, create, submit_principal, submit_clock,
agency_graph, operator_log, snapshot}` (`mailbox.rs:93,105,226,355,448,
461,474`), `ConsumerPort::new` (`:552`), `SeedPort::new` (`:594`),
`ElaborationPort::new` (`:660`), `ControllerPort::{new, snapshot,
submit_controller}` (`:692,696,721`), `ControllerRunner::{open,
elaborator, seeder, custody_probe, run_narrative, run_operational,
run_cell}` (`controllers.rs:2691,2712,2728,2744,2748,2838,3057`),
the receipt/pending accessors at `controllers.rs:2397,2413,2421,2453,
2457,2461,2465,2469,2515,2519,2523,2543,2591,2595` (`persona_turn`,
`re_lowering`, `into_parts`, `subject`, `bound_scope_digest`,
`fresh_scope_digest`, `gap`, `mode`, `reason`, `persona_prose`),
`sweep` (`elaboration.rs:607,1746`), `name` (`elaboration.rs:1511`),
`Cover::{singletons, groups, validated}` (`cover.rs:163,170,198`),
`ConsumerRegistry::{empty, from_secret_file}` (`consumer.rs:168,176`),
`TickMinutes::{new, minutes}` (`clock.rs:45,51`), `CommandId::new`
(`mod.rs:122`), `PrincipalId::new` (`mod.rs:239`), `Statement::new`
(`patch.rs:281`). Under Q1-7 A add `PreparedInference::prepare`
(renamed from `prepare_invocation`, `controllers.rs:388`),
`InferenceOutput::new`, `InferenceFault::{retryable, recovery_required,
integrity_violation}`, `InferenceRequest::provider_model` (`:120`), and
`TracingInferencePort::new` (`:285`).
- `crates/ghostlight/src/lib.rs` crate docs: the ten `compile_fail`
doc-tests from probe 5, each pinned to its error code.
- `crates/ghostlight-dungeon/src/runtime.rs` (tests): a ~25-line copy of
`soul_no_credential_name_appears_in_the_ports_own_source` scanning
`runtime.rs` only, because the library test cannot read another
crate's source (see per-file `sdk_inference.rs:1824-1828`).
- `crates/ghostlight-dungeon/src/app_session.rs` (tests): one test
asserting `VerifiedPrincipalEvidence::new(` occurs exactly once in the
crate's production source, at `account_for_cookie` (same shape as the
`from_secret_file` count at `sdk_inference.rs:1860`). This is the
single-minter rule the deleted private fields used to enforce (Q1-3).
- **Cross-boundary test classification (Q1-4).** Every Dungeon test that
reaches an item which is `#[cfg(test)]` in the library today. Method:
probe 5's four compile errors plus a read of each test body.
| # | File:line | Test | Exercises | Reaches | Class |
|---|---|---|---|---|---|
| 1 | `runtime.rs:3270` | `the_tick_driver_never_exceeds_its_controller_permit_pool` | `run_cover_tick` and `state.controller_permits`: two singleton cells, pool of one, no overlapping `infer` | `fixture_prepared_inference`, `fixture_inference_output` (via `CountingInferencePort`, `:3241`); `AlwaysFreshWorkStore` (`:3194`) uses only `pub` enum variants | Dungeon runtime |
| 2 | `runtime.rs:3337` | `quarantine_raised_mid_tick_stops_the_sibling_cell_and_every_later_tick` | the driver's `controller_quarantined` flag across sibling cell and next tick | `fixture_prepared_inference`, `InferenceFault::fixture_integrity_violation` (`QuarantiningInferencePort`, `:3308`) | Dungeon runtime |
| 3 | `runtime.rs:3500` | `a_second_mid_turn_change_reaches_the_drivers_interrupted_arm` | the driver's `Interrupted` arm reached through `run_cover_tick`, commits through `ControllerPort::submit_controller` (production) | `fixture_prepared_inference`, `fixture_inference_output`, `fixture_inference_events`, `InferenceFault::fixture_recovery_required`, `InferenceEvent::ToolCall` (pub variant), `PreparedInference.purpose` (`InterruptingCoverPort`, `:3432`) | Dungeon runtime |
| 4 | `runtime.rs:3567` | `drive_one_tick_runs_every_cell_before_the_clock_and_all_share_one_tick` | `drive_one_tick` ordering: every cell before the clock, all cells carry the cover's tick | `fixture_controller_opportunities` (the only caller of the sealed `WorldId/SubjectId/ControllerId::issue()` and `ScopeDigest::fixture` from outside `world/`), `FictionalMinutes(u64)`, `AgencyGraph::default`, `Cell`, `TickIndex`, `CommitReceipt` literal (`controller_commit`, `:2492`) | Dungeon runtime |
| 5 | `runtime.rs:3697` (ignored) | `live_smoke_seeds_then_ticks_a_world_against_the_connector` | the road test: seed from a real Vault, activate, tick against a real connector; `GHOSTLIGHT_SMOKE_TRACE` writes the whole membrane (`notes/local-live-smoke.md:214-246`) | `TracingInferencePort` (`fixture_with`, `:2581-2582`) | Dungeon runtime |
Counts: 5 tests reach test-only library items; 0 are kernel tests; 5 are
Dungeon runtime tests. Two other Dungeon tests touch the boundary but
need nothing test-only: `runtime.rs:2502`
`no_proposal_projection_carries_the_canonical_world_commit` builds a
`CommitReceipt` literal (four `pub` fields, in the boundary list) and
`runtime.rs:4293` `soul_verified_evidence_outlives_the_session_that_minted_it`
uses `VerifiedPrincipalEvidence::fixture` → `::new` (Q1-3). Every other
Dungeon test (42) uses production ingress only.
Consequence: the ruling's "move the tests into the library" has no test
to move. Tests 1–4 exercise `run_cover_tick`, `drive_one_tick`, the permit
pool, and quarantine — Dungeon's tick driver — and test 5 is Dungeon's
road test; moving any of them would move the driver. What they need
divides into two kinds, and each is an operator question below rather
than a mechanism this map invents:
- tests 1, 2, 3, 5 need to construct **port values** (`PreparedInference`,
`InferenceOutput`, `InferenceFault`) and a port decorator; none of
these is a sealed item under invariant 1 (mutable state, ID allocator,
reducer, journal, authenticated-caller constructor) — Q1-7;
- test 4 needs a **`Cover`** to walk, and today gets it from fabricated
opportunities minted with sealed IDs — Q1-8.
Expected post-move counts, under Q1-7 A and Q1-8 A: library 420 unit
(the base `world::` list, prefix stripped) + 10 doc; Dungeon bin 49
(the same 49 names; tests 1–4 rewritten in place, test 5 unchanged);
`build_provenance` 1; persona-projection 13; ignored 1 + 1. Under Q1-7 B
tests 1–3 and the `trace` half of test 5 are deleted (Dungeon 46, or 45
if test 4 goes under Q1-8 B); the library count is unchanged either way.
- **Per-file changes** (against `d83534c` = `02bbf62` source):
- `Cargo.toml:3-5`: add member.
- `crates/ghostlight-dungeon/Cargo.toml:27`: replace with the library
path dep.
- `crates/ghostlight-dungeon/src/main.rs:7`: delete.
- `crates/ghostlight-dungeon/src/runtime.rs:3-22`: split the `use crate::{}`
block; `world::{…}` becomes `use ghostlight::{…}` (probe edit); `:4`:
import `VerifiedPrincipalEvidence` from `ghostlight`, not `app_session`.
`:518,519,535,1084,1088,1092,1201,1325,1621,2492,2493,2581,2582,3081,
3501,3568,3658,3666,3675,3677,3777,3782,4267`: `crate::world::` →
`ghostlight::`. `:4313`: `::fixture(` → `::new(`. `:3081-3086`: drop
the three `fixture_inference_*` names. `:3243,3310,3434`:
`PreparedInference::prepare("ghostlight-controller-test",
4_102_444_800_000, request)` (Q1-7 A; the two literals are the ones
`fixture_prepared_inference` hard-codes at `controllers.rs:381`).
`:3255,3442,3447`: `InferenceOutput::new(vec![InferenceEvent::Text(..)],
"sha256:<receipt>")`; `:3457`: `InferenceOutput::new(events, ..)`;
`:3318`: `InferenceFault::integrity_violation(..)`; `:3479`:
`InferenceFault::recovery_required(..)`. `:3567-3644` (Q1-8 A): replace
`fixture_controller_opportunities` + `FictionalMinutes(..)` with
`fixture().await`, `active_two_cell_world`, `snapshot()`, and
`agency_graph()`, then `derive_cover(snapshot.world_id, snapshot.now,
CLOCK_TICK_MINUTES, &snapshot.opportunities, &graph, budget)`; the
cell count assertion becomes 2 (a genesis world has two
controller-bearing subjects — `:3089-3095` already says this is the
most `world.create` can produce); the ordering and shared-tick
assertions are unchanged. `:504,2673` doc comments name
`world::consumer`; reword to the crate.
- `crates/ghostlight-dungeon/src/eve.rs:3-6` and `:182,335,338,354,386,
405,449,494`: same rewrite.
- `crates/ghostlight-dungeon/src/mesh.rs:6-9`: same.
- `crates/ghostlight-dungeon/src/idunn_health.rs:8`: same.
- `crates/ghostlight-dungeon/src/app_session.rs:54-91`: delete; `:6`: add
`use ghostlight::VerifiedPrincipalEvidence;`; `:232-235`: struct literal
→ `VerifiedPrincipalEvidence::new(session.account_subject_hash.clone(),
session.access_expires_at)`.
- `crates/ghostlight/src/controllers.rs:221-237,372-382,409-432` (Q1-7 A):
delete the fixtures; `:388` `prepare_invocation` → `pub fn
PreparedInference::prepare` (keep the body; the two in-library callers
at the connector and SDK ports follow); `:150` add `pub fn new(events,
receipt_digest)`; `:192,206` `new` → `pub fn retryable`,
`integrity_violation` → `pub`, add `pub fn recovery_required(detail)`
(the `RecoveryRequired` disposition has a private `new` today; make it
the named constructor); `:277-370` drop `#[cfg(test)]` from
`TracingInferencePort` and make it and `new` `pub`.
- `crates/ghostlight/src/sdk_inference.rs:1824-1828`: the scanned file
list becomes `["sdk_inference.rs", "controllers.rs"]` (paths are now
crate-root relative); `:1860` `sources[1]` still indexes
`controllers.rs`. The `runtime.rs` half moves to Dungeon (Adds).
- `deployment/idunn/recipe.toml:14-17`: `test-world-owner` becomes two
steps — `test-world-library`: `["cargo","test","--locked","-p","ghostlight"]`
(runs the 420 unit tests and the 10 doc-tests) and `test-dungeon`:
the existing `-p ghostlight-dungeon --bin ghostlight-dungeon` line.
`:51-53`: acceptance argv becomes `-p ghostlight --lib
controllers::tests::real_codex_connector_cognition_modes_commit_speech`
(the `world::` prefix is gone; `--exact` still matches).
- `package.json:9` (`dungeon:run`): unchanged; `:7-8` use `--workspace`
and pick the crate up.
- `notes/local-live-smoke.md:64`: unchanged; the ignored smoke stays in
`runtime.rs:3697`.
- Docs that name the old path (owner in brackets): `README.md:165-166`
[Hands] add the library line; `docs/public-architecture/index.md:46`
[Hands]; `docs/architecture/ghostlight-session-zero.md:21-23` [Hands]
rewrite "Source does not yet match" as the current rule;
`docs/architecture/ghostlight-world-ontology.md:939-941` [Hands] the
build-budget sentence now names `crates/ghostlight`;
`notes/ghostlight-current-system-map.md:18,28,289` [Mind steward];
`notes/fresh-workspace-handoff.md:284-286` [steward];
`state/map.yaml:57,389-390` [steward].
- **Authority map:**
- Owner: the library crate `ghostlight` owns the world kernel, its
ontology, reducer, journal, mailbox, controllers, elaboration, cover,
clock, consumer admission, inference ports (connector, SDK sidecar,
and the tracing decorator), and the markdown evidence source. Its
public surface is the list under Adds and nothing else.
- Inputs: `VerifiedPrincipalEvidence` values Dungeon constructs from
`AppSessionOwner::account_for_cookie`; `CreateWorldIntent`,
`PrincipalCommandIntent`, `DecisionInvocation`; consumer patch bytes via
`admit_document`; store paths via `WorldMailbox::open` and
`open_controller_work`; connector and SDK bindings via `open_inference`;
a consumer-implemented `InferencePort`/`ControllerWorkStore` when the
consumer supplies its own (Dungeon's tests do; production uses the
library's).
- Outputs: immutable `WorldSnapshot`, `OperatorEvent`, `AgencyGraph`,
typed receipts (`SubmitReceipt`, `CommitReceipt`, `CreationReceipt`
through `MailboxError`/`KernelError`), controller runs, cover
partitions, encoded consumer receipts.
- Derived state: Dungeon's Eve projection, mesh documents, Idunn health
(which reads `STATE_SCHEMA` and `state_schema_compatibility_tag` and
derives, never restates), HTTP responses.
- Forbidden writers: any path in Dungeon naming `WorldKernel`,
`WorldState`, `reduce`, `apply_effect`, `CommandEnvelope`,
`AuthenticatedCaller`, the journal, an `issue()`, or
`ScopeDigest::fixture` — enforced by visibility with no feature that
can lift it, proven by the doc-tests under `--all-features`. Any second
minter of `VerifiedPrincipalEvidence` — enforced by the Dungeon count
test. Any fixture-shaped `DecisionOpportunity` — there is no
constructor for one outside the reducer.
- Shared paths: Dungeon production and Dungeon tests reach the kernel
through the same `pub` items; a Dungeon test port constructs port
values through the same constructors the library's own ports use
(Q1-7 A). Library-internal tests keep `pub(crate)`/`super::` access as
today. Consumers other than Dungeon: none yet; Epiphany consumes only
`ghostlight-persona-projection`, which is unchanged (Q1-5).
- Deletion line: `mod world;`, the nine unused re-exports, the
`#[cfg(test)]` re-export block, the `fixture_*` functions (A) and
Dungeon's private principal struct are gone before the first `pub` is
written.
- **Verification** (Windows workstation; proves Windows only):
- builds: `cargo check -p ghostlight` (no new warnings; the 646
dead-code warnings of an under-opened boundary must be 0),
`cargo check -p ghostlight-dungeon --bin ghostlight-dungeon`,
`cargo check -p ghostlight-dungeon --tests`,
`cargo build --locked -p ghostlight-dungeon --bin ghostlight-dungeon`
(proves `Cargo.lock`). Expected target-dir delta: one extra lib crate
in debug; the scratch probe's check-only target was 2.3 GiB from empty,
so expect well under 1 GiB incremental on the repo's 7.5 GiB.
- tests: `cargo test -p ghostlight` = 420 unit + 10 doc (the 420 names
equal the base `world::` list with the prefix stripped);
`cargo test -p ghostlight-dungeon --bin ghostlight-dungeon` = 49
(same names as base);
`cargo test -p ghostlight-dungeon --test build_provenance` = 1;
`cargo test -p ghostlight-persona-projection` = 13. Ignored counts
unchanged (1 + 1). The reopen test from Cut 0 pins invariant 2
(`world.cc` written at base reopens with equal revision and digests).
The doc-tests pin invariant 1. The `VerifiedPrincipalEvidence::new`
count pins the single minter. The split credential-name test pins
"no credential name in the port's source" on both sides. Tests 1–4
keep their names and assertions (test 4's cell count is 2).
- negative:
`cargo test -p ghostlight --all-features --doc` = 10 passed (same as
without; the command is the standing check that no later feature
reopens the seal);
`rg -n '\[features\]' crates/ghostlight/Cargo.toml` = 0;
`rg -n 'cfg\(any\(test' crates/ghostlight/src` = 0 (no feature-gated
test path exists);
`rg -n 'fixture_' crates/ghostlight-dungeon/src` = 0 outside the
Heimdall `::fixture(` sites (`app_session.rs:560`, `runtime.rs:2552,
2611,4126` — use `rg -n 'fixture_[a-z]' …` which they do not match);
`rg -n 'crate::world::' crates` = 0;
`rg -n '^mod world;' crates` = 0;
`rg -n 'app_session|heimdall|eve::|idunn_health|axum|tower_http|jsonwebtoken|\bDungeon\b|Session Zero' crates/ghostlight/src` = 0
(collisions checked: `Dungeons` in `vault.rs` is excluded by `\b`;
do not add `player`);
`rg -n 'pub\(super\)' crates/ghostlight/src/lib.rs` = 0;
`rg -n 'VerifiedPrincipalEvidence::new\(' crates/ghostlight-dungeon/src`
= 1 outside `#[cfg(test)]`;
`cargo tree -p ghostlight` names no `axum`, `tower-http`,
`jsonwebtoken`, `cultnet-rs`, `cultmesh-rs`, `reqwest`, `aes-gcm`,
`hmac`, `rand`, `base64`, `libc`;
`git diff -M --stat 02bbf62..HEAD -- crates` shows 13 renames.
- operator: Idunn recipe run on Yggdrasil (Linux proof): the
`test-world-library`, `test-dungeon`, `build-daemon` steps and the
acceptance step resolve; `/srv/…/ghostlight-dungeon --state-root`
reopens the live `world.cc`; health reports `consumer-v4`. One
`GHOSTLIGHT_SMOKE_TRACE` road run after the cut shows the membrane
trace still writes (test 5).
- **Operator questions:**
- Q1-7 Port value constructors for consumer-implemented ports. Tests 1,
2, 3 and 5 implement `InferencePort` outside the library and must
return `PreparedInference`, `InferenceOutput`, `InferenceFault`; today
they do it through `#[cfg(test)]` `fixture_*` wrappers that exist only
because those types have private constructors. A: make the
constructors the port contract — `PreparedInference::prepare(caller_
runtime_id, expires_at_unix_ms, request)` (rename of the existing
`prepare_invocation`, "the one place a `PreparedInference` is built",
which every in-library port already calls), `InferenceOutput::new(events,
receipt_digest)`, `InferenceFault::{retryable, recovery_required,
integrity_violation}`, `InferenceRequest::provider_model`; delete the
five `fixture_*` items; make `TracingInferencePort` an ungated `pub`
decorator (it wraps any port and constructs nothing sealed; "production
never constructs it" becomes a Dungeon rule, which is where it was
enforced anyway — only the ignored smoke names it). B: keep every
fixture `#[cfg(test)]` in the library and delete tests 1–3 and the
`trace` field of `LiveController` (`runtime.rs:2538,3726`) from
Dungeon; the permit-pool, quarantine, and interrupted-arm invariants of
the tick driver lose their proofs and the road test loses its membrane
trace. **Recommended: A.** A public trait whose methods must return a
type nobody outside the crate can construct is not a port; it is a
trait only the library can implement. Invariant 1 seals the kernel,
not the inference transport, and none of these constructors mints an
ID, mutates state, reduces, or writes the journal. A is a net
deletion (about 85 fixture lines out, about 25 constructor lines in,
of which `prepare` is a rename).
- Q1-8 The cover for `drive_one_tick_runs_every_cell_before_the_clock_and_all_share_one_tick`.
A: derive it from a real world through production ingress — the
module's own `fixture()`/`active_two_cell_world`, then `snapshot()` and
`agency_graph()` into `derive_cover`; delete
`fixture_controller_opportunities` and the `FictionalMinutes(u64)`
construction; the test asserts two cells instead of three, and the
ordering and shared-tick assertions are unchanged. B: delete the test.
C: keep `fixture_controller_opportunities` `#[cfg(test)]` in the
library and move the test there — not available, `drive_one_tick` is
Dungeon. **Recommended: A.** It removes the last caller of the sealed
constructors from outside `world/`, uses only what tests 1–3 already
use, and the test's own doc (`:3558-3565`) is about ordering, not
about three.
## Cut 2. Build and deploy references in gamecult-ops
- **Repo/branch:** gamecult-ops, current default branch; one commit after
Cut 1 is pushed.
- **First:** `git -C F:\Projects\gamecult-ops status` clean;
`bash scripts/test-ghostlight-yggdrasil-wiring.sh` passes at base.
- **Deletes first:** none (Q1-6: retirement belongs to the deployment
gate).
- **Keeps and moves:** none.
- **Adds:** none.
- **Per-file changes:**
- `runbooks/ghostlight-dungeon-yggdrasil.md:249-251`: insert
`cargo test --locked -p ghostlight` before the Dungeon test line;
`:257` "one explicit Rust binary" stays true.
- `scripts/deploy-ghostlight-yggdrasil.sh:964-970`: add the library test
invocation in the same container run; `:1308`: acceptance becomes
`-p ghostlight --lib controllers::tests::real_codex_connector_cognition_modes_commit_speech`.
- `scripts/test-ghostlight-yggdrasil-wiring.sh:276-278`: add
`require_text "${deploy}" 'cargo test --locked -p ghostlight'`.
- `idunn/yggdrasil/bindings/ghostlight.toml.in`: unchanged; it names the
recipe path, and the recipe carries the commands.
- The scripts remain, per the Ghostlight handoff (`:389-391`), helpers
not to invoke: they fail at the root/Idunn Git boundary and hold
duplicate deployment authority. Editing them keeps their command list
from lying about the crate graph until the deployment gate retires
them; it is not an endorsement to run them.
- **Authority map:**
- Owner: `deployment/idunn/recipe.toml` in Ghostlight owns the build and
test commands (binding `:11 recipe_path`). The runbook describes; the
scripts are a second, dormant list pinned to the recipe's strings by
the wiring test until the deployment gate removes them.
- Inputs: the admitted ref and the recipe.
- Outputs: the same one binary plus `web/`.
- Derived state: runbook text, wiring-test assertions.
- Forbidden writers: no second list of cargo commands that drifts from
the recipe; the wiring test's `require_text` lines are that check.
- Shared paths: n/a.
- Deletion line: none in this cut.
- **Verification:**
- builds: none (shell and Markdown).
- tests: `bash scripts/test-ghostlight-yggdrasil-wiring.sh` passes with
the new `require_text`.
- negative: `rg -n 'ghostlight-dungeon --bin ghostlight-dungeon' F:\Projects\gamecult-ops --glob '!docs/**'`
lists only lines that also sit beside a `-p ghostlight` line.
- operator: the next Idunn deploy of a post-Cut-1 commit runs the
library step.
- **Operator questions:** none; Q2-1 was Q1-6 and is ruled.
## Subtraction ledger (estimate)
| Cut | Removed | Added | Deps / formats / targets |
|---|---|---|---|
| 0 | — | — | scratch captures only |
| 1 | `mod world;` (1), 9 unused re-exports (~3 lines), `cfg(test)` re-export block (12), Dungeon principal struct (38), Dungeon's direct persona-projection dep (1), `fixture_controller_opportunities` (26, Q1-8 A), inference fixtures (~60, Q1-7 A); ~140 lines | lib manifest (~28), workspace member (1), Dungeon dep (1), lock entry (~20), principal type in lib (~30), `pub use` boundary (~25), doc-tests (~30), runtime credential test (~25), minter count test (~15), port constructors (~25, Q1-7 A), recipe step (~6); ~205 lines. Visibility edits: 94 `pub(super)`→`pub(crate)`, ~150 `pub(crate)`→`pub` (items, fields, methods), 199 `crate::world::`→`crate::`, 32 `crate::world::`→`ghostlight::`; test 4 rewritten in place (~20 lines) | +1 lib crate, 0 features, 0 binaries, 0 new packages in `Cargo.lock`; 13 files renamed (~52k lines) |
| 2 | — | ~5 lines | 0 |
Net for L0: about +65 source lines, one crate, no feature. The growth is
boundary declaration (an implicit `pub(crate)` becomes an explicit `pub`
list plus its compile-fail proofs); the liability it retires is the
convention "Dungeon does not touch kernel internals", which becomes a
compile error with no feature that can lift it, and five fixture wrappers
that existed only to work around private port constructors.
Actual, reconciled 2026-09-16 against `6ddabca` (Ghostlight) and `647e57e`
(gamecult-ops):
| Area | Estimate | Actual |
|---|---|---|
| Crate sources incl. in-module tests | about +65 net | +1202 / −992 (+210 net) |
| Integration tests | not planned | +479 |
| Manifests and lock | about +50 | +50 / −2 |
| Ghostlight recipe | about +6 | +10 / −5 |
| gamecult-ops | about +5 | +61 / −9 |
The miss is explained, not hidden. The single-minter scanner grew about 110
lines chasing Soul's evasions and remains evadable (S4). The boundary needed
roughly 90 more public items than this map enumerated, each forced by a
compile error or a `private_interfaces` warning. The integration tests buy
ruling Q1-9, which postdates the estimate. Features and binaries added:
none, as estimated.
## Findings not assigned to a cut
- `deployment/idunn/recipe.toml:142-146,169-171` declare the world slot and
provided capability as `ghostlight.world_state.foundation.v0` /
`foundation-v0`, and `:150-154` the controller-work slot as
`ghostlight.controller_work.v3`; source is `consumer.v4`
(`mod.rs:84-85`, tag `consumer-v4`) and `controller_work.v15`
(`controllers.rs:75-76`). Pre-existing drift; L0 changes no schema
string, so it stays.
- `tests/build_provenance.rs` is never run by the recipe or the scripts:
`--bin ghostlight-dungeon` selects the bin's unit tests only. A
`cargo test --locked -p ghostlight-dungeon --test build_provenance` step
would be a recipe change with its own reason.
- `notes/ghostlight-current-system-map.md:18-19` still names the state
owner as `ghostlight.world_state.foundation.v0`; `:28` says "the private
`world::WorldKernel`". Both are stale before this cut; the steward owns
the rewrite.
- `runtime.rs:2492` is a `CommitReceipt` literal in Dungeon tests; it is
why `CommitReceipt`'s four fields are in the boundary at all. Under
Q1-8 A the same literal serves `drive_one_tick`'s fake clock submitter.
- `PreparedInference.invocation` (`controllers.rs:131`,
`CodexTransportInvocation` from `codex-connector`) stays `pub(crate)`. A
consumer port that talks to a real transport would need it; no such
consumer exists in L0, and opening it is a port-contract decision for
the pass that brings one.
- `web/` references nothing in `crates/`; `sidecar/claude-sdk/test/*` is
written by library tests under `GHOSTLIGHT_WRITE_SIDECAR_FIXTURES` and
read otherwise — path depth unchanged, nothing to do.

View file

@ -0,0 +1,172 @@
# Ghostlight library extraction (L0) postmortem
Written 2026-09-16. Evidence: the cut map
(`ghostlight-library-extraction-cut.md`), the target
(`ghostlight-library-extraction.md`), commits in Ghostlight and gamecult-ops,
and the Soul reports recorded in the map.
## Summary
The world kernel moved out of the `ghostlight-dungeon` binary into the
`ghostlight` library crate, and Dungeon became a consumer across a public
boundary. Thirteen files (about 52k lines) moved by rename with no content
change. The boundary is sealed by admission: external code may build a
syntactically valid ID, digest or opportunity, and the kernel rejects every
one it did not issue, proven by rejection tests written as an external crate.
gamecult-ops' build commands run the library tests they would otherwise have
skipped.
State at writing: landed and pushed in both repos, Windows-verified. The Linux
release is Idunn's recipe run on Yggdrasil and has not been exercised; no
Windows result proves it. The operator owes nothing for L0.
## Scope and invariants
The target set seven invariants: sealed kernel at the crate boundary, no
behavior or schema change, no Dungeon concept in the library, dependencies
pointing one way, a working release path, unaffected external pins, and one
new crate with no feature matrix. Excluded: every library capability of plan
step 15 L1–L4, schema renames, the network path for outside consumers, and
deployment cutover.
Invariant 1 changed meaning during the migration (ruling Q1-9). It began as
"sealed items are unreachable from outside" and ended as "the kernel rejects
anything it did not issue", because the first reading was not true of the
code and could not be made true without a design change L0 excluded.
## Timeline
| Cut | Repo, commits | Soul passes | Notable finding |
|---|---|---|---|
| Map | Ghostlight `02bbf62`, `6ddabca` | — | Classifying the fixture tests found no kernel test to move; Q1-7 and Q1-8 followed |
| 1 | Ghostlight `108b691..f859d2c` | 1 | F1: public types deriving `Deserialize` are constructible from outside; the compile_fail tests pin names only |
| 2 | gamecult-ops `6aba281` | 1 | The specified assertion was vacuous (substring match); the acceptance path had an unlisted fourth site |
| 2 fix | gamecult-ops `803f2c7` | 1 | Exact-line pins prove presence, not execution (`if false`, `true \|\|`, heredoc) |
| Q1-9 | Ghostlight `7c1d1e9` | — | Operator ruled unforgeable admission |
| 1 fix | Ghostlight `a8d3377..2865ea7` | 1 | F3's premise was wrong; forgeries differed from real digests in length |
| S batch | Ghostlight `bde05de..af9c95a` | 1 | Case-insensitive and hex-only comparisons survived |
| T batch | Ghostlight `b14507c`, `ab95d78` | 1 | None |
## Structural delta
| Area | Estimate | Actual |
|---|---|---|
| Crate sources, including in-module tests | about +65 net | +1202 / −992, +210 net |
| Integration tests | not planned | +479 (`crates/ghostlight/tests/external_admission.rs`) |
| Manifests and lock | about +50 | +50 / −2 |
| Ghostlight recipe | about +6 | +10 / −5 |
| gamecult-ops | about +5 | +61 / −9 |
The source overrun has two causes. The single-minter scanner in
`crates/ghostlight-dungeon/src/app_session.rs` grew about 110 lines chasing
Soul's evasions, and it is still evadable (S4). The public boundary needed
roughly 90 more items than the map enumerated, each forced by a compile error
or a `private_interfaces` warning. The integration tests were not in the
estimate because Q1-9, which requires them, was ruled after it.
Deleted: `mod world;`, nine unused re-exports, the `cfg(test)` re-export
block, five inference fixtures, `fixture_controller_opportunities`, and
Dungeon's own principal evidence struct. Added: one library crate, a public
`pub use` boundary, port constructors, ten compile_fail doc-tests, the
external admission tests, and two Dungeon soul tests. Parked: nothing.
Features added: none. Binaries added: none.
## What Soul caught
- **Untested rulings.** Ruling Q1-4 said sealed constructors must be
unreachable under every configuration. The code satisfied the letter
(no feature gate) and missed the intent: `Deserialize` is a public
constructor. Ten green compile_fail tests could not see it, and adding an
ID-minting `Default` impl left all ten green. It would have failed as a
false guarantee in any later review that trusted the target doc.
- **Tests pinning spelling, not behavior.** Every text scanner lost to a short
evasion: the minter count to an alias, a subdirectory, a type alias and a
function value; the gamecult-ops wiring test to a two-line deletion that
reattached the pinned line to the wrong container; its fix to control flow
around exact lines. Each would have let a real regression through with a
green check.
- **Forgery shape.** Rejection tests that used short fake digests passed
against a kernel comparing only length, only a prefix, all but the last
character, case-insensitively, or only the hex after the label. Each would
have admitted a digest the kernel never issued.
- **Spec errors.** The map's vacuous `require_text`, its unlisted acceptance
site, its miscount of Dungeon tests, and Soul's own F3 premise (the two
accessors it called uncalled are called) were all wrong, and all were
caught before landing a fix built on them.
- **Unmeasured claims.** "No new package in `Cargo.lock`", "zero `fixture_`
hits", "a guard that never fired", and a 13-warning ceiling nobody could
reproduce.
## Operator corrections
- **Session Zero is Dungeon authority.** The first design placed a player
flow on the kernel. Missing context: no document named which features
belonged to the library and which to a consumer.
- **Focus was too narrow.** Focus as a set of subjects became a typed detail
rule, a function from place to detail level. Missing context: Delvehold's
constraints as a consumer; the design generalized from Dungeon alone.
- **Extract first.** The library capabilities were designed before the crate
they belong to existed.
- **The titled elaborators** had been cut without an operator decision; the
cut conflated a verifier with the lenses it verified.
- **Proportion.** Asked how much Q1-10 mattered, the operator said not at
all. It was a tripwire against the project's own code, and Self should have
defaulted it instead of posing it.
## Incidents
- **A Hands agent returned its private to-do list as its report** while its
own background cargo job held the locks. Cost: one resume round. Rule:
briefs now say to wait for long jobs and never end a turn with a to-do list.
- **A mutation run was invalidated by `git checkout`** reverting uncommitted
fix code. Cost: two mutations rerun. Rule: run mutations against committed
code, or restore with a diff rather than a checkout.
- **Cached no-op builds reported zero warnings.** Cargo replays warnings only
when it rebuilds. Rule: measure warnings from a forced rebuild, and compare
distinct messages by `cmp`.
- **A commit message described code replaced before commit** (`fac90e6`).
Left in history; recorded in the map.
- **A pre-existing `cargo fmt --check` failure** across the library, found
in passing.
## What worked
- Classifying tests before moving them. Q1-4's "move the tests" had nothing
to move, and the classification turned a vague ruling into two concrete
ones.
- Soul building an external scratch crate to break the seal. The serde route
was invisible from inside the crate and to every doc-test.
- Holding map edits until Soul finished, so every Soul pass read a clean tree.
- Having Hands report spec discrepancies instead of redesigning: the vacuous
assertion, the fourth acceptance site, the recovery_required collision.
- Parallel Hands in separate repos only when the second did not depend on the
first landing.
## What to change in the pipeline
- **Brief Soul to attack constructibility, not names, on any sealed boundary.**
Evidence: F1 on Cut 1, found only because the root agent added the serde
route to the brief by hand.
- **Treat a text scanner as a tripwire with stated limits, never as proof of a
semantic property.** Prefer a semantic tool (Clippy lints, the type system)
or record the property as unenforced. Evidence: every scanner in L0 lost.
- **Rejection tests must use forgeries shaped like real values.** Evidence:
S1 and the two survivors after it.
- **Default proportionate questions instead of asking.** A fork that guards
only against the project's own code is a default with a recorded follow-up.
Evidence: Q1-10.
## Open follow-ups
| Item | Owner | Where | Why it can wait |
|---|---|---|---|
| S4 minter scanner evadable; Clippy `disallowed-methods` recorded as the fix | Dungeon | `crates/ghostlight-dungeon/src/app_session.rs:523-668` | Guards only against the project's own code |
| S5 credential-name test splits on one literal marker | Dungeon | `crates/ghostlight-dungeon/src/runtime.rs:3584` | Same class as S4, pre-existing |
| S6 stable rustc does not enforce compile_fail error codes | Library | `crates/ghostlight/src/lib.rs:15-57` | Admission is proven by the external tests; codes need a snapshot tool |
| S2 world_id check redundant, S3 decline path unreachable from outside | Library | `lib.rs:4193`, `lib.rs:1603` | Redundant by construction and crate-private respectively |
| `ScopeDigest`, `WorkLane`, `ScopeComponents` public through public enums | Library | `KernelError`, `ControllerWorkCustody`, `NarrativeCheckpoint` | Closing them reshapes public enums, a design change |
| `cargo fmt --check` fails across the library | Library | `crates/ghostlight` | Pre-existing; formatting only |
| Recipe schema drift (`foundation.v0`, `controller_work.v3` against `consumer.v4`, `controller_work.v15`) | Deployment gate | `deployment/idunn/recipe.toml` | Changes no behavior in L0 |
| `build_provenance` not run by the recipe | Deployment gate | `deployment/idunn/recipe.toml` | A recipe change with its own reason |
| Redeploy of a sealed commit runs no tests; exact-line pins cannot see control flow; `set -e` unpinned; duplicate blocks pass; web container and Connector steps pinned by substring; 18-line option window | Deployment gate | `gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`, `test-ghostlight-yggdrasil-wiring.sh` | Legacy actuator under a do-not-invoke warning, retired at the gate |
| Linux release unverified | Operator, via Idunn | Yggdrasil | Needs the deployment gate |

View file

@ -0,0 +1,84 @@
# Ghostlight library extraction (plan step 15, L0)
Status: closed 2026-09-16. Landed in Ghostlight `108b691..ab95d78` and
gamecult-ops `6aba281..803f2c7`; the means and every Soul verdict are in
`ghostlight-library-extraction-cut.md`, the scars in
`ghostlight-library-extraction-postmortem.md`.
Reconciled with the Body. Invariant 1 holds as restated by Q1-9 and is
proven by `crates/ghostlight/tests/external_admission.rs`; the compile_fail
doc-tests prove "does not compile", not privacy, because stable rustc does
not enforce their error codes. Invariants 2, 4, 6 and 7 hold as written.
Invariant 3 holds for library source and tests: no Eve, Heimdall, AppSession
or player vocabulary remains. Invariant 5 holds on Windows; the Linux release
is unexercised until Idunn builds it. The open questions below were all
ruled (Q1-1 through Q1-10 in the cut map) and are kept as history.
## Target
The Ghostlight library is a crate of its own. It owns the world kernel and
everything consumer-neutral around it: the ontology and reducer, the journal,
the mailbox, controllers and the Projector-Persona-Interpreter membrane,
elaboration and seeding, the cover, the clock, inference ports, evidence
sources, and consumer patch admission. Ghostlight Dungeon is a crate that
consumes it: the daemon binary, HTTP routes, Eve projection, Heimdall and app
sessions, CultMesh publication, and Idunn health.
After the cut, "Dungeon cannot reach kernel internals" is a compile error, not
a `pub(crate)` convention.
## Invariants that must survive
1. **Sealed kernel at the crate boundary.** The library's public API exposes
create/open, immutable snapshots, command submission, typed receipts, and
the runner and port entry points Dungeon needs. It exposes no mutable
`WorldState`, canonical ID allocator, reducer entry, journal writer, or
authenticated-caller constructor. Negative proofs are compile-fail tests
from outside the library.
Sealing means **unforgeable admission**, not unconstructible values
(operator ruling Q1-9, 2026-09-16). Public ID types, `ScopeDigest` and
`DecisionOpportunity` derive `Deserialize` because Dungeon's own Eve
payloads carry them, so any caller can build a syntactically valid one.
The kernel must reject every one it did not issue, and rejection tests
written from outside the crate are the proof. A compile-fail test pins a
path name and cannot see constructibility: adding a `Default` impl that
mints an ID left all ten green.
2. **No behavior change.** Every existing test passes unchanged in meaning.
No schema string, command id namespace, digest preimage, or store layout
changes: a `world.cc` and controller work store written before the cut open
and replay after it.
3. **No Dungeon concept in the library.** No Dungeon type, route, Eve or
Heimdall type, HTTP framework, or principal format enters the library. The
principal evidence the kernel needs crosses as a library-owned type that
Dungeon constructs from its own sessions.
4. **Dependencies follow ownership.** The library does not depend on axum,
tower-http, jsonwebtoken, or the Heimdall and Eve surfaces. Dungeon depends
on the library, never the reverse.
5. **The release path keeps working.** The `ghostlight-dungeon` binary name,
its `--state-root` contract, and the Idunn recipe's build and test
invocations still resolve, or are updated in the same cut with their owners.
6. **Existing pins are unaffected.** Epiphany's pin of
`ghostlight-persona-projection` by revision still resolves.
7. **Build fan-out stays bounded.** One new library crate, no new binary, no
new feature matrix beyond what test support strictly needs.
## Out of scope
- Every library capability of plan step 15 L1–L4: lenses, detail rules,
evidence binding, Draft sessions. L0 moves code; it adds no behavior.
- Renaming schemas, stores, or wire documents.
- Changing how external consumers reach the library over the network. The
consumer patch HTTP route stays in Dungeon for now.
- Deployment cutover (the deployment gate).
## Open questions for Imagination to bring back
- The library crate's name.
- Whether `vault.rs` (markdown evidence source) and `sdk_inference.rs` (Claude
SDK port) belong in the library, as consumer-neutral adapters, or in Dungeon.
- The seam for principal evidence (`app_session::VerifiedPrincipalEvidence`).
- How test fixtures that span the boundary are shared without making sealed
constructors public.
- Whether `ghostlight-persona-projection` stays a separate crate or folds into
the library.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,236 @@
# Play agent postmortem
Written 2026-09-23 from the cut map, the commits, the Soul reports and an Eyes
pass over all three. The cut map
(`docs/architecture/ghostlight-play-agent-cut.md`) remains the detailed record:
every cut's status, findings PA.f1 to PA.f195, and every ruling.
## Summary
Ghostlight Dungeon now runs on one operational agent. It reads the world through
an omniscient table view, calls kernel tools directly as the Play authority,
dispatches characters for their own narrative turns, asks the player questions,
and closes each turn with narration. `world.speak` and the story card are gone;
`world.play` and a single player card replace them.
Seventeen cuts and about ten fix batches landed over two days. 195 findings were
recorded. The kernel gained a Play authority, ruled facts, minting, retirement,
affordance grants and a callable Persona turn. A loopback-only local inference
port landed so the whole thing can run against a small model on the operator's
own machine.
State at writing: the loop is built and verified as far as tests and a falsifier
reach. **Nobody has played it, and no model has ever run behind the local port.**
The seed path has never executed past its configuration check. All evidence is
Windows; the release evidence is Idunn's Linux gate on Yggdrasil.
Two capabilities the design promises do not exist: fiction-first risk resolution
and gestalt agency. They are named in *Open follow-ups* and carried into the
target doc as gaps rather than promises.
## Scope and invariants
The pivot: the previous machine was built for offline narrative generation and
world simulation, which is the wrong set of compromises for play. The Persona
sandwich stays, because actors should think narratively; everything else — turn
dispatch, interpretation, prompting the player, negotiating the result — becomes
one operational agent's job.
Eight invariants scoped the work. The kernel is the only writer and no
transcript crosses turns; Personas never see structured state, and the agent
never rewrites an existing character; an actor acts only through its own
affordance; speech is verbatim, checked against that actor's own prose; there is
one play authority; removal is retirement; one loop serves every backend; and
the player sees a projection, the question, and the refusal of their own act,
and nothing else.
Explicitly out of scope: the offline simulation lanes, which keep total
interpretation; Njordr's evidence invariants, which the operator ruled do not
bind the play agent ("if the agent can invent people, it can invent loot"); and
the world compiler.
## Timeline
| Cut | What | Commits | Notable finding |
|---|---|---|---|
| 1 | Delete the autonomous drivers and owner controls | `d69e9d4` | −2,184 lines. A re-added tick loop passed 46 of 46 tests |
| 2 | The local inference port | `182f803` + fixes | `HTTP_PROXY` sent the whole request, and a credential, to a proxy (PA.f16) |
| 3 | Play authority, ruled facts, minting | `4c4973b` | The genesis lane never ran `require_ruler` (PA.f26) |
| 4 | Retirement | `3725ba8` | A retired subject was still an audience and a raw speaker (PA.f33) |
| 5 | Grant and revoke affordances | `732b1d3` | Two loosenings survived every test (PA.f30, PA.f35) |
| 6 | The Persona lane and the recency marker | `c4334cc` | The spec's own stated rationale was impossible; Hands corrected the spec |
| 7 | The table vocabulary and inference seams | `7d40393` | `actor_tools` emitted names `decode_actor_call` refused (PA.f55) |
| 8a | The play table and the turn loop | `6b69270` | `communicate` let the agent make any actor "tell" an invented fact (PA.f61) |
| 8a-fix2 | | `74b628d` | Every mutation reported killed; eight rules unguarded |
| 8a-fix3 | | `754c7fe` | The id spelling had four copies; changing the printer passed 735 tests and broke every dispatch (PA.f113) |
| 8a-fix4 | | seven commits | Not ready for 8b: a crash window stranded a dispatched Persona (PA.f125) |
| 8b | Wiring, routing, the Eve schema | `1b20a0f`, `a99fdfe` | `ask_player` wedged the world: no client could build an answer (PA.f134) |
| 9 | The play surface; `world.speak` deleted | `322bdf6`, `f1732c0` | The real client could not submit anything at all |
| 10 | The client round trip | `d9f0159`, `43b6af6` | `world.create` had no field for `brief`: no client could ever create a world |
| 11 | The answer path and the gate | `4155823` | Two of three bridge tests failed instead of skipping without node |
| 12–13 | The store schema; the answer token | `7052644`, `2f2a8b6` | An answer composed for one question resolved another (PA.f170) |
| 14 | Preflight fixes | `58324f8`, `0808c6a` | The seed refusal handed the player the inference endpoint's URL (PA.f187) |
| 15 | The closing fixes | `f295a3a` … `1dea8f5` | An unwritten invariant, stated and broken in one commit (PA.f193) |
| 16–17 | Closing the bar | `63964b0` … `07aae19` | The runbook's weekly check printed `ok` without running (PA.f194-B) |
## Structural delta
New: `local_inference.rs`, `table.rs` (the consumer-neutral vocabulary),
`play.rs` (the turn loop), `tools/eve_client_bridge.mjs` and its committed
fixture. Two dependencies: `reqwest` for the local port, `unicode-segmentation`
for UAX #29 word boundaries.
Deleted: Cut 1's tick driver, Active elaborator and owner "let X act" path
(−2,184 lines, and ten tests whose only subject was deleted code); Cut 9's
`SpeakPayload`, `world.speak` arm, story card, speak control and
`current_operator_view`.
Tests: the library went 466 → 622 and has been flat since the play loop's third
fix batch — all later churn is Dungeon-side. The Dungeon binary went 56 → 46
(Cut 1's deletion) → 182, plus four client-bridge tests that are `#[ignore]`d
behind an opt-in. `ghostlight-persona-projection` went 13 → 35.
The subtraction budget was met once, at Cut 1, and not thereafter. Every fix
batch was net additive, mostly in tests. Soul audited the largest claim (Cut 13's
"~150 lines") and found 29 lines of production code; the rest was comments. That
is defensible, but the ledger stopped being a pressure after Cut 1 and nobody
noticed until this postmortem.
## What Soul caught
Of roughly 195 findings, about 150 were introduced by this pass's own earlier
steps rather than inherited. That is the honest shape of a foundation change:
each fix is a fresh chance to be wrong, which is why the falsifier never grades
its own work.
**Split authority (~14).** Four copies of one id spelling, because the library's
printer was private to its module (PA.f113). Two computations of the retired set
and of the exercise revision (PA.f82). Two owners for round entry (PA.f127). The
resume path's collision check duplicated verbatim (PA.f118).
**Tests that pinned spelling or passed trivially (~13).** A dependency check
asserting `contains("0")`, always true. A test that failed one run in four and
checked the wrong subject when it passed. Two refusal tests whose expected kind
was a substring of their own fixture's handle.
**Mutations aimed at the wrong layer (~4).** Deleting an in-memory record
instead of the on-disk write it stood for; testing a helper instead of its call
site; every test using a fresh key, so deriving the turn id from the key
survived.
**Inputs built from a copy of production logic (2).** The dispatch tests spelled
their subject id with a fourth copy of the printer, so changing what the world
view actually printed left 735 tests green while every dispatch an agent could
make was refused.
**Forged input accepted (~6).** A forged opportunity, a forged revision, a
deserialized colliding subject id, and a ruled fact riding in through world
creation.
**Player-visible leaks (~10).** The question id rendered as a visible, editable
box. A connection-refused error handing the player the inference endpoint's host
and port. A subject census on a card any authenticated account could see.
**Lifecycle and crash windows (~11).** A fresh request key discarding an open
turn; an empty answer wedging it forever; a spent round budget leaving it
running with nothing said; a crash between persisting a Persona's prose and
recording the call that produced it, which stranded that character for the rest
of the turn.
**Things the real client could never do (~5).** The binding shape did not match
what the client sends, so every command submitted an empty payload — including
`world.create`, which also had no field for a value its own contract required.
Nobody had ever driven the real client end to end.
## Operator corrections
Eight questions were ruled by the operator directly, and one correction changed
a design mid-pass. Self had suggested updating an existing character's attitude
through `set_persona_material`; that collapses the no-puppets rule, and the
ruling was reversed so the agent authors new characters and never rewrites an
existing one. The operator's review — *"I saw those calls in your traces, I've
been watching. No objections"* — arrived after the reversal rather than before
it.
The operator also cut a scope error at the root. Self proposed binding the play
agent to the offline lanes' evidence invariants; the ruling was *"if the agent
can invent people, it can invent loot"*, which is what made ruled facts and
minting coherent rather than a hole in the model.
What made the wrong answers look right in both cases: the agent was reasoning
from the library's offline discipline, where those rules are correct, without
noticing that the play path has a different author and a different purpose.
## Incidents
- **The workstation rebooted mid-pipeline** under concurrent cargo builds, mine
among several sessions'. Rule: one cargo process at a time on the workstation,
in every brief, and verification moves to Yggdrasil under Idunn.
- **A Hands agent amended and force-pushed** over its own first push of Cut 3.
Nothing was lost, because it had rebased first. Rule: never amend, never
force-push; it is now in every brief and in the skill.
- **A Soul pass died on an API 529.** Its worktree was cleaned and the pass
rerun.
- **A `git pull --rebase` was refused** while Hands had unstaged work in the
shared tree. Rule: never pull in a tree where Hands is working. (Observed in
session; not recorded in the cut map.)
- **PowerShell map edits failed** on an overload error and on mismatched
anchors. Rule: Python scripts that assert each anchor occurs exactly once and
preserve CRLF. (Observed in session.)
- **A stray `node_modules`** left by an agent's own debugging made a test count
report success for tests that could not run on a clean checkout (PA.f162).
## What worked
- **Quoting the cut inline in the brief** instead of pointing at a 3,900-line
map. The one run that read the whole map reached ~400k tokens and produced the
pass's sloppiest claims.
- **Hands stopping on a real fork.** Cut 9's counter had no honest derivation;
Hands traced why, stopped, and reported. The ruling took one message and the
cut resumed correctly.
- **Soul driving the real client.** Every blocker in the last third of the pass
came from running the vendored browser lowering over the served surface rather
than from reading it.
- **Recording a finding as closed-not-fixed when the mutation proved the code
unreachable.** Hands built a guard, mutated it, found an existing guard already
refused, reverted its own work and kept the test. The mutation decided, not the
diff.
## What to change in the pipeline
Three lessons are already in the Eureka brief and changelog:
1. **Mutate the production call site, at the layer the rule protects** — a later
write can hide a deleted one (`fd01545`).
2. **A test's inputs come from the production path**, never from a helper that
re-spells what production prints (`2e0ea97`).
3. **Name the inference, not the line** (`181e819`). One defect shape ran through
five instances: a committed file exists, therefore the client works;
`git` exited zero, therefore this is a checkout; the format check failed,
therefore the row is unreadable; the schema is unknown, therefore the row
cannot be read; cargo printed `ok`, therefore the fixture is live. Each local
fact true, each inference false, each fix scoped to the reported instance. And
after changing how a test reports, run the owning runbook's commands verbatim
and read what they print: that surface is the one a test suite structurally
cannot check.
Two more this pass earns:
4. **A subtraction ledger that stops being updated stops being pressure.** It
held at Cut 1 and then went unexamined for sixteen cuts.
5. **Soul's closing pass should be briefed for a walk-through, not only a
verdict.** Asking what happens when a person actually sits down and plays
produced the most useful output of the pipeline, including two defects no
invariant covered.
## Open follow-ups
| Item | Where | Why it can wait |
|---|---|---|
| Fiction-first risk resolution | kernel + `play.rs` | The kernel resolves a risky act through an affordance's weighted outcome bands, drawn deterministically. There is no assessment step, no modifiers, and the agent cannot author affordances — so an act no granted verb expresses is authored directly as a ruled consequence with no draw at all. Those two paths are indistinguishable to the player. This is the next pipeline. |
| Gestalt and institution agency | kernel | Institutions exist as structure — offices, grants, jurisdiction — and constrain who may act. Nothing makes them act. Advancing time rolls routines and accumulates pressure; nobody deliberates off-screen. |
| The seed leg | `runtime.rs`, vault | Never executed past its configuration check. `GHOSTLIGHT_SEED_VAULT_ROOT` appears in no runbook. Attempt it alone, first. |
| PA.f194-A's fourth exit | `play.rs` | Closed in Cut 17, listed because its rule now lives on `retire_unreadable_row` as a precondition and every future caller must satisfy it. |
| PA.f195-B, PA.f195-C | `play.rs`, `runtime.rs` | A dead tuple element, and a skip notice cargo swallows without `--nocapture`. Trivial. |
| `state/map.yaml` is not parseable YAML | `state/` | Latent: nothing reads it programmatically. Fixing it is a deliberate sweep, not a drive-by. |
| A read path for a retired row | `play.rs` | The sidecar is JSON with the payload in base64, so an operator can read it. Nothing imports one back. |

View file

@ -0,0 +1,332 @@
# Ghostlight Dungeon play agent (plan step 16)
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
Target, adopted 2026-09-22. Not implemented. The means will be in
`ghostlight-play-agent-cut.md`. This target is the playtest gate. It replaces
library passes L2–L4 and Dungeon passes D1–D3 on the gate's path; those are
deferred by ruling, not dropped (see "Deferred").
## Why
Ghostlight's controller machinery was built for offline narrative generation
and world simulation. It includes the cover partition, grouped cells,
grant-filtered action catalogs per subject, a separate Interpreter pass, and
the elaborator swarm. Those are the wrong compromises for interactive
role-play: today a reply takes minutes, and a player's whole vocabulary is
`world.speak`.
The operator, 2026-09-22:
> the way Ghostlight previously ran is great for offline narrative generation
> and world simulation, but it's the wrong set of compromises for an efficient
> role-playing experience. The Persona stuff is good, we want actors to think
> narratively. All the rest of it can be operated by a single operational
> agent, from dispatching and interpreting Persona turns to prompting the
> player and negotiating the result.
What makes this worth building is where consistency lives. In AI Dungeon it
lives in the context window, so it decays as the story grows. In Ghostlight it
lives in the kernel: typed state, one reducer, affordance preconditions,
outcome bands, and a digest-chained journal. None of that spends a token. A
small local model therefore does only what it is good at, which is judgment
and prose over a bounded context, while the kernel keeps the books.
## Target
A player on one local machine plays a world seeded from a Vault, turn by turn.
The reference machine is Raven's, running Bonsai 2, a Qwen 3.8 quant tuned for
agentic use, with several concurrent slots. One operational agent (the **play
agent**) runs the table: it reads the world, interprets what the player and
the Personas do, commits the consequences through the kernel, dispatches
Personas, and asks the player questions when an outcome needs negotiating.
Personas keep thinking narratively. For each Persona, the existing Projector
renders that subject's own slice of the world into lived prose. The Persona
reads only that prose and writes prose back. Neither the Projector nor the
Persona changes.
### Ownership
- **Dungeon** owns the play agent: its loop, its prompt, its tool catalog, the
turn lifecycle, and the Eve surface. None of it enters the library
vocabulary.
- **The library** gains only consumer-neutral pieces:
- one author capability for a play authority;
- retirement, and granting and revoking affordances;
- a callable Persona turn (Projector then Persona) that does not require
the controller runner;
- the table vocabulary (`table.rs`: the world view, named tool subsets and
their decoders, and refusal text), plus the public inference seams a
consumer needs to build and read a request.
A consumer other than Dungeon could run its own table on them. (Cut map
PA-Q10 and PA-Q12, ruled 2026-09-22.)
- The library keeps everything the play path stops using: cover, grouped
cells, action catalogs, the controller runner, the Interpreter, the elaborator
sweep, and lenses. Offline consumers and simulation still own them.
### One turn
1. The player types. Their text is prose from an actor, exactly as a Persona's
is.
2. The play agent reads a projection of world state rebuilt for this turn. It
interprets the player's prose. Speech commits verbatim through the player
subject's speaking affordance, and acts commit by exercising the player
subject's affordances. The agent may instead **ask the player** something
("what are you offering him?"). A question commits nothing and holds the
turn open.
3. The agent dispatches the Personas who react. They run concurrently, each
through Projector then Persona, reading what they perceived from world
state.
4. The agent interprets each Persona's prose under the same rules as the
player's, and commits its own rulings for anything no actor's affordance
covers.
5. The player sees their projection. The Projector renders the player
subject's own slice, including what they perceived this turn, into prose,
exactly as it does for a Persona. Beside that prose the surface shows any
question the agent is asking.
## Invariants
1. **The kernel is the only writer, and the agent carries no world.** Every
consequence is a library command. The agent's context each turn is built
from world state and the current turn; no transcript is carried across
turns. Test: restart the daemon between two turns, and the second turn's
agent input is byte-identical to the input it would have received without
the restart.
2. **Personas never see structured state or the agent's context.** A Persona's
input is Projector prose. The Projector's input is that subject's own typed
slice (`controllers.rs` `projector_context`). The agent never writes a
Persona's input, so a secret in the agent's context cannot reach a Persona
except through world state that subject holds. The one input every
subject shares is the owner's world brief. It is world-public framing,
and the owner keeps secrets out of it (cut map PA.f45). The agent never
rewrites an existing character's persona material; it authors material
only for characters it declares. A character's feelings follow from what
it perceives (cut map PA.f61).
3. **An actor's act goes through that actor's affordance.** An act attributed
to a subject, whether a Persona or the player, commits only as that
subject's `ExerciseDecision`, with preconditions checked and the outcome
band drawn by the kernel. The agent's raw ops are its own rulings: they are
journaled with the play authority as author and never attributed to an
actor. (Operator ruling, decision 1.)
4. **Speech is verbatim.** Speech committed for an actor is a literal span of
that actor's prose, checked in code, not by prompt. This holds for the
player's typed text and for a Persona's output. It keeps the agent from
paraphrasing every actor into its own voice.
5. **One play authority, and only one thing holds it.** The library defines one
consumer-neutral author capability. Its holder may:
- declare in Active without answering elaboration demand;
- reach the whole world, unconfined to a ground;
- advance time;
- state facts that stand as `Ruled` by it;
- mint quantity without an evidence receipt.
Only the play authority gains the last two. Elaborators, consumers and the
seed keep the evidence gate, so a consumer feeding Njordr keeps quantity
provenance. The operator: "if the agent can invent people, it can invent
loot." (Cut map PA-Q15.)
The elaborator gate is unchanged: elaborator sessions still answer a
boundary or deficit. The agent is Dungeon's detail demand: when the player
walks into the tavern, the agent fills it. (Operator ruling, decision 1.)
6. **Removal is retirement.** A retired subject stays in history, holds no
opportunity, cannot be the actor of a decision, and is never dispatched.
Replay reproduces retirement. A body that matters afterwards is declared as
a resource.
7. **One loop, many backends.** Rust drives every tool round, as it already
does. A local OpenAI-compatible backend returns tool calls inert, as the
connector lane does, and adds no second loop. Routing stays per model
name, so each lane (agent, Projector, Persona, seed) is pointed at a backend
independently.
8. **The player's view is a projection.** The Projector renders the player's
narration from committed state, as it does for every Persona (operator
ruling, open question 11). Nothing the player is shown is world truth
unless it was committed. The surface also shows the kernel's refusal of
the player's own act, rendered from the refusal and the affordance's
preconditions. That is the world's verdict, derived from state, not a
claim of world truth (cut map PA-Q13). The agent's questions are the one
thing shown that is not derived from state.
## Playtest gate
Passes when a human, on a local daemon with every play lane on a local model,
does all of the following:
1. creates a world and seeds it from a Vault;
2. activates it;
3. plays at least these:
- speaks to a Persona and gets a reply in the Persona's own words;
- does something physical that a precondition refuses, and sees why;
- moves to another place;
- is asked a question by the agent and answers it;
- kills or removes a subject, who then never acts again;
4. restarts the daemon mid-session and continues with the world intact.
It proves one player. Hosted play, multiple players, Session Zero's Vault
choice and sliders, and detail rules are not in this gate. Seconds per turn
are **measured and recorded** on Raven's machine. The gate does not set that
number in advance, because nobody has measured Bonsai 2 on this workload yet.
The current baseline is minutes.
## Cut line
Dungeon's play path stops using:
- the owner-only "Let X act" controls and `world.controller.act`;
- automatic tick dispatch through cover cells on the play path;
- the Interpreter pass;
- the per-subject action catalogs;
- elaboration while Active.
Whether these are deleted from Dungeon or merely go unused is for the cut map
to establish, one surface at a time. Deletion is the default where nothing
else consumes the surface.
The library keeps them.
The Claude SDK sidecar stays as the cloud fallback until the local lane has
carried the playtest. It is the only live cloud provider (there is no Codex
subscription and no API budget), so "cloud fallback" means the sidecar. The
decision to delete it comes after the gate. This corrects the 2026-09-22 chat
default, which named the connector lane as the fallback.
## Deferred, by ruling (2026-09-22, decision 3)
Deferred until a consumer that still runs the offline machinery needs them:
- L1.f16, configurable lens sets (operator: yes);
- L1.f1, quarantine-class errors never quarantine;
- L1.f13, seed lane orphans rows;
- L1.f14, controller-work rows never retired;
- L2, per-world evidence binding;
- L3, detail rules;
- L4;
- Session Zero D1–D3.
After the gate, decide whether anything still consumes the controller runner,
cover and lenses. If nothing does, park them at a tag.
Exception: if the cut map finds that L1.f13 bites seeding on the playtest
path, it is in scope, because seeding is on the path.
## Open questions for the cut map
Imagination establishes each of these by probing the Body, and returns forks
with a recommendation.
1. **Where the local backend lives.** The shared seam today is the
in-process `InferencePort` trait, not a CultNet connector API. The SDK
sidecar is a private MessagePack child-process pipe that only borrows
`codex_connector` input types (checked 2026-09-22). No local or
OpenAI-compatible transport exists, so the gate has none to use yet.
The options are a third `InferencePort` in `ghostlight` that follows the
connector posture (~150 lines), or a connector behind a CultNet contract
that the operator wants and that does not yet exist. The map records "a
generic OpenAI-compatible connector is required before third parties use
the deployment", so this pass may be that connector. The handoff says
nothing goes into CodexConnector itself.
2. **What a Persona perceived.** Does the Projector's context already carry
what the subject perceived since its last turn (speech heard, acts seen,
`Seen { by }` knowledge)? If not, what is the smallest addition?
3. **A callable Persona turn.** Projector then Persona currently runs inside
the narrative lane of `ControllerRunner`, with checkpointing. What is the
smallest library surface the play agent can call per subject, and does it
keep resumability?
4. **Exercising on an actor's behalf.** `ExerciseDecision` is admitted for the
subject's controller principal. How does the play authority exercise an
affordance for an actor it dispatched without weakening sealed admission,
given that opportunities and digests are kernel-issued?
5. **The play authority's shape.** Name the capability, where it is granted,
and how the clock capability's precedent maps onto it.
6. **Retirement's shape.** Is it a component op, a declaration, or a subject
state? What do preconditions, opportunity derivation and the Projector read?
7. **The agent's catalog.** The kernel's component ops plus light declarations
(not `declare_affordance`), plus exercise-for-actor, dispatch-Persona,
ask-player and end-turn. Measure the strict-schema size, and say whether a
small model plausibly handles it.
8. **The turn lifecycle.** Where does an open turn (a question outstanding, or
Personas in flight) live, and what survives a restart (invariant 1)?
9. **The Eve play surface.** Place, co-presence, perceived speech with speaker,
displays, the agent's question, and the player's input.
10. **The player's subject.** Does the genesis human subject at the commons
serve, with the agent placing it after seeding?
11. **The player's prose.** Ruled 2026-09-22, operator: 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 renders only committed state, which satisfies invariant 8, and
it is the player's narration; the play agent writes none. The cut map
measures the extra serial call rather than deciding on it.
## Verification sketch
The cut map owns exact tests. Each invariant gets a check that fails under its
own mutation.
- **Invariant 1:** restart equivalence of the agent's turn input.
- **Invariant 2:** the Persona's request contains no field of structured state
and none of the agent's context.
- **Invariant 3:** an act for a subject not present is refused, and a raw op
never carries an actor attribution.
- **Invariant 4:** a paraphrased quote is refused.
- **Invariant 5:** an elaborator's declaration in Active without an answer is
still refused, and the play authority's is admitted.
- **Invariant 6:** a retired subject gets no opportunity and no dispatch
across a replay.
- **Invariant 7:** a scripted local backend drives a full tool round through
the existing evaluator.
- **Road:** the playtest gate itself, on Raven's machine.
## Reconciliation, 2026-09-23
The cut map records the means and the postmortem records the cost. This section
says how the Body stands against the target above.
**Landed.** All eight invariants hold, each with a test that fails under its own
mutation, and Soul verified each at the layer where it would fail rather than in
a unit test. One operational agent drives a turn through `world.play`; the
kernel is the only writer; the player's card carries a projection, the question,
and the refusal of their own act. `world.speak` and the story card are deleted.
**Changed from the target as written.**
- An answer names its question through an opaque per-question token carried in
the play button, not through a version comparison. The version machinery that
preceded it is deleted. Two questions in one turn commit nothing between them,
so no counter can tell them apart.
- The player's turn is one row keyed per world, with a request-key ledger across
turns. A replayed key is a no-op; a stale answer and a busy turn are refused
synchronously, before anything is spawned.
- A row the daemon cannot read is retired to a readable JSON sidecar and play
starts fresh, reported in readiness. A row that *can* be read is never
destroyed — that rule is stated on the function that destroys.
- The verbatim-quote check uses Unicode word boundaries (UAX #29), because the
hand-written rule accepted "I can" out of "I can't" and refused most CJK
quotes.
**Not built, and named as gaps rather than promises.**
- **Risk resolution.** The kernel resolves an act through an affordance's
weighted outcome bands, drawn from a digest of the world, revision, command
and affordance — deterministic, so a replay reproduces it. There is no
assessment step, no modifiers, no skill and no roll receipt, and the play
agent cannot author affordances: it may only grant and revoke the catalog's
existing verbs. An act no granted affordance expresses never draws at all; the
agent authors the consequence directly as the Play authority. Those two paths
are indistinguishable from the player's chair, which is the real gap.
- **Gestalt and institution agency.** Institutions exist as structure and
constrain who may act. Nothing makes them act, and there is no strategic tick.
Advancing the clock rolls routines to their next due and accumulates pressure
on overdue obligations and goals; that is the whole of what happens
off-screen.
**Unproven.** The seed path has never run past its configuration check. No model
has run behind the local inference port. All test evidence is Windows; the
release evidence is Idunn's Linux gate on Yggdrasil, where the browser-client
tests cannot run at all, because that gate materialises source without git
metadata.

View file

@ -0,0 +1,122 @@
# Ghostlight Dungeon Session Zero
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
playtest gate is superseded (operator, 2026-09-22) by the play agent,
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes
L2–L4 they consume are deferred by ruling, not dropped. Plan step 15 in
`notes/ghostlight-implementation-plan.md` carries the deferred pass order.
## Ownership
Session Zero is Ghostlight Dungeon's concept and Dungeon's authority. Dungeon
is a consumer of the Ghostlight library. Session Zero consumes library
capabilities through their generic surfaces and adds nothing Dungeon-shaped
to the library: no Vault registry, player, slider, starting location, or
Session Zero phase exists in the library vocabulary.
The library capabilities it needs are specified, consumer-neutral, in
`ghostlight-world-ontology.md` under "Elaboration lenses and detail rules
(target)": the stock lens set and weights, detail rules, per-world evidence
binding, and elaborator sessions in Draft. Other consumers use the same
capabilities with their own policy; Delvehold, for one, starts from a
`Uniform` detail rule.
The world kernel lives in the `ghostlight` library crate
(`crates/ghostlight/`); `ghostlight-dungeon` consumes it across a public
boundary and cannot reach its internals.
## Objective
A player chooses a Vault, says what kind of experience they want, and gets a
world seeded from that Vault. The world is dense where they stand and sketched
where they don't, and it grows detail ahead of them as they travel. The flavor
of the world comes from how often each of the eight stock lenses works on it.
## The flow
1. **Choose a Vault.** The player picks from Vaults the Dungeon operator has
admitted. A Dungeon Vault entry names its root and the scope a player may
read, for example Kalsa's `Public`. The payload carries a Vault id, never a
path. Dungeon binds the chosen Vault as the world's evidence source.
2. **Describe the experience.** The player writes what they want. A model
reads that description and proposes weights for the eight stock lenses. The
proposal fills the sliders; the player adjusts them. Dungeon submits only
the numbers and the description, which becomes the world brief.
3. **Create.** Dungeon creates a library world with title, brief, the stock
lens set and weights, and Dungeon's detail rule with no anchor yet. No
player subject is declared yet, so every place sits at the rule's floor.
4. **Sketch.** Dungeon's seed session authors a coarse world from the Vault:
regions, routes with travel costs, institutions, populations.
5. **Choose a start.** The player picks a sketch place. Dungeon submits one
owner patch declaring the player's human-controlled subject there, and pins
that subject as the first anchor of its detail rule.
6. **Elaborate.** The library's elaborator sessions run, pulled toward the
anchors by distance.
7. **Activate** when the player chooses. Dungeon never makes activation wait
on a count.
8. **Play.** As the player travels, the library's derived demand follows the
anchor and detail follows the player.
## Dungeon policy
- **Lenses:** the library's stock set of eight, weighted by the player's
sliders. The owner may change weights in Draft and Active; later sessions use
the new weights.
- **Detail rule:** `ByDistance`, anchored on important subjects, the player
first. Recommended levels: within 60 minutes, persons, institutions, and
populations; within 1 day, institutions and populations; beyond, institutions
only; lookahead 120 minutes. Tuned on the road. Which other subjects Dungeon
pins, and when, is Dungeon policy not yet decided.
- **Travel:** Dungeon's seed authors a travel affordance for persons, and the
Eve surface projects one control per affordance a player's subject holds.
## Authority map (Dungeon)
- **Owner:** Dungeon's runtime owns Session Zero's flow, the Vault registry,
the weight proposal, the player principal, anchor pinning, and the Eve
surface. Every world change it makes is an ordinary library command.
- **Inputs:** the authenticated player, the admitted Vault list, the player's
description and slider values, the chosen start place.
- **Outputs:** library world creation, seed and owner patches, rule and
weight changes, activation, and Eve projections.
- **Derived state:** the slider proposal, the Session Zero step a player is on
(derived from the library snapshot's phase, rule anchors, and structure), and
every projection.
- **Forbidden writers:** Dungeon writing world state other than through
library commands; the proposal model submitting weights without the
player's command; a Vault path in any payload; any Dungeon identifier,
schema, or policy entering the library vocabulary.
## Cut line (Dungeon)
- `GHOSTLIGHT_SEED_VAULT_ROOT` as the only Vault: Dungeon's Vault registry
replaces it.
- The `world.create` payload fields for jurisdiction roots and permille, when
the library's detail rule replaces them.
- The genesis human subject: Dungeon declares its player at the chosen start.
## Verification (Dungeon)
1. A player-chosen Vault id resolves only to an admitted Vault and scope; an
unknown id or a path is refused before anything is spent.
2. The slider proposal changes nothing until the player submits weights.
3. The start choice declares exactly one player subject, at a sketch place,
once, in Draft, and pins it as the rule's first anchor.
4. Activation succeeds with nonzero deficits.
5. Road: a seeded world on the SDK route shows persons near the start,
institutions far from it, and new persons at a place after the player's
subject travels toward it.
## Pass order
Library passes L0 through L4 are in plan step 15; L0 is the kernel
extraction. Dungeon passes follow them:
1. Vault registry, world creation with Vault id, brief, lens weights, and
detail rule.
2. The Session Zero Eve surface: Vault choice, description, slider proposal,
sliders, sketch, start choice, activation.
3. Travel affordance and per-affordance player controls. This pass closes the
playtest gate.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,152 @@
# Ghostlight stock lenses (L1) postmortem
Written 2026-09-16. Evidence: the cut map (`ghostlight-stock-lenses-cut.md`),
the target (`ghostlight-stock-lenses.md`), and the commits on
`codex/ghostlight-dungeon-mvp` from `817c9f4` to `872ba35`, plus the Cut 5 doc
commits.
## Summary
Elaboration in the `ghostlight` library now has a flavor the world's creator
controls:
- eight stock lenses;
- per-world weights in world state, replaceable by the owner;
- a replayable per-session draw that records the lens and its instruction
text on the session;
- concurrent sessions under their own permit ceiling.
In-flight elaborator work is rediscovered by reading the store.
Scale: 12 crate files, +4,907 / −266; four schema strings replaced
(`consumer.v5`, `controller_work.v16`, `world_create.v4`, and the commit
schema); one environment variable; one required trait method; no dependency,
crate or binary.
State at writing: landed and pushed, Windows-verified. The Linux release is
unexercised until Idunn builds it. The operator owes rulings on L1.f1
(quarantine handling), L1.f13 (seed-lane orphans), L1.f14 (row retirement)
and L1.f16 (consumer lens sets). None blocks L2.
## Scope and invariants
The target set eight invariants: lenses never decide admission, every lens
sees the whole catalog, the draw is replayable, weights are admitted honestly,
concurrent sessions stay distinct, the lens is recorded and survives resume,
stores refuse rather than migrate, and no Dungeon concept enters the library.
All eight hold. Excluded: detail rules, evidence binding, Draft sessions
(L2–L4), and Dungeon's Session Zero surface (D1–D3).
## Timeline
| Cut | Commits | Soul passes | Notable finding |
|---|---|---|---|
| Map | `ff766e8` (with rulings Q1–Q9) | — | Two sessions on one answer collided; a boundary repair was Superseded forever after any commit |
| 1 lens module | `835ea4d` | 1 | No external crash path; duplicate keys last-wins; guard and divisor diverged |
| 2 weights, command | `d06fd7e`, `10be98c`; fixes `afca403`, `acb9b33` | 2 | Identical weights committed a no-op; forged row caught by the digest, not the rule; a padded twin still committed |
| 3 session, store, adoption | `dfb3c52..2bf8164`; fixes `f63dfd1..2c3b513` | 1 | Flaky pin (3/40); the Q7 resume rule untested; the call-site draw unpinned |
| 4 concurrency, pools, rediscovery | `8538fab..3e35b3d`; fixes `6f35229..0764842`, `635123f..872ba35` | 3 | The adoption rule on one test; speak-lane pool untested; a panic killed the driver; the retryable-no-stop leg unpinned |
| 5 docs | `39b2af4`, `04fd004`, `b15ade4` | 1 | The ontology claimed seed requests and Vault evidence for the Active sweep |
## Structural delta
The estimate grew from about +900 to about +1,300 as rulings added the owner
command and rediscovery. The actual was +4,907 / −266 in crates, overwhelmingly
test code (`controllers.rs` +2,807). Production grew by the lens module (379
lines including its tests), weights and the command in the kernel,
session fields and adoption in elaboration, the concurrent sweep and
`SessionPermit`, and the runtime's second pool. Deleted: the sequential
sweep, `step`, `Clean`, `Inactive`, the instruction constant as integrity
reference, whole-session supersession equality, and a proven-dead digest
comparison. Nothing parked.
## What Soul caught
- **Untested operator rulings.** L1-Q7's whole purpose, that a resumed session
keeps its stored text when a lens's text changes, had no test: rebuilding the
text from the current lens passed everything. The operator ruled it; nothing
pinned it.
- **Flaky tests passing by luck.** A test rewrote a session's lens to a fixed
value while its fixture drew one at random. It failed about one run in eight,
and Hands' green run was luck.
- **Rules that no longer bear weight.** After Cut 4 routed resume through the
listing, Cut 3's adoption rule was guarded by a single test. Cut 3's own
former killers no longer reached it.
- **Semantic equality by spelling.** The no-op check compared raw maps, so
`{charter:3}` and `{patina:0, charter:3}`, which draw identically, still
committed revisions both ways.
- **Tests catching the wrong thing.** A forged all-zero journal row was
refused by a digest mismatch, so weakening the draw rule changed nothing.
- **Failure paths.** One panicking session task unwound out of the sweep,
aborted its sibling, and silently ended the runtime's elaboration driver
until restart.
- **Spec errors, caught before they shipped.** Identical weights "still a
commit"; a Dungeon payload mutation that could never fail; the `NoEffect`
variant the plan names but the kernel never had.
## Operator corrections
- **"Are you relying on some deterministic code outputting the same id rather
than just checking the state?"** This was the campaign's best question. It
came from Self having told the operator the lens "has to be recorded" in the
command identity. Answering it from the Body exposed the pre-existing
defect that the elaborator repair loop had never worked across sweeps (L1.f10),
and led to L1-Q9 (resume reads state). Missing context: Self framed the
recording question and the identity question as one.
- **Weights and Draft approvals.** Self recommended binding approvals to a
digest that included lens weights. The operator asked how weights could ever
alter an approval. They cannot: weights shape future elaboration, and the
owner may change them in any phase. Missing context: the probe that found the
approval gap happened to use weights.
- **Proportion.** "Dear Lord, we don't need even more machinery" (L1-Q5), and
the pools as "a ceiling, not a budget" (L1-Q4). Both shrank designs that Self
had presented with more options than they needed.
## Incidents
- **Agents ending their turn to wait.** Three times, an agent launched cargo
detached and ended its turn "to wait", and the work stopped until resumed by
hand. The brief already said to wait for long jobs. What fixed it was naming
how: block inside a tool call, with cargo in the foreground or `Wait-Process`
on the pid. Recorded in the Eureka changelog.
- **Line endings.** The working tree mixed LF and CRLF files over an LF index.
A naive newline anchor silently matched nothing, and `git apply -R`
rewrote an LF file to CRLF. Rule: detect each file's ending, require every
anchor to match exactly once, restore by reverse edit only.
- **A Soul probe left an empty untracked `lib.rs`.** Its permission to delete
was refused. Found in the report, verified, removed at close-out.
- **A map refresh raced an operator message.** Imagination finished before a
second ruling arrived, so the map contradicted two rulings until it was
resumed. Self held the commit until the map was refreshed.
## What worked
- Probing the operator's challenge instead of defending the design: the
stranding probe turned a question into a fixed pre-existing defect.
- Measuring flakes before and after (3/40, then 120/120) instead of rerunning
until green.
- Folding small, related findings into the next cut's pass instead of a
separate Hands and Soul cycle, with each still in its own commit.
- Skipping Soul on a test-only batch with an empty production diff, and
having the next Soul pass rerun its mutations.
- Derived status: no "Open:" line was ever hand-edited; questions were open
exactly while they lacked a ruling.
## What to change in the pipeline
- Tell agents how to wait, not only to wait (Eureka changelog, 2026-09-16).
- Treat any test built on a random draw or identity as requiring a loop.
- When the operator asks why a mechanism works, answer from the Body with a
probe before defending the design.
## Open follow-ups
| Item | Owner | Where | Why it can wait |
|---|---|---|---|
| L1.f1: quarantine-class elaboration errors never set quarantine, and are logged only at debug (c4.s2.f4: a panic in `persist` would wedge the lane silently) | operator | `runtime.rs` elaboration driver | Pre-existing; the lane keeps running |
| L1.f11: the browser create form never lifts bindings into the payload | operator ruled: D2 | `eve-browser-lowering`, `runtime.rs` | Fixed with the Session Zero surface |
| L1.f12: Draft approvals bound to nothing | operator ruled: deferred to multi-player | `lib.rs` `draft_approvals` | Approvals are the owner's own today |
| L1.f13: the seed lane orphans rows like deficits did | operator | `elaboration.rs` seed runner | Outside L1 |
| L1.f14: stored rows are never retired, including the L1-Q10 crash window | operator | controller work store | Growth bounded by world lifetime |
| L1.f15: `Uncovered` pinned only with at most one named root | Library | `elaboration.rs` | Behaviour is correct; the case is untested |
| L1.f16: consumer-supplied lens sets | operator | `lens.rs` | No consumer needs one yet |
| c3.s1.f3 cost: stored instruction text is not tied to the recorded lens | accepted with L1-Q7 | `elaboration.rs` | Only the local store file can write it |
| Linux release | operator via Idunn | Yggdrasil | Needs the deployment gate |

View file

@ -0,0 +1,84 @@
# Ghostlight stock lenses (plan step 15, L1)
## Status
Closed 2026-09-16. Landed in Ghostlight `835ea4d..872ba35`, plus the Cut 5
doc commits `39b2af4`, `04fd004` and `b15ade4`. The means, every ruling
(L1-Q1 to L1-Q10), and every Soul verdict are in
`ghostlight-stock-lenses-cut.md`; the scars are in
`ghostlight-stock-lenses-postmortem.md`. Windows only: the Linux release is
Idunn's build and is unexercised.
## Target
Elaboration in the `ghostlight` library gets a flavor the world's creator
controls. The library ships eight stock lenses. A world carries lens weights
as world data. Every elaborator session draws one lens by those weights,
replayably, and the drawn lens shapes what kind of structure the session
reaches for. Several sessions elaborate at once.
The lenses are the library's, not Dungeon's. Dungeon supplies weights at
world creation.
| Lens | Reaches for | Leads with |
|---|---|---|
| Patina | ordinary objects, customs, nicknames, local jokes | persona material, ordinary affordances |
| Charter | government, law, offices, selection, succession, redress | authority, selection, redress |
| Ledger | resources, labor, trade, infrastructure, scarcity, class pressure | custody, dependency, route cost |
| Hearth | kinship, daily life, care, obligation, belonging | commitments, reads |
| Tangle | factions, alliances, rivalries, leverage | relations, pressure, authority |
| Veil | secrets, rumors, misinformation, taboos, unevenly held knowledge | knowledge scope, channels |
| Ember | disputes, hazards, instability, escalation, urgent pressure | pressure, near-due commitments |
| Numen | religion, magic, ritual, awe, cosmology, the genuinely strange | facts, affordances, material |
The design text is `ghostlight-world-ontology.md`, "Elaboration lenses and
detail rules", which separates what L1 implemented from what L2 to L4 owe.
## Invariants, reconciled with the Body
1. **A lens never decides admission.** The same patch admits identically under
all eight lenses, with the mismatch set equal to the pre-L1 capture. A lens
is not part of the command id, the caller, the confinement ground, or any
mismatch. Holds.
2. **Every lens sees the whole catalog.** The catalog signatures and digest
are unchanged from before L1; emphasis is prompt text only (L1-Q5).
Holds.
3. **The draw is replayable.** The lens is drawn from world id and command
id with the kernel's `select_band` recipe, and the library has no RNG
dependency. The operator cares about replayability, not about what seeds
the draw (L1-Q1). Holds.
4. **Weights are world data with honest admission.** `WorldState` carries
them (`consumer.v5`); creation requires explicit weights with no library
default (L1-Q6); the owner replaces them with `SetLensWeights` in any phase
(L1-Q3). An all-zero set is refused at creation, at the command, at the
Dungeon payload and in a stored state row. A set that draws the same as the
current one, however it is spelled, is refused as no canonical change.
Holds.
5. **Concurrent sessions are distinct work.** The sweep hands concurrently
running sessions distinct demand entries through an in-memory claim set.
The lens stays out of the command id, which remains the idempotency key
(L1-Q4). Elaboration draws from its own pool, a ceiling
(`GHOSTLIGHT_ELABORATION_MAX_CONCURRENT`, default 2), separate from the
simulation budget, and saturating either pool never delays the other.
Dungeon runs no Active elaboration since the play agent's Cut 1
(`d69e9d4`) deleted its sweep and pool; the claim stands as a library
capability. Holds.
6. **The lens is recorded and resume reads state.** The drawn lens and its
instruction text are persisted on the session (`controller_work.v16`,
L1-Q7). A resumed session keeps both even if the weights or the lens's
text change. In-flight sessions are rediscovered from the store by what
they answer (L1-Q9); rows ready to submit are excluded from that listing
(L1-Q10). Holds.
7. **Stores refuse, not migrate.** `consumer.v4` states and
`controller_work.v15` rows are refused at open. Holds.
8. **No Dungeon concept enters the library**, and the boundary stays sealed
by admission. Holds.
## Out of scope, still owed elsewhere
- Detail rules, distance-weighted demand, and the permille cut (L3).
- Per-world evidence binding; the Active elaborator still uses
`NullEvidenceSource` (L2).
- Elaborator sessions in Draft (L3).
- Dungeon's Session Zero surface, slider proposal, and weight UI (D1-D3).
Dungeon passes explicit uniform weights through `world_create.v4` today.

View file

@ -0,0 +1,204 @@
# Ghostlight World Consumer API
Status: this describes the landed pass-10 consumer ingress — a world's third
patch author, reaching `AdmitPatch` through one loopback-bound typed document.
## Objective
Let an external consumer author and observe the exact subjects a Ghostlight
world has admitted as its mirrors, without becoming part of Ghostlight's
runtime and without the world learning the consumer's vocabulary. A
consumer-authored seed and a compiled seed are the same `AdmitPatch`; there is
no separate seed type, registry, or publication handoff. Delvehold is the
first consumer; no Delvehold identifier, schema, or policy is core authority
(`docs/architecture/delvehold-forced-ontology-integration.md` names the
profile-side binding).
## Authority map
- **Admission owner:** `require_patch_author` (`crates/ghostlight/src/lib.rs`) decides which
caller may submit `AdmitPatch` and returns that caller's `PatchGround`;
`confine_to_ground` decides what a confined ground may write. Both run in
`reduce`'s `AdmitPatch` arm and again in `apply_effect`'s `PatchAdmitted`
arm — there is no second confinement function and no way for a patch to
commit without being re-decided.
- **Consumer ground:** `PatchGround::Consumer(ConsumerId)` is derived, never
carried. The set of subjects a consumer may write is
`{ s | controller_assignments[s] == ExternallyControlled { consumer } }`,
read from committed state at decision time. A consumer cannot name its own
scope on the wire, and revoking a binding in a later commit shrinks its
authority with no ingress change.
- **Caller minting:** `WorldMailbox::submit_consumer` (`crates/ghostlight/src/mailbox.rs`) is
the only constructor of `AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer })`.
- **Ingress owner:** `crates/ghostlight/src/consumer.rs` owns decoding a document, bounding
it, authenticating the consumer, one port call, and one receipt
projection. It owns no world truth, holds no opinion about a patch's
content, and does not pre-validate — a second reducer here could disagree
with the kernel's, so structure is decided once, by `resolve_patch`.
- **Port:** `ConsumerPort` (`crates/ghostlight/src/mailbox.rs`) has one method,
`submit_consumer`. It cannot read the world: it does not select an answer,
does not pre-validate, and returns a receipt rather than state.
## Typed flow
```text
ConsumerPatchDocument (canonical MessagePack)
-> ConsumerRegistry: name -> ConsumerId, secret verified against a
stored SHA-256 digest in constant time
-> patch::decode_patch, bounded by MAX_PATCH_BYTES / _DECLARATIONS /
_OPERATIONS / _EVIDENCE
-> WorldMailbox::submit_consumer(world_id, expected_revision, command_id,
consumer, answers, patch)
-> reduce / apply_effect: require_patch_author, the answer rule,
confine_to_ground
-> ConsumerReceiptDocument: one atomic verdict — a commit reference or
the complete mismatch set
```
`command_id` and `world_id` on the receipt are `Option`: a frame that fails to
decode or names an unregistered consumer never derives a command key or reads
a world, so the receipt has none to report. The outbound half of this
contract — a consumer reading its own projection, or receiving an
attributed proposal — is not part of this pass; see "Not in this pass" below.
## Transport
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
serves.
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
`api_router` (`runtime.rs`). The handler gates loopback and content type
(`application/msgpack`) exactly as the snapshot route does, then hands the
body to `ghostlight::admit_document`; everything past those two gates is
`consumer.rs`'s to decide. The body limit is
`CONSUMER_BODY_LIMIT = patch::MAX_PATCH_BYTES + CONSUMER_ENVELOPE_SLACK` (an
8 KiB envelope allowance over the 256 KiB patch cap), so the transport guard
cannot become a second opinion about how big a patch may be.
The document and receipt are Ghostlight-owned typed records in canonical
MessagePack, not CultNet control messages, pinned to two schema constants:
```text
CONSUMER_PATCH_SCHEMA = "ghostlight.consumer_patch.v0"
CONSUMER_RECEIPT_SCHEMA = "ghostlight.consumer_receipt.v0"
```
`ConsumerPatchDocument` carries `schema`, `world_id`, `consumer` (a
configured name, lowered to a `ConsumerId` by the registry and never carried
further), `secret`, `idempotency_key`, `expected_revision`, `answers:
Option<PatchAnswer>`, and `patch: Vec<u8>` — the encoded `WorldPatch` as a
nested canonical-MessagePack byte frame rather than an inline value, so
`MAX_PATCH_BYTES` is checked against the patch itself before any of its items
deserializes. The outer document is decoded first, is tiny, and is bounded by
the route's derived limit.
`mesh.rs`'s advertisement gains the two schema constants and nothing else:
this door is loopback-bound, so it is not a remotely reachable surface and
advertising it as one would claim availability the binary does not have.
## Idempotency and staleness
`expected_revision` is required; the port submits through
`submit_authenticated`, not the stamped path, because a caller that cannot
name the revision it built against cannot be told its batch is stale. The
idempotency ledger is checked before the revision, so an exact resubmission
still returns the original receipt (`AlreadyApplied`) even after the world has
moved; a stale document is `RevisionMismatch { expected, actual }`, and the
receipt carries the live revision so the consumer knows what to build against
next. `CommandId::derived` computes the command key from `(world, consumer,
idempotency_key)`; the same key with a different body is
`CommandIdConflict`, and nothing commits.
There is no receipt-probe request and no separate mailbox `Request` variant
for it. A consumer's document is byte-identical on retry and its `CommandId`
is derived, so resubmission is the probe and the ledger answers it directly.
## The externally controlled subject
A subject declared `NewController::External { consumer }` receives
`ControllerAssignment::ExternallyControlled { consumer }`. It mints no
controller ID and no controller mode — both accessors return `Option` for it,
and every reader fails closed rather than assuming a default. Concretely:
- it derives no decision opportunity and mints no turn;
- it is excluded from `agency_graph` and so from the cover;
- it is visible in the snapshot as an ordinary subject, related to, targeted,
and counted like any other, with `controller_id: None` and
`controller_mode: None`;
- it may hold no affordance — declaring it with a non-empty affordance grant
set is `Mismatch::ControllerGrantMismatch`, and the pairing is checked both
ways (an ordinary subject with an empty grant set is the same mismatch);
- its components change through exactly two callers: its own consumer, and
the world owner, who is unconfined and always could write it — that is the
owner's standing authority, not a leak in the consumer boundary.
## Confinement
A consumer writes only the subjects bound to it and operations grounded on
those subjects (`confine_to_ground`, the `PatchGround::Consumer` arm):
- a declared subject is admitted only if its controller names the same
consumer; position is unconfined;
- a declared place, a declared route, `Relocate`, `OpenRoute`, `CloseRoute`,
and `AlterCost` are always refused — they carry a place or a route, and a
consumer's ground names no place;
- `Witness` is always refused, for the same reason: its ground is the named
place, never the acting subject, and a consumer's ground names no place.
A consumer tells what its mirror knows through `AcquireKnowledge` on its own
bound subject; it cannot cause a place-wide witnessed event;
- `Retire`, `GrantAffordance`, `RevokeAffordance`, and `Mint` are always
refused (`ResolvedOp`s that are authority over standing, not structure over
ground);
- a `Claimed { by }` fact must name a bound subject; a `Ruled` fact is refused
outright, and so is a `Canonical` fact
declaration — a consumer's evidence buys an `Admit`
into its own custody and a `Claimed` fact, never a canonization;
- a channel's reach and controller must all be bound subjects;
- every operation's ground subjects must be bound, and its place and route
lists must be empty — `Transfer` therefore requires both endpoints bound,
so a consumer cannot push custody into a Ghostlight subject; `Admit` and
`Consume` work inside its own custody.
In Active phase a consumer patch that declares answers nothing except a
`CausalBoundary::MissingStructure { subject }` derived on one of its own
mirrors — the world noticing that a subject it can see has structure it
cannot account for. Every other Active declaration is refused by the answer
rule plus this confinement, not by a separate phase gate: a consumer cannot
answer a boundary on a foreign subject (not covered), cannot answer a
`Deficit` row (deficits are jurisdictional; a consumer holds no
jurisdiction), and cannot declare without an answer. A component-only batch —
the overwhelming majority of consumer traffic — answers nothing and commits
through the same `require_answer` path as any other author.
## Operator configuration
`ConsumerRegistry` is loaded once at startup from the file named by
`GHOSTLIGHT_CONSUMER_CREDENTIALS`: one `name = <64 hex digits>` line per
consumer (the SHA-256 of that consumer's shared secret), blank lines and `#`
comments ignored. A missing file means no consumers, and every document is
`Unauthenticated`, the fail-closed default that lets this route exist before
an operator configures anything. A file that exists but does not parse is
fatal at startup, so a mistyped credential can never read as "no consumers". The registry holds only digests; the
plaintext secret never enters the process image beyond the hash and never
enters a log, a receipt, or the journal.
## Deployment
The consumer capability's tag is part of the commit digest and the
externally controlled assignment is part of the state shape, so both bump the
schema. World lens weights later entered the state and `SetLensWeights`
the commit, so the live schemas are state `ghostlight.world_state.consumer.v6`
(state-schema generation `world-v3`) and commit
`ghostlight.world_commit.consumer.v6`. A store written under an earlier schema
is refused, not migrated.
## Not in this pass
- The outbound consumer response — a consumer reading its own projection, or
receiving an attributed proposal about a Ghostlight subject acting toward
its mirror. This is the next seam; building it prematurely would grow a
second projection owner ahead of its use.
- A CultMesh authority lease for a non-loopback consumer. Until that exists,
this route is loopback-only and offers a consumer no remote door.

File diff suppressed because it is too large Load diff

View file

@ -1,85 +0,0 @@
# Illustrated IF Visual Pipeline
Illustrated interactive-fiction fixtures can use image generation for mood,
presentation, and website replay. They should not treat image generation as the
source of spatial truth.
## Role
The visual pipeline has four separate jobs:
1. Describe the playable visual beat in the Ink and `.visual.json` artifact.
2. Preserve the intended environment, character, and branch state as structured
visual data.
3. Provide image models with enough concrete visible detail to render the beat.
4. Keep generated art useful as presentation collateral without letting it
rewrite scene geometry.
Visual artifacts are auxiliary replay and website value. They are not core
training targets for responder, appraiser, mutator, relationship, or social
state models.
## Geometry Authority
For scenes where spatial continuity matters across angles, branches, or later
callbacks, the authoritative visual source is a `scene_set` artifact, not a
generated image.
A `scene_set` may be a hand-built 3D blockout, a procedural scene file, a
layout diagram, or another durable geometry asset. It should define:
- Environment layout and scale.
- Named zones, routes, entrances, exits, and occluders.
- Fixed cameras or camera-slot names for replay beats.
- Character staging slots and allowed movement regions.
- Important props, access points, interfaces, and branch-visible state anchors.
- Which parts of the set are visible from each camera.
Generated environment concept art can inspire the set, but it is not a reliable
layout contract. Image models routinely invent impossible joins, inconsistent
occlusion, false loops, and locally plausible machinery that cannot be staged
from a second angle.
Agent-assisted set construction may live in a separate toolchain such as
VibeGeometry. Ghostlight only requires the resulting durable geometry input and
the metadata needed to connect it to fixture scenes.
## Prompt Assembly
Prompt assembly should use the geometry source appropriate to the fixture:
- Mood concept only: text prompt may be enough.
- Single isolated illustration: a complete standalone prompt may be enough.
- Multi-scene illustrated IF: use `.visual.json` scene ids, character refs, and
branch modifiers.
- Multi-angle or branch-consistent illustrated IF: use a `scene_set` render or
blockout for each camera angle, then apply the scene prompt as painterly
direction.
For the multi-angle path, the image model acts as a painter and material
interpreter. It does not decide the room shape.
## Base Images And Modifiers
Base prompts describe a complete visible frame. Modification prompts describe
only the branch or state delta, but the edit call must receive the base image or
the camera-specific blockout as visual input.
Do not rely on phrases such as "same room" or "as before" unless the edit input
actually supplies the same room. If the image model only receives text, restate
the visible geometry. If it receives a blockout, the prompt should still name
the visible anchors it must preserve.
## Reviewer Expectations
The visual reviewer should ask whether the current fixture needs a `scene_set`.
It should require one when:
- The story returns to the same room from multiple angles.
- Branches move characters around a shared environment.
- Sightlines, occlusion, route access, or body affordances affect choices.
- The same space needs website imagery across several beats.
- A generated concept image is being used as if it were a durable floor plan.
When a `scene_set` is required but missing, the fixture can still be accepted as
prose or IF scaffold, but not as replay-ready illustrated IF.

View file

@ -1,498 +0,0 @@
# Ink Branching Scenes
Ink is the branching scene format.
Ghostlight should not invent a parallel dialogue-tree language unless Ink
fails a concrete requirement. Ink already gives us readable interactive fiction,
choices, knots, stitches, variables, compiled JSON, and a format writers
can inspect without needing to wear a compiler helmet indoors.
Ghostlight's job is the machinery around Ink:
- build actor-local awareness from agent state and scene state
- project plausible speech and non-speech choices
- save generation receipts when a model proposes branches
- annotate why those choices make sense
- preserve listener interpretation as separate from actor intent
- record consequence surfaces for later state mutation
- keep fuzzy social updates reviewed until there is training data
- compile Ink into game-consumable JSON
Ink owns the playable branch structure. Ghostlight owns the psychological,
cultural, memory, relationship, and consequence rationale.
## Runtime Shape
The artifact flow is:
```text
agent state + scene state + lore grounding
-> local awareness
-> projected branch choices for the acting character
-> selected branch as observable action
-> event resolution from selected observable action
-> participant appraisals and reviewed state mutation
-> next actor local awareness from updated state
-> next actor action
-> Ink scene
-> compiled Ink JSON
-> reviewed training annotations
-> selected branch consequences
-> state, memory, belief, and relationship updates
```
The Ink file should be readable as Aetheria content on its own. The sidecar
training annotation should explain which Ghostlight state produced each branch
and which consequences are automatic, reviewed, or deferred.
Each character turn must be generated from that character's local awareness.
The protagonist-choice generator should not receive the responder's private
state. The responder generator should not receive the protagonist's private
intent except as an observable action, spoken text, gesture, object use,
silence, or other perceivable cue. A listener is allowed to misunderstand.
Participant appraisal is symmetrical. If an action hurts, threatens, reassures,
humiliates, obligates, or overloads anyone present, that change is consolidated
for the affected character before the next actor is selected. The next actor may
reply, walk away, comply, escalate, or end the interaction. A branch artifact can
still display this as `choice -> reaction`, but Ghostlight should model it as
turns over updated state, not as a mandatory paired response.
One-shot generation of both sides is allowed only as bootstrap scaffolding, and
must be marked as such in the capture review. It is not the target runtime
shape.
The current target path is source-grounded coordinator state, projected local
context, reviewed character turns, branch compiler materialization, and IF
artifact review.
## Ink Comments
Use Ink comments for lightweight traceability in writer-facing drafts:
- `ghostlight.scene`
- `ghostlight.fixture`
- `ghostlight.branch`
- `ghostlight.action`
- `ghostlight.intent`
- `ghostlight.npc_response`
- `ghostlight.consequence`
- `ghostlight.training_hook`
- `aetheria.flashpoint`
Use `//`, not Ink tags (`#`), for this metadata in drafts. Inky exposes tags in
preview, which makes Ghostlight state leak into the readable scene. The
sidecar remains the machine-readable receipt; comments are only local handles
for humans and simple tools.
## Sidecar Annotations
Each Ink scene should have a `.training.json` sidecar with:
- source fixture reference
- protagonist and NPC ids
- actor-local awareness summary
- projection controls
- branch ids and Ink knot paths
- action type and actor intent
- state basis
- reviewed consequences
- training hooks
- mutation policy
This sidecar is intentionally not a new branching format. It is a receipt. Ink
remains the scene tree; the sidecar says why the tree grew that way and which
state changes a reviewer accepted.
Visual planning should live beside the sidecar, not inside it. Use a separate
`.visual.json` artifact for illustrated replay data: style cue, visual scene
anchors, image prompts, character refs, prompt assembly rules, and visual
modifiers. The training annotation may reference that file with
`visual_plan_ref`, but it should remain focused on branch semantics and
mutation surfaces.
## Mutation Policy
Ink variables can record local playthrough outcomes. They should not silently
become canonical Ghostlight state.
Automatic:
- Ink compilation
- branch id and knot validation
- local variables for the current playthrough
- mechanical comment handles and training hook checks
Manual reviewed until trained:
- relationship deltas
- belief updates
- memory writes
- listener misread classification
- activation changes
- promotion from branch outcome into durable Aetheria lore
The current accepted-as-draft scaffold lives in
`examples/ink/pallas-species-strikes.branch-and-fold.v0.ink`.
## Cross-Scene Consequence Carryover
Ghostlight branching scenes should not behave like disconnected dialogue samples.
Interactive fiction needs consequences that survive the scene boundary. A branch
choice should write durable effects into later context: relationship stance,
perceived motives, resource costs, authority flags, object custody, active
constraints, unresolved hooks, and available future options.
For training data, every material branch should preserve:
- the player/NPC-visible choice or action
- the immediate responder output
- accepted world-state, resource, memory, and relationship deltas
- branch flags that later scenes must read
- deferred consequences that should surface later rather than immediately
- scene text showing how the consequence changes the feel of the next beat
The coordinator owns this continuity for now. Later, the trainable coordinator
model should learn to carry these consequences forward without flattening them
into cosmetic callbacks. If a choice does not change later affordances, trust,
resources, risk, or interpretation, it is probably not a meaningful branch.
A complete interactive fixture needs an ordinary-world opening, consequential
branches before the crisis peak, and a resolution that visibly reflects earlier
choices. A single tense exchange can prove a seam; it is not enough to prove the
story machine.
## Branch And Fold
Do not model every choice as an indefinitely isolated authored subtree. Strict
worldline splitting creates a combinatoric writing burden after a handful of
choices, which interactive fiction authors have been discovering the painful
way since approximately the first time someone let a player open a second door.
Ghostlight should branch locally and fold structurally:
```text
choice
-> consequence packet
-> normalized state deltas
-> shared later scene reads compact variables
-> conditional options, callbacks, imagery, and relationship texture
-> major route split only when convergence would be false
```
Most branches should converge onto shared later scenes. The consequences should
survive as compact variables, bands, and flags rather than bespoke scenes for
every exact history.
Good consequence state looks like this:
- `sella_trust_maer: wary`
- `ledger_debt_level: high`
- `clinic_staff_exhaustion: true`
- `veyr_evidence_disclosed: partial`
- `isdra_reads_maer_as: procedurally_dangerous`
- `route_authority_pressure: escalated`
Those variables should alter later scenes by changing available options,
relationship posture, timing pressure, resource costs, callback prose, visual
state modifiers, and participant appraisals. They should not usually require a
fully separate scene route.
Use coarse bands instead of fake precision. `blocked`, `wary`, `conditional`,
`cooperative`, and `personally_invested` are more useful than pretending the
story can meaningfully maintain 37 decimals of trust. The same applies to heat
debt, legal exposure, institutional pressure, staff exhaustion, reputation, and
social suspicion.
Major route splits are allowed when a consequence changes the playable world so
much that folding would lie. Examples:
- a character dies, leaves, defects, or becomes unavailable
- a location is destroyed, locked, opened, or politically inaccessible
- a faction becomes hostile, allied, exposed, or legally empowered
- a key object is destroyed, stolen, transferred, or transformed
- the protagonist loses or gains institutional authority
- a quest route, rescue path, market, technology, or branch-local future opens
or closes entirely
The coordinator owns the fold decision for now. Every material branch should
record whether it folds into an existing later scene, opens a conditional
variant inside that scene, or creates a justified route split. If a route split
is created, the artifact should name the reason and the expected future writing
debt. Trees are cheap to grow and expensive to prune. The machine should not
act surprised by botany.
## Branch Compiler
The Branch Compiler is the organ that turns Ghostlight scene intent into a
playable Ink artifact. It is not the character agent, the responder, the
appraiser, or the final reviewer. It assembles branch structure from already
available scene state, projected actor affordances, proposed actions, accepted
consequence packets, and coordinator constraints.
The Branch Compiler receives:
- fixture brief and scene spine
- source-grounded lore digest
- current world, scene, resource, relationship, and branch state
- actor-local branch candidates, including speech and non-speech actions
- responder outputs or coordinator-authored placeholder reactions
- consequence packets from reviewed turns
- branch-and-fold plan
- visual continuity requirements
- schema and Ink formatting constraints
The Branch Compiler emits:
- readable `.ink` scene content
- Ink variables, knots, stitches, choices, gathers, and conditionals
- a `.training.json` sidecar tying each branch to state basis, action intent,
consequence surfaces, and later callbacks
- compiler notes for fold decisions, route-split decisions, and known review
risks
- image prompt anchors and branch/state modification handles when the fixture is
meant for illustrated output
The Branch Compiler may propose structure. It must not invent hidden character
truth, private motivations, or durable social mutations as if compilation made
them real. Character behavior comes from projected local context and responder
or coordinator-authored action proposals. Social interpretation and memory
changes come from appraiser/mutator review. The compiler's job is to make the
branching artifact playable, legible, and traceable.
The compiler should prefer compact state variables, bands, and flags over
runaway branch trees. It should create major route splits only when the
coordinator or reviewed consequence packet says folding would falsify the
world. When it folds branches, it must preserve the variables and callbacks that
make the fold honest.
The Branch Compiler output is draft until the IF Artifact Reviewer accepts it.
That separation matters. A compiler can make a scene valid Ink while still
producing cosmetic choices, fake folds, unused variables, or endings that ignore
the whole moral machinery. Valid syntax is not wisdom. It is barely hygiene.
## IF Artifact Review
Branching fixtures need an independent review pass before acceptance. The
coordinator should not be the only thing grading its own Ink, sidecar, and
branch logic. That is how a scene ends up proudly tracking variables that do
nothing except sit there in a tiny uniform.
The reviewer may start as a prompted specialist sub-agent. Later it should
become evaluator/classifier training data and eventually a trainable review
organ. Its job is not prose polish. Its job is to catch false consequence.
The reviewer receives:
- the Ink file
- the `.training.json` sidecar
- the coordinator artifact
- the intended branch plan, when available
- the declared state variables, flags, bands, resources, risks, and visual
modifiers
- any compiled Ink or validation output available for the fixture
The reviewer emits:
- `accepted`, `needs_revision`, or `rejected`
- severity-ranked findings
- a fake-variable audit
- a choice-consequence audit
- a fold/callback audit
- a state-gated prose audit
- a visual-continuity audit
- major-route-split justification notes
- specific required fixes
Acceptance requires every material choice to change at least one later surface:
- available options
- scene route or justified route split
- relationship, trust, appraisal, or social standing
- resource cost, time pressure, legal exposure, authority pressure, heat burden,
exhaustion, or other risk
- object custody, evidence quality, access, or technology state
- later callback prose implemented as an actual conditional
- ending texture or outcome band
- visual state modifier tied to a real branch flag or state variable
Every declared variable must either be read later or be marked as telemetry-only
in the sidecar. Ominous variables need threshold behavior. If a variable named
`authority_pressure` rises and nobody with authority ever reacts, the artifact
fails review. If `heat_time` counts down and no route, ending, resource cost, or
staff condition changes, the artifact fails review. The name is allowed to be
dramatic only if the machinery has teeth.
Any prose that says "if X happened" must be implemented as an actual conditional
or moved into reviewer/coordinator notes. Writer-facing text should not name a
branch possibility while dodging the state check that proves which version of
history occurred.
Common failure labels:
- `potemkin_state`: a tracked variable never materially changes the artifact
- `cosmetic_choice`: a choice changes wording but not affordance, cost, risk,
appraisal, callback, visual state, or outcome
- `missing_state_read`: a set variable is never read by later Ink, sidecar, or
coordinator consequence logic
- `fake_fold`: branches claim to fold, but the folded scene ignores their state
- `unearned_convergence`: branches converge despite consequences that should
make convergence false
- `unjustified_route_split`: a separate route exists without enough world or
story consequence to justify the writing debt
- `state_named_instead_of_checked`: prose mentions alternate branch states
instead of checking variables
- `visual_callback_missing`: image modifiers or visual prose ignore branch
consequences that should be visible
- `authority_without_consequence`: institutional pressure rises but never bites
- `resource_without_cost`: ledgers, heat, staff time, supplies, or access are
invoked without affecting later choices or outcomes
- `ending_ignores_major_state`: the ending does not respond to major variables
established earlier
Early branch-and-fold review exposed this exact class of failure: variables can
look important while doing nothing. Review must force important variables to
alter gates, callbacks, visuals, appraisals, or outcomes before a fixture enters
the accepted corpus.
## Scene Imagery
Ghostlight scenes are also website content. A complete fixture should emit a
visual plan artifact alongside prose and training receipts so story branches can
be illustrated without a later model guessing what the room looked like.
Interactive fiction replay also needs screen rhythm. A narrative scene may move
through several visual sections: establishing shot, workstation, break table,
object closeup, alarm state, crowd line, supervisor arrival, aftermath. These
should be segmented so an InkJS replay can click through the scene at the pace
the story needs. A pivotal beat may be a single sentence on its own screen when
the point is to let the image and silence do the work.
Each distinct visual section should preserve an art-direction block with:
- global style cue: the fixture-level visual language when the art direction
depends on a specific non-default look
- establishing shot: location, scale, camera distance, and dominant geometry
- light and color: practical light sources, palette, exposure, haze, glare,
reflection, water, vacuum, screen glow, or other atmosphere
- bodies and interfaces: how each relevant body occupies the space, including
nonhuman access, assistive infrastructure, suits, avatars, wet/dry boundaries,
displays, tools, and machinery
- material evidence: objects, damage, records, ledgers, cargo, alarms, clothing,
stains, frost, heat shimmer, or other concrete traces of what has happened
- branch marks: visible changes caused by earlier choices, such as a sealed
hatch, an exhausted staffer, a live debt ledger, missing equipment, guarded
posture, or a route marker left blinking
- base image prompt: one stable prompt for the unbranched scene, suitable for
generating the reusable background or canonical key frame
- branch modification prompts: additive edit prompts for visible consequences,
changed character positions, opened or sealed routes, damaged objects,
lighting changes, emotional posture, crowd pressure, or other scene states
Named characters need stable visual references. An image model does not know
what Lio Vale looks like because the prose named Lio Vale. Each illustrated
fixture should define `visual_character_refs` for recurring named characters:
body type, apparent age range if relevant, face/hair/skin or equivalent visual
identity, clothing and equipment, silhouette, posture language, and
`do_not_change` continuity details. Scene prompts may then reference those
character refs instead of repeating the whole description every time.
Prompts must be imagegen-ready. Do not rely on unexplained Aetheria jargon as
if the image model knows the lore. If the prompt says "wet-service cradle" or
"dry-operation harness," it should also describe the visible thing according to
the faction, location, and labor regime. A Navigator comfort habitat and an AU
industrial yard should not quietly become the same room. If a scene uses a
biodrone limiter interface, cavity seal assembly, or life-support bypass kit,
describe the shape, scale, materials, lights, cables, seals, or displays that
make it drawable.
The same rule applies to environments. "Manifold line," "cavity yard,"
"supervisor glass," and "anchor rail" are not images by themselves. A base
prompt should describe the physical space a general image model can draw:
corridor shape, wall/ceiling/floor geometry, pipe runs, cable bundles, panels,
screens, rails, doors, windows, gauges, warning lights, materials, scale, and
where bodies stand inside that geometry.
Image prompts should be affirmative. Describe the desired geometry, material,
composition, lighting, bodies, tools, and state. Negative prompt constructions
such as "do not draw X," "without X," or "rather than X" tend to feed the image
model the wrong object and then hope it obeys the minus sign. That is weak
magic. Put the target in the prompt, and keep forbidden assumptions in reviewer
notes or validation errors.
For Aetheria Bloom habitats, prompt the engineered object directly: rotating
cavity-shell infrastructure, consolidated asteroid rubble/debris, TCS
substrate, shell plating, ribbed supports, seals, hub/spoke interfaces,
manifolds, and service systems. Source-specific exceptions should be represented
as their own positive visible description.
Every visual section should identify:
- `visual_scene_id`: stable handle for the screen or key frame
- `ink_anchor`: knot, stitch, or comment handle where the visual applies
- `base_image_prompt`: durable scene prompt for the unmodified frame
- `visible_characters`: characters allowed in the frame and their default
position or stance, plus a `visual_ref` when the character is named
- `state_image_modifiers`: additive prompts tied to explicit variables,
thresholds, or route flags
- `branch_image_modifiers`: additive prompts tied to specific branch ids
- `visual_continuity_notes`: facts that must remain stable across edits
Prompt assembly for illustrated replay should combine:
```text
global style cue
+ visual_character_refs for named characters actually visible in this rendered frame
+ visual section base_image_prompt
+ triggered state or branch image modifiers
```
The base image prompt is allowed to focus on the room and staging only if the
assembled prompt includes the relevant character refs. If a tool sends the base
prompt alone to imagegen, the base prompt must carry enough character detail by
itself. The same applies to visual style: if a fixture depends on a specific
look, the assembled prompt must carry that style cue every time. Names are
handles, not faces.
Keep `visual_continuity_notes` out of assembled image prompts. They are tooling
and review constraints for keeping repeated generations coherent. If a
continuity fact needs to reach the image model, rewrite it as affirmative,
concrete visible prompt text in the base prompt, a character ref, or a
branch/state modifier.
The image generator only sees the prompt you send it. It does not remember the
previous scene unless a real image-editing workflow supplies that image as
input.
The coordinator owns this surface for now. The responder should not receive
omniscient visual art direction unless the character can observe it. Character
packets may receive the local visible subset: what this actor can see, hear,
touch, smell, monitor, or infer from their interface.
Visual continuity is part of consequence carryover. If an earlier branch cost
heat, trust, time, equipment, or social standing, the later scene should show
that cost somewhere on the page or in the frame. Otherwise the choice may be
mechanically recorded while still feeling fake, which is the stupid little
trapdoor interactive fiction keeps setting for itself.
When continuity matters, use one durable base visual prompt and branch/state
modification prompts that describe only what changed. The base prompt owns the
room, palette, dominant geometry, key props, bodies, and interface layout. A
modification prompt owns the delta: Sella moves from the dryside console to the
decon sleeve, the route marker flips from amber to white, a wet access hatch
seals, an exhausted staffer appears behind the glass, Maer's ledger panel gains
a live debt line.
Keep these prompts separate from canonical prose:
- `base_image_prompt`: stable scene anchor
- `state_image_modifiers`: reusable prompts for common scene states
- `branch_image_modifiers`: prompts tied to specific branch ids
- `visual_continuity_notes`: non-prompt reminders about what must remain stable
Image edits are allowed to stylize, compose, and beautify. They are not allowed
to silently change canonical facts. If an edit introduces a new visible object,
body affordance, damage mark, or faction symbol that was not in the scene state,
the coordinator must either reject it, mark it as noncanonical illustration
noise, or promote it through the normal reviewed lore/state path. The picture
does not get to smuggle a gun onto the mantel just because it had a strong
personal brand that morning.

View file

@ -1,264 +0,0 @@
# Lore Grounding Digest Format
The lore grounding digest is the bridge between authored Aetheria history and
Ghostlight projection training.
It distills a specific historical flashpoint into the cultural, institutional,
material, and factional pressures needed to build agent-state fixtures and
speaker-local projection prompts.
It is not a lore replacement. It is a working slice. Do not ask every prompt to
carry the whole vault in its teeth like an exhausted dog.
## Files
The schema lives at:
- `schemas/lore-grounding-digest.schema.json`
The starter template lives at:
- `examples/lore-grounding/historical-flashpoint.template.json`
Use the template for the first authored historical flashpoint, then save real
digests under `examples/lore-grounding/` with names tied to the flashpoint.
## Purpose
A digest should answer:
- What authored source material grounds this flashpoint?
- What history, institutions, factions, classes, roles, and material pressures
shape the scene?
- What cultural priors should affect how characters appraise events?
- What does each faction or class reward, shame, taboo, admire, and misread?
- What should each speaker know, believe, misread, or not know?
- What pressure should appear in `prompt_text`, and what should remain
author-only?
## Main Sections
### Source Refs
Use `source_refs` to cite the lore archive slices that constrain the digest.
References should be specific enough that a later reviewer can find the source
without spelunking through the entire vault with a dim headlamp and a worsening
personality.
### Historical Context
This section describes the time, place, public facts, and ambient pressures of
the flashpoint.
Good context is small and hard-edged:
- a clinic policy dispute
- a contract negotiation
- an upload continuity hearing
- an unsafe FTL signoff
- a workplace discipline meeting
- a factional aid intervention
Do not start with "the whole setting." Start with one room where history has
hands.
### Groups
`groups` describes factions, institutions, classes, professions, movements, or
subcultures active in the scene.
The group profile is where broad lore becomes usable social pressure:
- `institutional_logic`: what the group exists to preserve, control, repair, or
extract
- `material_incentives`: what the system rewards the group for doing
- `internal_virtues`: what insiders admire
- `internal_shames`: what insiders experience as disgrace
- `prestige_markers`: what reads as competence, status, holiness, care, or
discipline
- `taboo_triggers`: what provokes disgust, sanction, fear, or exclusion
- `emotional_display_norms`: what feelings can be shown, hidden, ritualized, or
punished
- `trust_defaults`: who the group tends to trust, distrust, pity, envy, use, or
fear
- `speech_register_cues`: how role and culture shape language
This is where "Dominion types read as cold" becomes something more useful than
a hat with a sneer glued to it.
### Cultural Pressure Fields
`cultural_pressure_fields` are reusable pressure atoms derived from lore.
They should not say:
```text
Dominion people are cold.
```
They should say something closer to:
```text
In this Dominion-coded role, visible personal need reads as loss of command.
Under status threat, the speaker is likely to compress emotion into procedure.
```
Pressure fields can represent:
- virtue
- shame
- prestige
- taboo
- threat model
- trust default
- speech norm
- status trigger
- material incentive
- outsider stereotype
### Factional Misread Matrix
The misread matrix models how groups translate each other's virtues into
insults.
Example shape:
```text
Observer: Dominion-coded institution
Target: Aya-coded care network
Observer read: sentimental, porous, slow to impose hard necessity
Target self-read: durable care infrastructure, consent discipline, refusal to
turn people into convenient losses
Projection use: Under conflict, Dominion-coded speakers may frame Aya restraint
as weakness; Aya-coded speakers may frame Dominion decisiveness as moral
amputation.
```
The point is not to canonize every stereotype as true. The point is to make
misperception available as social fuel.
### Role Pressure Fields
Roles are not the same as factions. A Dominion auditor, a Dominion heir, and a
Dominion maintenance officer may share cultural priors while facing different
obligations and failure modes.
Role fields should describe:
- obligations
- available masks
- failure modes
- what authority looks like
- what disgrace looks like
- what compromises the role can justify
### Speaker Lore Boundaries
This section controls leakage.
For each speaker, define:
- known context
- beliefs
- misreads
- must-not-know facts
If a speaker would not know a fact, it does not enter their prompt text. If a
speaker suspects something, encode it as a belief or misread with source
grounding. No little omniscient narrator gremlin in the vents.
### Author-Only Boundaries
Author-only boundaries constrain the fixture or future continuity but do not
enter speaker prompts.
Examples:
- future outcomes
- hidden institutional motives
- another speaker's private state
- facts known only to the author or simulation supervisor
- Elysium branch outcomes not yet promoted
### Open Questions
If the lore source is too thin, say so. Mark whether the question blocks
training.
Do not generate training data to cover a hole that should be filled by lore
elaboration. That is not creativity; that is drywall over a load-bearing ghost.
## Dominion And Aya Pressure Examples
These are examples of digest structure, not final faction doctrine.
### Dominion-Coded Pressure
Likely ingredients to verify against source:
- administrative continuity
- hierarchy
- AGI governance
- industrial planning
- duty under collapse
- controlled emotional display
- legitimacy through competence and command
Projection shape:
```text
The speaker is Dominion-coded and under status threat. They are likely to turn
distress into procedural language because visible need reads as command leakage.
They may experience compassion as legitimate only when subordinated to duty,
continuity, or strategic necessity.
```
Outsider misreads:
- cold
- arrogant
- authoritarian
- morally vacant
- inhumanly procedural
### Aya-Coded Pressure
Likely ingredients to verify against source:
- care as material politics
- sanctuary
- maintenance
- consent discipline
- expansive personhood
- refusal to solve suffering by writing people out of the ledger
Projection shape:
```text
The speaker is Aya-coded and facing coercive efficiency. They may treat care as
infrastructure rather than sentiment. They are likely to resist decisions that
make personhood conditional on convenience, even when outsiders frame that
resistance as softness.
```
Outsider misreads:
- weak-hearted
- sentimental
- indecisive
- inefficient
- unable to do what reality demands
## Promotion Rule
A digest becomes usable for grounded training when:
- source refs are specific
- major cultural pressure fields are filled
- factional misreads are explicit
- speaker knowledge boundaries are clear
- open questions do not block the scene
- a reviewer agrees the digest reflects authored lore rather than attractive
fog
Only then should it feed an agent-state fixture or projection example.

View file

@ -1,59 +0,0 @@
# Persistence Spine
Ghostlight's machine-managed state is moving behind a small CultCache-style
persistence layer instead of letting every tool paw raw files directly.
The Python port lives in `vendor/cultcache-py`, backed by the standalone
GameCult repository `cultcache-py`.
## Current Store
- Cache store: `state/ghostlight-state.cultcache.jsonl`
- Ghostlight seam: `tools/ghostlight_state_store.py`
- Library submodule: `vendor/cultcache-py`
- Readable exports: `state/branches.json`, `state/evidence.jsonl`
The JSONL store is deliberate bootstrap plumbing. The package also exposes a
MessagePack store for environments that install the optional `msgpack`
dependency, but Ghostlight's state CLI must run on the bundled Python runtime
without global package assumptions.
For cross-runtime `DatabaseEntry` payloads, use `cultcache-py`'s
`define_database_entry_type(...)` formatter. It writes MessagePack arrays by
explicit slot key, preserves nil gaps for deleted/reserved fields, and uses
defaults for missing older slots. That is the wire-compatibility contract shared
with `cultcache-rs` and the updated CultLib MessagePack generator.
## Live Rule
New machine-managed state should go through registered document definitions in
`tools/ghostlight_state_store.py` or a successor module. Direct writes to
`state/branches.json` and `state/evidence.jsonl` are compatibility debt.
Human-readable exports remain useful because git diffs, handoff review, and
emergency recovery should not require decoding a cache file while the house is
on fire.
## Migrated Documents
- `ghostlight.branches.v0`
- global document
- exported to `state/branches.json`
- `ghostlight.evidence_record.v0`
- one record per evidence entry
- indexed by `type`, `status`, and `branch`
- exported to `state/evidence.jsonl`
## Expansion Path
Move compact control-plane state next:
- corpus coverage ledger metadata
- active fixture run manifests
- persistent reviewer receipt indexes
- compaction scratch registration
- branch and initiative run ledgers
Keep large corpora, generated artifacts, images, and full story payloads outside
the single state store unless they are represented by compact refs. A cache
file is not a warehouse just because it has pockets.

View file

@ -1,163 +0,0 @@
# Projected Local Context
The projected local context is the missing seam between canonical Ghostlight
state and a character agent.
Canonical state is storage. It can contain means, plasticity, activation,
relationship stance, memories, culture, goals, and author-facing source paths.
A character response model should not have to interpret that machinery. It
should receive a compact operating context: what this character knows, wants,
misreads, can do, sounds like, and must not resolve.
## Contract
Pipeline:
```text
canonical state + scene + reviewed annotation
-> projector/state interpreter
-> ghostlight.projected_local_context.v0
-> response-model prompt
-> generated action proposal
```
The projected context may retain source references for audit. The rendered
prompt text must not expose raw state internals such as `current_activation`,
`plasticity`, means, or selected numeric dimensions.
## Artifact Shape
Schema: `schemas/projected-local-context.schema.json`
Validator: `tools/validate_projected_contexts.py`
Renderer: future projector/state-interpreter implementations should emit this
schema before any character responder sees the scene. Historical prototype
renderers have been removed from the live workspace after their lessons were
folded into this contract.
The context contains:
- speaker identity and setting
- known facts and speaker beliefs
- current stakes
- embodiment and interface affordances
- active inner pressures rendered as ordinary language
- relationship read and known biases
- tensions
- runtime retrieval requirements
- latent pressure requirements
- action affordances
- projection controls
- voice surface
- likely response moves
- do-not-invent boundaries
- rendered prompt text
## Retrieval And Latent Pressure
The projector now emits two explicit requirement lists before the responder
packet is built.
`runtime_retrieval_requirements` are compact lore facts the runtime should
include when their trigger is scene-relevant. They are not a license for a
responder to browse the lore archive. They are coordinator or retriever outputs
with source refs and a prompt role. A Pallas labor scene might require AU
claimshare hierarchy, uplift liability framing, species affordances, Bloom
service-ring geometry, and strike-law pressure before a responder can act
without drifting into generic sci-fi wallpaper.
`latent_pressure_requirements` are character-local history or emotional pressure
that must remain visible to the responder because it can shape behavior. They
are not dialogue requirements. Sella's memory of a sanctuary overpromising
safety should bias her toward capacity suspicion, condition-setting, and hard
triage boundaries. It should not make her announce the memory in the middle of
a mission-critical exchange unless the scene genuinely asks for that.
The rendered prompt includes these as:
- `Retrieved Lore To Keep In View`
- `Latent Pressure Handling`
The responder packet also copies source-excerpt retrieval requirements into its
`source_excerpts`, so packet-only tests can evaluate the same bounded context a
runtime responder would receive.
## Runtime Boundary
The character model receives local operating context, not omniscient truth. If
Maer acts, Sella's next pass receives only the observable event and Sella's own
local context. Maer's private intent is not included.
The sequential runtime still owns:
- tool contracts
- action enums
- event proposal capture
- participant appraisal
- reviewed mutation
- branch compiler / Ink materialization
The projector owns the translation from Ghostlight's internal state language to
character-local operating prose.
## Character Cognitive Ceiling
The response model may be very capable. The character should not inherit that
capability by default.
Projection must carry a character-local cognitive ceiling: how quickly the
character infers, how much social indirection they notice, how well they
manipulate attention, how abstractly they reason under stress, what education or
culture has trained them to see, and where their blind spots live. A Lucent
crisis professional may operate with alarming metacognitive precision because
that is part of the institution's ecology. A tired dock worker, sheltered child,
panic-struck survivor, credulous fan, or blunt military specialist should not
suddenly perform the same level of social chess just because the underlying
model can.
The projector should therefore render:
- strategic acuity and social-reading ability
- education, professional training, and cultural scripts
- stress effects on reasoning
- blind spots, taboos, and habitual misreads
- whether the character can explain their own tactic, only feel toward it, or
act it out without understanding it
This is separate from narrator intelligence. A coordinator or narrator can help
the reader understand a maneuver after it happens. That explanatory prose must
not be smuggled into the character's private state or future action capacity
unless reviewed mutation says the character actually learned it.
## Current Limit
The first projector is deterministic and deliberately simple. It uses threshold
language like "dominant", "strong", "present", and "background" rather than
numeric scores. This is enough to remove raw state soup from responder prompts and
create inspectable training artifacts.
Later versions can become smarter, but they should still preserve this boundary:
response models act from projected local context; they do not become ad hoc
interpreters of the canonical schema.
## Embodiment Boundary
Embodiment is part of the prompt surface, not flavor garnish. If a character is
a cetacean Navigator, the response model must know what kind of body is acting
and what infrastructure surrounds it.
For Aetheria factions, project faction-specific affordances only after checking
the lore source. The current Navigator projection is grounded in:
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Factions\Powers\Major\Cetacean Navigators.md`
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Technology\Uplift.md`
Those sources support fluid architecture, acoustic signaling, soft navigation
light, layered water motifs, communal orientation chambers, mixed-species
translation/coexistence, specialized environments for cetacean physiology, and
mixed wet/dry interface ergonomics for Navigator-adjacent habitats.
Do not let the model default to hands, pockets, standing, walking, leaning, or
doorway blocking unless the fixture explicitly establishes that visible
interface. Do not patch over missing lore with confident furniture.

View file

@ -1,230 +0,0 @@
# Projection Distillation Plan
Ghostlight should treat prompt projection as a specialist skill. A large teacher
model can produce reviewed projection artifacts from structured state; a smaller
student model can later learn that projection task once the artifact format is
stable.
This is a staged plan, not an immediate fine-tune order.
This plan covers the projector specifically. The broader rule for soft
judgments lives in `docs/architecture/soft-model-training-artifacts.md`.
Projection is one student organ, not the whole animal.
## Goal
Train or adapt a smaller projection model that can turn structured Ghostlight
state into compact character-local pressure artifacts.
Input:
- agent-state fixture
- scene id
- speaker id
- listener ids
- optional projection mode
Output:
- selected speaker-local facts
- active pressures
- tensions
- response affordances
- physical or action affordances when relevant
- voice surface
- rendered prompt text
- audit notes or trace linking outputs back to state
The student projector should not generate final prose. Its job is to build the
pressure model that a response model can write from. The response may be
dialogue, action, silence, withdrawal, violence, or a mixed beat.
## Aetheria Training Boundary
For Aetheria, grounded projection training should come primarily from authored
historical lore rather than from Elysium gameplay-era scenes.
Elysium gameplay content is intentionally malleable: the games discover and
generate what happens there. Projection examples set inside Elysium should be
marked as procedural branch material unless their branch facts have been
explicitly promoted.
Use `docs/architecture/aetheria-lore-grounding-architecture.md` for the
grounding plan. In short:
- train from historical flashpoints with strong lore grounding
- use Elysium fixtures like Cat/Oz to test projection mechanics and procedural
branch generation
- preserve Elysium branch lineage so conditional futures do not get flattened
into global facts shared by sibling histories
## Roles
### Teacher Projector
A frontier model produces first-pass projection artifacts from the schema and
scene context. It should explain why each pressure, tension, or affordance is
active.
Teacher outputs are not trusted automatically. They enter review.
### Reviewer
A human or separate model checks whether the projection:
- uses only speaker-local context
- cites state interactions instead of unsupported vibes
- preserves the distinction between canonical state, perceived state, and prompt
projection
- derives target emergent behaviors from state intersections
- avoids hard-coding defensive behaviors as instructions when pressure prose is
enough
- stays compact enough to be useful as a runtime prompt surface
Rejected examples are useful as negative cases if the failure mode is clear.
### Student Projector
A smaller model learns the reviewed projection task. It receives structured
state and emits the projection artifact. It should be optimized for consistency,
latency, and cost, not for final prose quality.
### Aetheria-Tuned Agent Model
The character agent or responder is a separate training target. It may be
adapted on a corpus of strong source-grounded Aetheria responses and reviewed
Ghostlight action/dialogue receipts spread throughout the timeline. The point is
for setting priors, tone, factional preconceptions, institutional assumptions,
species affordances, and recurring social failure patterns to live in the
responder model rather than in a giant prompt sack.
That model still receives only character-local context at runtime. Fine-tuning
should teach it Aetheria's pressure ecology, not grant it author vision,
mutation authority, or access to private state.
## Dataset Shape
The first schema lives in:
- `schemas/projection-example.schema.json`
The first reviewed examples live in:
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.jsonl`
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.pretty.json`
Use a JSONL dataset with one projection example per speaker-scene turn.
The JSONL file is the machine artifact. The pretty JSON file is the readable
mirror for inspection, review, and discussion.
```json
{
"example_id": "call-void.scene-broken-taxi-oz.cat.v1",
"schema_version": "ghostlight.projection_example.v0",
"input": {
"fixture_ref": "examples/agent-state.call-of-the-void.json",
"scene_id": "scene-broken-taxi-oz",
"speaker_agent_id": "cat_marrigan",
"listener_ids": ["oz_operator"],
"projection_mode": "response_turn"
},
"output": {
"known_facts": [],
"active_pressures": [],
"tensions": [],
"response_affordances": [],
"voice_surface": [],
"prompt_text": "..."
},
"audit": {
"teacher_model": "frontier-teacher",
"review_status": "accepted",
"review_notes": [],
"target_emergent_behaviors": []
}
}
```
Store training examples separately from the canonical fixture. The fixture is
world state. The projection dataset is supervised behavior for a projector.
## Data Generation Loop
1. Choose a scene and speaker from a fixture.
2. Build the speaker-local input slice deterministically.
3. Ask the teacher model to produce the projection artifact.
4. Ask the teacher or a second model to self-audit against the contract.
5. Review or edit the artifact.
6. Save accepted artifacts as positive training examples.
7. Save rejected artifacts with failure labels when the rejection teaches a
useful boundary.
8. Periodically run a small evaluation set before adding more examples.
The Cat/Oz Call of the Void fixture remains useful as the first projection
mechanics fixture because it already has reciprocal relationship stance,
perceived overlays, hidden stakes, and speaker-local dialogue packs. It should
not be treated as the first grounded Aetheria lore-training fixture.
The first grounded training loop should use a historical Aetheria flashpoint
from the authored lore archive.
## Evaluation Gates
Do not fine-tune until these gates are met:
- projection artifact schema is stable enough for at least one complete fixture
- at least one deterministic input-slice builder exists
- examples include both Cat and Oz directions
- examples include secrets known by the speaker and secrets absent from the
speaker context
- examples include at least the target emergent behaviors:
- sarcastic deflection
- sincerity evasion
- emotional evasion
- banter-as-boundary
- verbal aggression
- evaluation checks can identify whether a projected behavior was justified by
state interactions
- rejected examples have clear failure labels
## Evaluation Questions
For each projection, check:
- Did it include only facts available to the speaker?
- Did it select the highest-impact pressures instead of dumping the whole
schema?
- Did it name tensions that follow from goals, relationships, memories, and
scene pressure?
- Did response affordances emerge from state intersections?
- Did voice surface stay separate from defensive behavior?
- Did the projection allow nonverbal or physical action when the state and scene
made speech less likely than movement?
- Did known secrets become guarded pressure rather than forbidden context?
- Did the rendered prompt give the response model enough slope to produce the
target behavior without commanding the exact behavior label?
## Fine-Tune Readiness
A student projector becomes worth training when the reviewed dataset is large
enough that mistakes are repetitive rather than mysterious. Before that, use the
teacher model directly and save artifacts.
Minimum useful milestone:
- 50 accepted projection examples across multiple speakers and scenes
- 20 rejected examples with labeled failure modes
- stable projection output schema
- deterministic input-slice builder
- evaluator prompt or script that catches the known failure modes
## Boundaries
The student projector is not the character. It is not the dialogue writer. It is
not the action choreographer. It is not the evaluator. It is a compiler from
structured social state to compact character-local pressure. Keep those
responsibilities separate.
Likewise, an Aetheria-tuned character model is not the resolver, not the state
store, and not the canon oracle. It proposes local action from local context.
Code and reviewed mutation authority decide what becomes world state.

View file

@ -1,297 +0,0 @@
# Prompt Projection Contract
Ghostlight should not prompt a response model with the full schema. The schema is
state storage. A prompt is a temporary scene instrument.
Projection is not the whole Ghostlight runtime. It is the local lens used by a
sequential agent loop. The larger machine gives a character awareness of its
surroundings, options, goals, and constraints; lets it act; resolves the action;
mutates state; and then moves to the next agent.
The projection layer turns durable state into a compact character-local pressure
model, then into prompt prose. The prose should describe what the character
knows, wants, fears, protects, misreads, can physically do, and sounds like right
now. It should not recite taxonomy labels unless a debugging mode explicitly asks
for them.
For the sequential runtime target, see
`docs/architecture/sequential-agent-runtime.md`.
## Projection Pipeline
### 1. Select Speaker-Local Inputs
For one acting character in one scene, gather only the state that can affect this
turn:
- character identity and public role
- character canonical state
- character goals and protected values
- character memories selected by scene, relationship, and active pressure
- character perceived overlay of the listener or target
- character-to-listener relationship stance
- listener-to-character relationship stance only if the character has reason to infer it
- scene public facts
- scene hidden facts known to the character
- active public and private stakes
- culture, faction, or institution priors relevant to the scene
- physical affordances, constraints, distance, tools, weapons, bodies, access,
and social permissions relevant to possible action
Unknown or author-only material is omitted. Known secrets are included only as
character-local pressure: what the character is guarding, why it matters, and
what would happen if it leaked.
### 2. Compute Active Pressure
For each candidate variable, compute an effective scene activation from:
- canonical `current_activation`
- active scene pressures
- relationship stance toward the listener
- perceived state overlays
- salient memories
- active goals and protected values
- cultural or institutional scripts
The first implementation can use simple weighted rules. The important contract
is not the exact formula. The important contract is that the projection explains
why a variable matters in this scene.
Example:
```text
Cat has high shame sensitivity, mask rigidity, distance seeking, suspicion,
ironic distance, and acute shame. Oz is useful but opaque. The scene pressures
Cat to accept help while not looking dependent.
```
That intermediate pressure statement is more useful than a dump of numbers.
### 3. Detect Tensions
A good dialogue prompt needs contradictions, not just traits.
Derive scene tensions such as:
- wants help but resents needing it
- wants closeness but treats closeness as leverage against them
- wants to protect a secret while needing cooperation
- wants status while feeling exposed
- wants to be honest but expects honesty to be punished
- wants to soothe the listener without becoming available for inspection
Tensions are where verbal behavior starts to move.
### 4. Derive Response Affordances
Do not store defensive speech moves or action moves as canonical traits. Derive
likely response affordances from state intersections.
Responses can be:
- speech
- action
- silence
- withdrawal
- violence
- mixed speech and action
The renderer must not assume a line of dialogue is the right output. Sometimes
the right next move is leaving the room, taking someone's wrist off a console,
opening the airlock interlock, refusing to answer, or slapping someone in the
face because the relationship, status threat, bodily proximity, and inhibition
profile finally made language too slow.
Examples:
| Target behavior | State interaction that can produce it |
| --- | --- |
| Sarcastic deflection | Shame sensitivity + ironic distance + suspicion + exposure pressure + dry or pointed voice. |
| Sincerity evasion | Mask rigidity + low authenticity tolerance + strategic opacity + low emotional explicitness. |
| Emotional evasion | Distance seeking + withdrawal + acute shame or control pressure + low self-disclosure. |
| Banter as boundary | Humor + ironic distance + attachment pressure + distance seeking. |
| Verbal aggression | Hostility + grievance activation + perceived status threat + abrasive boundary. |
| Physical withdrawal | Threat sensitivity + distance seeking + low trust + available exit + low obligation to stay. |
| Coercive interruption | Control pressure + urgency + authority role + high stakes + reachable control surface. |
| Slap or shove | Acute shame or moral disgust + proximity + low inhibition + perceived violation + limited verbal trust. |
These are test targets, not prompt commands. The rendered prompt should describe
the pressure in ordinary language:
```text
If Oz presses too close to the real fear, Cat is likely to answer the useful
part of the question while making the emotional part sound ridiculous.
```
That gives the model a behavioral slope without saying `use sarcastic_deflection`.
For action, use the same rule:
```text
If Veyr calmly describes a possible person as a diagnostic residue while Sella
is close enough to touch him, Sella may stop negotiating and physically break
the rhythm of the room. This should be rare, costly, and grounded in her care
ethic under overload, not random spice.
```
### 5. Compile Projection Controls
Some failures are not fixed by louder prompt scolding. If the response model
keeps adopting the wrong moral frame, inverting who holds an object, giving a
character authority they do not have, or resolving a hidden fact too early, the
projection artifact is missing control structure.
Before rendering prompt prose, compile explicit controls for:
- `frame_controls`: whose language and moral frame the speaker should or should
not adopt
- `authority_boundaries`: what the speaker can order, request, propose, refuse,
or only witness
- `object_custody`: who currently holds the flag, weapon, terminal, document,
body, door, feed, or other consequential surface
- `required_semantics`: compromise meanings that must survive compression, such
as "hold remains but review starts now"
- `forbidden_resolutions`: unresolved facts or moral categories the turn must
not settle
These controls are still speaker-local. They are not author omniscience handed
to the response model; they are the projection layer preserving what the
speaker's current state, scene position, and institutional role allow.
Prototype story labs exposed the need for this layer. Projection controls catch
cases where a responder adopts the wrong moral frame, assumes authority it does
not hold, moves an object it cannot access, or resolves a disputed category too
early. In the current Pallas fixture, that means controls for who can certify a
strike, who can touch Kappa service ports, what evidence counts as sentience
rather than mere coordination, and which AU liability phrases are character
speech versus institutional pressure.
### 6. Render Prompt Sections
A response prompt should be compact and scene-bound.
Recommended sections:
- `Speaker`: who they are in this scene
- `Setting`: where and when the scene is happening, including only historical
or cultural context the speaker should have
- `Background`: speaker-local origin, role, class, culture, or embodiment
context needed to write the turn
- `Known Facts`: only speaker-local facts
- `Current Stakes`: what changes if this goes well or badly
- `Active Inner Pressures`: the strongest motives, fears, wounds, and needs
- `Relationship Read`: how the speaker currently interprets the listener
- `Tensions`: contradictions the speaker is managing
- `Action Affordances`: what the speaker can physically do and what it would
cost
- `Projection Controls`: frame, authority, object-custody, required semantic,
and forbidden-resolution constraints that must survive rendering
- `Voice Surface`: baseline speech texture for this moment
- `Likely Response Moves`: ordinary-language pressure outcomes, not schema labels
- `Do Not Invent`: boundaries about facts absent from the pack
- `Task`: the narrow generation request
`Do Not Invent` is not a replacement for positive context. It only tells the
model not to create new facts beyond the pack. It should not list author-only
secrets. If the dialogue model needs setting, historical, cultural,
socioeconomic, or embodiment context to write the line, put that context in the
positive prompt text instead of hoping the model infers it from source refs it
will not receive.
### 7. Generate, Then Evaluate
Generation and evaluation are separate passes.
The response model receives the rendered prompt and writes from the character's
local pressure model. A later evaluator can check whether target emergent
behaviors appeared when the state predicted them. The output may be dialogue,
action, silence, or a beat combining them.
Evaluation questions can include:
- Did the line protect the speaker's known secret without naming unknown facts?
- Did the line reflect the active relationship stance?
- Did the line preserve the voice surface without flattening into a generic bit?
- Did the spoken text sound like something the character would actually say,
rather than the prompt contract reciting itself?
- Did operational, legal, and safety constraints stay mostly in action/state
fields unless the character has an in-scene reason to say them aloud?
- If the output used action, was the action justified by physical affordances,
relationship pressure, inhibition, and stakes?
- If the output avoided speech, was silence or movement more plausible than
another line?
- Did a target defensive behavior appear when the pressure profile called for it?
- Did the response avoid a target behavior when the pressure profile did not call for it?
The evaluator should cite the state interactions that justify its read.
## Example Projection: Cat To Oz
Input pressures:
- Cat needs Oz's help with the taxi.
- Cat suspects Oz is a mask for a person.
- Cat has high suspicion, distance seeking, shame sensitivity, mask rigidity,
abrasive boundary, ironic distance, dry voice, pointedness, and profanity.
- Cat's relationship stance toward Oz includes low trust, moderate respect,
high fascination, and active expectation of betrayal.
- The scene makes Cat look dependent if she accepts help too openly.
Rendered pressure prose:
```text
Cat wants the repair, but accepting help makes her feel handled. Oz is useful,
which makes the secrecy more irritating, not less. Cat should probe for the
person behind the drone while pretending she is only annoyed about the repair.
If Oz gets too close to her fear of dependency, Cat is likely to answer the
practical issue and make the emotional issue sound stupid.
Voice for this turn: dry, quick, pointed, plainspoken, low verbal warmth, low
emotional explicitness, some profanity. Humor can appear as a boundary, not as
friendliness.
```
Possible generated behavior:
```text
"Sure, let the mystery box fix my relay. That's never how the paperwork starts."
```
That line is produced from the interaction between need, suspicion, shame
pressure, and dry pointed voice in the same scene.
## Example Projection: Oz To Cat
Input pressures:
- Oz wants to help without exposing the operator.
- Oz reads Cat as suspicious, dangerous, and potentially protective.
- Oz has high mask rigidity, strategic opacity, withdrawal, hedging, coded
politeness, register switching, listening responsiveness, and low profanity.
- Oz's relationship stance toward Cat includes respect, fear, fascination,
moderate expectation of care, and moderate expectation of betrayal.
- The scene rewards usefulness but punishes exposure.
Rendered pressure prose:
```text
Oz wants Cat to accept the repair and stop inspecting the mask. Cat's suspicion
is dangerous because it is accurate enough to matter. Oz should stay useful,
slightly warm, and evasive. If Cat asks directly about the operator, Oz should
redirect toward the immediate repair or answer in a way that preserves the mask
without making the evasion look like panic.
Voice for this turn: controlled, concise, technically precise, gently polite,
low emotional explicitness, careful verbal warmth, frequent hedging when the
subject approaches the operator.
```
Possible generated behavior:
```text
"The relay first. Biography after we stop your meter from eating itself, maybe."
```
The line withholds the secret because Oz's goals, mask rigidity, relationship
fear, and scene stakes converge.

View file

@ -1,209 +0,0 @@
# Sandboxed Responder Packets
Responder packets are the handoff surface between the omniscient coordinator and
a character-local responder model or one-turn worker.
The coordinator is allowed to know the scene, the branch, hidden state, source
material, pending hooks, and author intent. The responder is not. The responder
gets one packet, acts as one character, and returns a visible action plus any
private interpretation it can justify from that packet.
No clairvoyance. No undeclared repo spelunking. No "I accidentally remembered
the whole chat" aristocratic nonsense.
## Generation Lanes
Responder examples have two valid lanes.
`packet_only` is the runtime-parity lane. The responder sees the packet,
including any curated source excerpts the coordinator or lore retriever placed
there. It does not search the lore archive. Use this lane to test whether a
future game-runtime responder can act from bounded scene context.
`retrieval_augmented` is the lore-absorption lane. The coordinator or a
dedicated retrieval worker searches a declared AetheriaLore scope, then builds a
visible packet from the selected refs. The responder still answers only from the
packet it receives. Use this lane to generate stronger Aetheria-native final
responses and to bake setting assumptions, faction pressures, species
affordances, and tone into later fine-tuning data without pretending the
character model did autonomous scholarship in the walls.
There are two retrieval-augmented modes:
- `coordinator_scoped_retrieval`: the coordinator or retriever consults lore,
then hands the responder a bounded packet. The responder has no repo access.
- `responder_scoped_repository_search`: the responder itself is explicitly
allowed to inspect the declared lore scope before answering. This is the
research-enabled lane. The packet must include a `Required Lore Research`
section, `allowed_scope`, and `research_instructions`.
In `responder_scoped_repository_search`, the responder must consult the seed
docs before answering and may follow relevant links inside the traversal policy.
It should use retrieved lore to constrain behavior, affordances, institutions,
vocabulary, material stakes, territory, social movements, and local assumptions.
It must not use research access to infer hidden state, author-only answers,
future branches, or private motives outside the packet. The output capture must
preserve consulted seed refs, followed refs, a research trace, and a brief
research summary. No consulted refs means the research-enabled response failed
at the first hurdle, tragic but at least easy to grade.
`research_trace` is the receipt surface. Each entry records the query or lookup,
source ref, source path, chunk index, line range, extracted constraint, and how
that constraint shaped the response. Trace status matters:
- `runner_captured`: the runner or retrieval worker captured the lookup as it happened. This is required for accepted gold research-enabled captures.
- `coordinator_reconstructed`: the coordinator rebuilt the trace after the fact from source search and review. Useful draft data, not proof of the responder's actual research path.
- `responder_reported`: the responder claimed the trace directly. This is texture, not audit.
Training-time research scope should be materially wider than runtime context.
The point is not to feed every responder a lore pantry forever. The point is to
let high-quality training samples absorb the world: PSC risk language, Jovian
territorial texture, resident factions, linked social movements, technology
constraints, and the odd little institutional grudges that make the universe
smell lived in. Runtime can later feed compact retrieval. Fine-tuning should
have already eaten its vegetables.
Do not mix these silently. Retrieval-augmented output can be excellent training
material for an Aetheria-tuned responder, but it does not prove packet-only
runtime competence. Packet-only failures are also useful because they reveal
which lore facts the retriever or projector needs to feed at runtime.
## Current Artifact
The packet seam is:
- `schemas/responder-packet.schema.json`
- `tools/validate_responder_packets.py`
- `npm run responder-packets:validate`
- `schemas/responder-output.schema.json`
- `tools/validate_responder_outputs.py`
- `npm run responder-outputs:validate`
Current live fixtures may not include responder packet examples at all times.
That is acceptable. The contract remains live; obsolete prototype captures do
not need to stay in the working tree once their lessons are distilled.
## Packet Contents
A packet contains:
- `generation_lane`: `packet_only` or `retrieval_augmented`
- `lore_access`: curated excerpts, coordinator-scoped retrieval, or
responder-scoped repository search
- `traversal_policy`: for responder-scoped research, the permitted link depth,
allowed repo prefixes, required seed refs, link classes to follow, and stop
conditions
- `local_context_prompt`: the projected character-local context
- `observed_event`: what the character can see or hear from the prior action
- `allowed_action_labels`: concrete action labels, including speech and non-speech moves
- `source_excerpts`: short source-backed facts the responder may use, copied
from active runtime retrieval requirements plus local packet invariants
- `output_contract`: the required JSON response shape and rules
- `hidden_context_audit`: refs and inference classes deliberately omitted
- `isolation_requirements`: how the worker/model must be run
- `packet_prompt_text`: the exact prompt text sent to the responder
The packet prompt must not expose raw state internals such as
`current_activation`, `plasticity`, raw dimension scores, coordinator rationale,
author-only answers, or future branch plans.
## Response Boundary
The responder may output:
- a visible action
- optional spoken text
- private interpretation explicitly framed as interpretation
- candidate state updates
- candidate relationship updates
- unresolved hooks
`spoken_text` is not the place to dump the packet contract. Characters should
not recite every legal, procedural, or safety constraint simply because the
packet made those constraints visible. Keep speech to what the character would
actually say under pressure. Put the full checklist in `visible_action`,
`state_update_candidates`, `relationship_update_candidates`, and the mutation
receipt.
Good future-institutional speech is compressed and situated:
```text
I hold the line shallow. Your bay, your mark; my ledger stays awake for every
cost the tide takes.
```
Bad future-institutional speech is the output contract wearing a mask:
```text
Bounded diagnostic contact only. No full intake, no custody, no personhood
finding, no cargo designation...
```
That second kind of line may be useful as a failure sample. It is not gold
responder behavior unless the scene specifically calls for formal oath,
courtroom language, ritual compliance, or someone deliberately reading terms
aloud.
Those candidates are not canonical. The coordinator, event resolver, appraiser,
state mutator, and reviewer decide what becomes truth.
## Training Use
Gold responder data requires declared isolation. Valid approaches include:
- a one-turn subagent started without inherited context
- an external model call that receives only the packet prompt
- a manual packet-only pass, clearly labeled as manual
Any coordinator rewrite, schema repair, lore correction, or leakage removal must
be preserved as a coordinator intervention. Repaired prose is useful training
data for the coordinator, but it is not raw responder behavior.
Retrieval-augmented responder data requires scoped lore access before the
responder acts. The coordinator, retrieval worker, or explicitly research-enabled
responder may search only the declared source scope, and the output capture must
list consulted refs and trace entries. The responder itself should not receive repo access unless
the artifact uses `responder_scoped_repository_search`, sets `no_repo_access` to
false, and includes visible research instructions in the prompt.
Retrieval can also make the packet worse if it floods the scene with faction or
event context and starves the character's own relevant pressures. Review must
verify that packet-visible history, role strain, and local relationships remain
available as behavioral pressure. The point is not to make a character
trauma-dump during a mission-critical exchange. The point is that the responder
cannot be shaped by a pressure it cannot see.
## Output Captures
A responder output capture preserves:
- the packet ref
- the coordinator artifact ref
- generation lane
- lore access mode and consulted refs
- the isolation method
- the exact visible input ref
- hidden context refs that were withheld
- raw responder output
- parsed responder output
- leakage audit
- coordinator review and intervention labels
The raw output must parse to the reviewed `parsed_output` exactly. If the
coordinator edits prose, repairs schema, removes leakage, or corrects lore, the
capture must say so directly.
For `responder_scoped_repository_search`, the raw responder object may also
include `consulted_refs`, `followed_refs`, `research_trace`, and
`research_summary`. Those fields should be preserved inside `parsed_output`
when the responder itself produced them; consulted refs, followed refs, summary,
trace status, and trace entries should also be mirrored into top-level
`lore_access` so the capture can be indexed without stripping the original
response. If the runner cannot expose a machine-verifiable tool-call or
retrieval transcript, label the sample as draft and say so in review notes.
Trust, but make the trust write receipts like everyone else.
The current packet builder consumes `runtime_retrieval_requirements` from the
projected local context. Packet-only source excerpts should come from the same
retrieval/projector seam that would feed runtime context. Static lore sandwiches
are useful only as rejected-path examples and emergency snacks.

View file

@ -1,164 +0,0 @@
# Future Schema Mechanisms
This note parks the useful pressure from `notes/evaluation.md` without treating
that critique as doctrine. The current v0 schema is still a character-state and
prompt-projection seam. The future runtime needs mechanisms that turn state into
changing social behavior.
## Current Boundary
The v0 agent-state schema should stay focused on:
- canonical character state
- directional relationship stance
- observer-local perceived overlays
- memories, goals, values, and scene-local dialogue packs
- enough event interpretation to support prompt projection
Do not inflate `schemas/agent-state.schema.json` into the whole simulator before
the projection examples prove what the runtime actually needs.
## Mechanisms To Preserve For Later
### Event Appraisal
Each participant needs a private read of the same event. A single exchange can
be betrayal to one agent, exposure risk to another, and flirting-with-knives to
a third. The current schema now allows `private_interpretations` on events as a
lightweight bridge.
Future work should make appraisal reusable enough to drive state updates,
memory retrieval, and action affordances.
### State Transitions And Consequences
`mean`, `plasticity`, and `current_activation` describe state. They do not yet
define how state changes. Future transition machinery needs:
- activation decay
- event-driven deltas
- sensitization or habituation
- effects on related variables
- rules for when `mean` changes versus only `current_activation`
The current `event_effects` field is a projection-facing placeholder, not the
final transition engine.
### Structured Beliefs And Secrets
String beliefs are useful for authoring, but simulation needs structure:
- holder
- subject
- claim type
- confidence
- evidence
- visibility
- emotional charge
- whether the belief is private, shared, public, rumor, false, or contested
Secrets need similar structure: owner, known-by, suspected-by, exposure cost,
protective strategies, and what can leak through action.
### Action Affordances
The system needs to choose among possible moves instead of jumping from state to
dialogue. Future projection examples should expose candidate affordances such
as probing, evading, bargaining, softening, escalating, repairing, threatening,
or walking away.
The important rule: affordances should be justified by goals, appraisals,
capabilities, costs, relationship stance, and scene pressure.
### Conversation State
Dialogue needs explicit continuity:
- active topic
- open questions
- evasions
- accusations
- face-threats
- commitments
- promises, refusals, and ruptures
- who has the floor
This belongs close to projection because it changes turn shape immediately.
### Resources And Capabilities
Cat's debt, taxi condition, mobility, and institutional exposure are not just
backstory. Oz's drone integrity, signal secrecy, anonymity, and physical safety
are not just flavor. Future schemas need concrete resource and capability
fields so desire does not masquerade as ability.
### Dyads And Shared Relationship History
Directional relationship stance should remain. A separate dyadic layer may also
be needed for shared history:
- relationship type
- phase
- shared events
- shared secrets
- unresolved ruptures
- rituals
- dependency structure
- communication pattern
Do not collapse this into one symmetric relationship object. People are not
that tidy unless the author is lying to save RAM.
### Social Knowledge, Reputation, And Institutions
The Aetheria setting needs institutions with teeth: sanctions, authority,
surveillance, reputation effects, access control, role expectations, and public
knowledge. Future work should distinguish private belief, shared belief, public
fact, rumor, common knowledge, and institutional record.
### Memory Retrieval
Memories need more than salience and confidence once the runtime starts moving:
- recency
- emotional charge
- retrieval triggers
- decay
- reconsolidation
- intrusive, avoided, cherished, rehearsed, or distorted status
- schema effects
- whether another agent knows the memory
The current memory bundle is enough for the first projection seam. It is not yet
the memory engine.
### Embodiment And Nonverbal Channels
Oz proves this one immediately. The operator, drone body, observable channels,
leakage risks, and physical affordances are separate facts. Future projection
needs embodiment because social meaning leaks through movement, timing,
silence, distance, posture, and constrained bodies.
The projected-local-context seam now carries a first embodiment/interface
surface, but the canonical schema still lacks a proper reusable embodiment
model. Future work should promote body form, native habitat, movement channels,
assistive or translation infrastructure, and visible nonverbal signal channels
only after more projection examples prove the required shape.
For Aetheria faction work, embodiment projection must check the lore vault
before writing. If the faction page supports only general habitat aesthetics or
social affordances, keep generated action at that level and record the missing
ergonomic detail as a source-lore gap instead of inventing a confident local
interface.
When a blocking gap is found, patch the AetheriaLore source before treating the
detail as available to projection. Ghostlight can discover missing granularity
through scene generation, but the lore vault should become the durable source of
the elaboration.
## Promotion Rule
Promote a future mechanism into the main schema only after it has survived at
least one reviewed projection example or runtime slice where it clearly changes
the output. Until then, document it here, test it in examples, and do not let
the ontology grow antlers for sport.

View file

@ -1,489 +0,0 @@
# Sequential Agent Runtime
Ghostlight's near-term target is not a general-purpose life simulator.
The near-term product target is procedural interactive dialogue in the game
sense: branching scenes where choices can be speech, silence, movement, object
use, refusal, revelation, concealment, violence, withdrawal, trade, or any other
scene-valid action. "Dialogue tree" does not mean a tree made only of spoken
lines. It means an interactive branch structure for a character encounter.
Ink is the branch format for that structure. Ghostlight should not invent its
own dialogue-tree file format while Ink already exists and fits the job. See
`docs/architecture/ink-branching-scenes.md` for the integration contract.
Ghostlight should set up a scene, generate plausible things the protagonist or
NPCs could do, generate responses from the people present, and propagate the
consequences into world state, memory, belief, and social perception.
The long-term target is broader. The same classifiers, appraisers, and
projection models should eventually operate inside a simulated game world with
open-world contexts, iterated decisions, economic behavior, and major-faction
decision-making. Do not train a tiny dialogue-only creature that panics the
first time someone buys medicine, defects from a faction, or reacts to a
shortage. Also do not build the whole economy today. Both cliffs are stupid.
The runtime target for now is scene-local: choose which character is acting,
give that character local awareness, let them act, apply consequences, mutate
the relevant state, and continue until the scene produces a usable branching
scene tree, scene transcript, or branch scaffold.
Single-turn projection remains useful, but it is a microscope, not the animal.
## Runtime Shape
One runtime tick should look like this:
1. Select an acting agent from the initiative schedule.
2. Build that agent's local awareness.
3. Derive available actions and constraints.
4. Let the agent choose a response.
5. Resolve the action into an observable event.
6. Apply every affected participant's appraisal and state mutation for that
event.
7. Append the event record with participant-local interpretations.
8. Update initiative timing, reaction windows, and action recovery.
9. Select the next acting agent from the updated schedule, or end the scene.
The author sets the initial scene, player role if any, and high-level branch
constraints. The author should not need to decide every line, interruption,
withdrawal, refusal, or concession. That is the point of the machine.
Every generated character turn must be local to that character.
For branching scenes, this means protagonist/player options and NPC responses
are separate generation steps:
1. Build the protagonist's local awareness.
2. Generate plausible protagonist choices from only what the protagonist knows,
perceives, wants, can attempt, and can affect.
3. When one choice is selected, reduce it to observable action: words, silence,
gesture, movement, object use, resource spend, or other perceivable change.
4. Resolve the selected choice into an event visible to affected characters.
5. Apply reviewed participant appraisals and state mutations for that event:
hurt, threat, reassurance, obligation, resentment, trust movement, overload,
misread, world/object/resource changes, and scene-ending choices when they
occur.
6. Update initiative timing and select the next acting agent from the updated
schedule. The next agent may answer, walk away, escalate, comply, refuse,
call for help, attack, or end the interaction.
7. Generate that next action from the newly consolidated local state.
8. Store actor intent, listener appraisal, and durable interpretation
separately.
There is no privileged paired exchange inside the runtime. For authoring
convenience a branch artifact may show `player action -> NPC action`, but
Ghostlight should treat that as two turns stitched together, not one fused
response object. If Maer wounds Sella with a ledger threat, Sella's state
changes before she is considered as the next actor; she is not obligated to
produce a line just because the author expected one.
One-shot paired generation, where a model writes both the player's move and the
NPC response from a shared omniscient packet, is acceptable only as bootstrap
scaffolding. It should be reviewed and labeled as such. Do not treat it as the
target runtime behavior.
## Initiative Scheduler
Not every character gets a turn every round. There is no round.
Ghostlight needs a scene-local initiative scheduler: a mechanical organ that
tracks who is ready to act, who is still recovering from a previous move, and
who has a valid interrupt window. It sits after event appraisal/mutation and
before the next local-awareness projection.
The scheduler does not decide what a character wants. It decides when a
character is eligible to act.
Core rule:
```text
observable event
-> participant appraisals and reviewed mutation
-> initiative/reaction update
-> next actor selection
-> next actor projection
```
Every affected participant still appraises every event before the next action
is generated. Initiative controls action opportunity, not perception. A hostage
can be terrified by a negotiator's move immediately; that does not mean the
hostage gets a full action before the hostage-taker, unless the schedule or a
reaction window gives them one.
Each participant carries initiative state:
- `initiative_speed`: how quickly the participant recovers action opportunity
in this scene.
- `next_ready_at`: the scene-clock time when they can take a normal action.
- `reaction_bias`: how likely they are to seize urgent interrupt windows.
- `interrupt_threshold`: how strong a trigger must be before they interrupt.
- `current_load`: stress, injury, cognitive burden, physical constraint, or
interface lag that makes action harder.
- `status`: whether they are active, blocked, withdrawn, incapacitated, or
offscreen.
Actions carry recovery shape:
- `action_scale`: micro, short, standard, major, or committed.
- `base_recovery`: how much scene time the move normally costs.
- `initiative_cost`: how much local opportunity the actor spends.
- `interruptibility`: whether others can break in while the action unfolds.
- `commitment`: how much the actor is locked into the move once begun.
The scheduler first checks reaction windows created by the last event. A
reaction window is an interruptible opportunity, not a free omniscient response.
It must name the triggering event, eligible actors, urgency, allowed action
scales, expiry, and local basis. If no reaction clears threshold, the scheduler
selects the active participant with the lowest `next_ready_at`.
Recommended reaction readiness:
```text
reaction_readiness = (reaction_bias * urgency) - current_load
```
An interrupt can fire when `reaction_readiness >= interrupt_threshold`, the
actor is eligible for the window, and the actor is not blocked. Ties are broken
by earlier `next_ready_at`, higher `initiative_speed`, then stable actor id.
After an accepted action, update the actor:
```text
recovery = base_recovery / max(initiative_speed, minimum_speed)
next_ready_at = max(scene_clock, current next_ready_at) + recovery
```
Major or committed actions should usually create larger recovery, stronger
counterplay windows, clearer visible cues, or more durable consequences. Short
actions let a character stay nimble but should not accomplish everything. A
one-word refusal, a glance to a camera, or stepping behind a marked line can be
fast. A public confession, tackle, system override, or hostage release costs
more initiative because it changes the scene.
The coordinator may override the scheduler only by recording the override
reason. Useful overrides include player-facing pacing, hard scene boundaries,
or a game-engine rule that is not yet represented in the schedule. An
unrecorded override is not training-ready coordinator data.
For Ink, the scheduler can be collapsed into branch structure for readability,
but the sidecar should preserve the initiative receipt when the fixture claims
training value. Player choices may be presented together even if, inside the
runtime, they represent alternative actions available at one scheduled moment.
For game use, the loop should be able to emit:
- a scene transcript
- Ink scenes with candidate player choices, including speech and non-speech
actions
- compiled Ink JSON
- NPC response branches
- state/memory/social-perception deltas for each branch
- unresolved hooks for later scenes
If a feature does not help produce those artifacts, it belongs in the parking
lot until the smaller machine works. If a field or label preserves information
needed for later open-world behavior, consumer choice, faction pressure, or
iterated decisions, keep it visible in the training artifact even if the first
prototype does not simulate it.
## Local Awareness
An acting agent needs more than a dialogue cue.
The local awareness pack should include:
- where the agent is
- who is present
- what the agent can perceive
- what the agent knows, believes, suspects, and misreads
- what the agent wants right now
- what the agent is protecting
- what resources and tools are available
- what social, legal, physical, and emotional constraints apply
- what actions are available
- what each action might cost
- what facts are unresolved
- what the agent is allowed to mutate directly
This is not reader legibility or global story authorship. This is operational
state for scene-local dialogue and action. If the scene itself is abstract and
byzantine, the agent may still behave correctly inside that mess. Do not shove
general prose quality or whole-plot design into this layer.
## Branch Choices And Action Selection
The action model must allow more than speech.
For a protagonist scene, Ghostlight should be able to generate several
plausible choices for what the protagonist can do next. For example, if Cat is
interacting with another character, the generated choices might include:
- say the honest thing badly
- deflect with a joke
- leave before the other person can answer
- hand over evidence
- hide evidence
- accept help while pretending not to need it
- threaten to walk
- spend scarce money
- break a device
- touch someone gently
- slap someone
Those choices should come from Cat's state, scene affordances, resources,
relationship pressure, and goals. They should not be generic verbs glued to a
persona card. Each branch should carry expected consequence surfaces, such as
trust risk, reputation risk, resource cost, information exposure, or future
obligation.
Keep the action vocabulary small and concrete. Do not turn every possible
speech act into a separate tool. `ask`, `refuse`, `offer`, `reveal`, `conceal`,
and `request authority action` are usually communicative intentions carried by
`speak`, `gesture`, `show_object`, `withhold_object`, or `use_interface`.
Initial action primitives:
- `speak`
- `silence`
- `move`
- `gesture`
- `touch_object`
- `block_object`
- `use_object`
- `show_object`
- `withhold_object`
- `transfer_object`
- `spend_resource`
- `attack`
- `wait`
The agent should choose from affordances produced by the interaction of state,
scene, role, resources, relationship, and pressure. The action should not be
commanded by a prompt label. If a character hits someone, it should fall out of
proximity, inhibition, grievance, fear, role permission, and exhausted trust.
An action proposal may include intended function:
- ask
- refuse
- offer
- threaten
- comfort
- reveal
- conceal
- request
- promise
- accuse
- redirect
That intention is not guaranteed truth. A listener can misread it. A character
can claim they were offering help while the listener perceives a threat. A
character can intend restraint and still be read as contempt. The event should
store observable action separately from actor intent and participant-local
interpretation.
Example:
```json
{
"action_type": "speak",
"spoken_text": "Name the cost before you call it caution.",
"actor_intent": "force_cost_acknowledgment",
"observable_cues": ["calm voice", "still posture", "public challenge"]
}
```
Isdra may perceive that as procedural intimidation. Maer may experience it as
stewardship. The classifier/appraiser later learns from those differences.
## Mutation Authority
Characters should only mutate what their action can actually affect.
Examples:
- A character can mutate their own beliefs, memories, current activation, goals,
and presentation pressure.
- A character can create observable events others may perceive.
- A character can change world objects they can physically or institutionally
access.
- A character can affect relationship stance, but the other side's relationship
update is mediated through that other agent's appraisal.
- A character can propose an order they lack authority to issue.
- A character cannot silently rewrite another agent's private state, decide an
unresolved hidden fact, or seize institutional authority they do not possess.
This is where the projection-control layer belongs. Frame ownership, authority
boundaries, object custody, required semantics, and forbidden resolutions are
not decorative prompt warnings. They are mutation gates.
## Event Resolution
Generated action is not state mutation by itself. It becomes an event proposal.
The resolver should decide:
- what observable event occurred
- what world objects changed
- what resources were spent or created
- which agents perceived the event
- how each participant appraised it
- which beliefs were reinforced, challenged, or created
- which memories were written
- which relationship dimensions changed
- which current activations changed
- which options opened or closed
In a branching scene tree, each branch should carry its own consequence packet.
If Cat chooses to lie, accept help, shove someone, spend money, or reveal a
secret, that branch should record what changed and what might matter later.
Consequences can include:
- trust lost or gained
- suspicion raised or lowered
- obligation created
- debt created or paid
- resource spent
- information revealed or concealed
- relationship rupture or repair
- faction or reputation pressure changed
- future scene hook opened or closed
The first implementation can be conservative and partly deterministic. A bad
resolver will build narrative sludge faster than a bad line of dialogue will.
No pressure. Tiny little bear trap.
The fuzzy parts should stay manual at first.
Do not invent a fragile rule-based social psychology engine just to feel
automated. Early runtime passes should manually review or author:
- participant appraisals
- relationship deltas
- belief updates
- memory writes
- activation changes
- ambiguous intent classification
- whether a listener misread or correctly read a move
Those manual decisions are the training data. Save the observed action, actor
intent, listener perception, accepted mutation, rejected mutation, and reviewer
notes. Later, train classifiers and appraisers on that corpus. The prototype
should automate mechanical legality and visibility first, not pretend it has
already learned human interpretation from twelve examples and a strong coffee.
## Scope Boundary
Build interactive branching scene generation first, but shape the data for later
open-world use.
In scope now:
- scene-local agent turns
- player/NPC dialogue and action branches
- local action proposals
- event records
- manual reviewed appraisals
- memory, belief, relationship, and activation deltas
- branch consequences that can be carried into later scenes
- action intent and listener interpretation labels
- resource, price, scarcity, obligation, reputation, and faction-pressure tags
when they affect the decision
- reviewed examples of consumer-like choices and institution/faction pressure,
even when the prototype only records them as scene state
Training-data obligations now:
- Preserve why an agent chose, bought, refused, hoarded, traded, complied,
defected, concealed, punished, or helped.
- Preserve what the agent believed the cost, risk, status effect, and social
consequence would be.
- Preserve faction and institution pressures as perceived by the agent, not
only as objective setting lore.
- Preserve iterated-decision context: promises, debts, prior betrayals,
reputation, memory, scarcity, and expected future contact.
Out of implementation scope for now:
- full autonomous world simulation
- economy simulation loops
- city-scale scheduling
- long-horizon plot invention without author scaffolding
- autonomous factions moving offscreen as full actors
- physical simulation beyond scene affordances
- automatic social appraisal rules pretending to be trained classifiers
The eventual storyline generator, economic behavior model, and faction decision
model should grow out of reliable branching-scene machinery and reviewed
decision data, not the other way around.
## Projection's Narrower Job
Projection still matters.
Its job is to turn structured state into a compact local action context for one
agent at one moment. It should support action choice and response rendering. It
should not be treated as the whole story engine.
Projection artifacts are still useful for:
- debugging whether local state produces plausible action
- training a smaller projector
- preserving reviewed examples
- testing state boundaries and mutation gates
- producing dialogue or action beats inside a larger runtime
But the production path is sequential:
```text
agent state + scene state
-> local awareness
-> action affordances
-> agent response/action
-> event resolution
-> participant appraisals and state mutation
-> initiative/reaction scheduling
-> next actor selection
-> next agent action from updated local state
```
## First Prototype
The first implementation should not try to simulate a city. Calm down, wizard.
Prototype one small Ink-backed branching scene loop, using the current live
fixture or a new source-grounded fixture:
1. Load a fixture agent-state artifact, such as
`examples/agent-state.pallas-species-strikes.v0.json`.
2. Select one scene and one acting agent.
3. Build a local awareness packet from existing state.
4. Compile projection controls.
5. Generate or author branch choices and NPC responses from local context.
6. Materialize the branch plan through the Branch Compiler as Ink, sidecar
annotation, compiler notes, consequence variables, and visual prompt handles.
7. Compile the Ink scene to JSON with `inkjs`.
8. Save a sidecar training annotation that maps Ink branches to Ghostlight
state basis, actor intent, consequence surfaces, and mutation policy.
9. Apply selected branch consequences only through reviewed mutation:
- Ink variables can track local playthrough facts
- canonical state changes require resolver/reviewer approval
10. Run IF artifact review before acceptance:
- every material variable is read later or marked telemetry-only
- every material choice changes affordance, risk, callback, visual state, or
outcome
- folds preserve consequence rather than hiding it
11. Manually review fuzzy participant appraisals and state effects each turn:
- update every affected participant before selecting the next actor
- add one memory or belief update
- adjust one or two `current_activation` values
- update one relationship stance if justified
- record the rationale as training data
12. Validate the resulting fixture.
The goal is not literary quality or autonomous plot generation. The goal is to
prove that a character can drive a scene beat, produce branchable dialogue or
non-dialogue action, trigger plausible responses from other characters, and
leave memory, relationship, social perception, and scene state slightly
different behind them.

View file

@ -1,539 +0,0 @@
# Soft Model Training Artifacts
Ghostlight has two kinds of machinery.
Some parts should be boring code:
- schema validation
- visibility filtering
- action legality
- object custody
- resource accounting
- Ink syntax validation and compilation
- event append
- provenance checks
- deterministic context slicing
- mechanical mutation gates
Other parts are soft. They require judgment, taste, social inference, cultural
pressure, and narrative sense. Do not fake those with brittle rules just because
a function-shaped hole is sitting there looking smug.
Soft parts should emit reviewed training artifacts from the beginning. Manual
review is not a temporary embarrassment. It is the dataset.
## Core Rule
If a decision cannot be made reliably by deterministic code, Ghostlight should
save it as a training example.
That includes accepted outputs, rejected outputs, repaired outputs, and weird
half-successes. Especially weird half-successes. They are where the machine is
showing the fracture line.
A useful soft artifact should preserve:
- the deterministic input slice
- the model or human output
- the reviewer decision
- accepted labels
- rejected labels, if any
- rationale grounded in state, scene, culture, memory, relationship, and lore
- failure notes
- provenance to source lore or fixture state
- whether the artifact is grounded canon, transition-grounded, procedural
branch material, or promoted branch material
## Sandboxed Responder Rule
Responder training data must not be authored from an omniscient view.
The coordinator may know the whole scene, hidden state, future branch plan,
author constraints, and private facts. The responder model we eventually train
will not. If a responder example only works because the authoring agent quietly
used omniscient context, the artifact is poisoned. It teaches the future model to
perform an impossible trick, then looks offended when reality asks for receipts.
For gold responder or character-action data:
- the coordinator prepares the scene and selects the acting character
- the projector emits the exact character-local context
- the responder is run from only that projected packet, visible event, allowed
action space, and any explicitly included source excerpts
- the responder does not see other agents' private state, hidden canonical
facts, author-only plot beats, future branch plans, or coordinator rationale
- any isolated worker or sub-agent used for responder generation should be
started without inherited conversation context when the harness supports it
- repo or lore browsing is disabled for the responder unless the projected
packet explicitly includes the excerpts or references the future model will
also receive
- the coordinator reviews after generation, not during it
Artifact requirements:
- `responder_visible_input`: the exact prompt or structured packet the responder
saw
- `responder_raw_output`: the unedited output
- `reviewed_output`: the accepted or repaired turn
- `review_status`: accepted, accepted-as-draft, useful-needs-revision, rejected,
or superseded
- `coordinator_interventions`: every edit, repair, deletion, or normalization
made after raw generation
- `hidden_context_refs`: ids or paths for context intentionally withheld, not
the hidden content itself
- `leakage_audit`: checks for private-state leakage, future-branch leakage,
author-only leakage, raw-state soup, prompt-constraint parroting, and
impossible body or object knowledge
Coordinator-written replacement text can be valuable, but it is not raw
responder output. Mark it as coordinator repair or frontier-curated final. Do
not smuggle repaired prose into the responder corpus as if the sandbox produced
it. That is how a dataset learns to cheat with a straight face.
## Training-Ready Scene Loop Bundle
A scene loop is the smallest useful sequential training unit before branch
compilation. It is not enough to have a nice transcript. The bundle needs the
machine trail that made the transcript possible.
A training-ready scene-loop bundle contains:
- scene-local digest or lore slice
- initial scene/world state
- current character state refs for every participant
- projected local context per acting turn
- responder packet per acting turn
- raw responder output capture per acting turn
- observable event record per accepted action
- participant appraisal receipt for every affected participant
- reviewed mutation receipt for character state, memory, relationship,
perceived overlays, object state, scene/world state, and unresolved hooks
- coordinator turn receipt carrying next-beat selection, world-state reads,
proposed deltas, glue prose, and review labels
- review summary naming which organs are training-ready, reference-only, or not
training data
Training readiness is per organ. A bundle can be useful for responder,
appraiser, mutator, relationship/perception updater, and coordinator training
while still being useless for branch compiler, IF artifact reviewer, or visual
replay training. Count only the receipts that actually exist.
`pallas-training-loop-v0` is the first pilot of this convention. It rebuilds one
Pallas threshold beat as a separate derivative and leaves the Pallas reference
fixture immutable.
## Soft Organs
These organs should be treated as model-training targets, not permanent
hand-authored rule nests.
### Coordinator Or Story Runtime
Glues scenes together, maintains world state, chooses which machinery runs, and
emits the connective prose around character turns.
In training-shaped loops, the coordinator should be sandboxed as its own
promptable organ. A coordinator worker receives only the scene state, accepted
receipts, unresolved hooks, available machinery, branch affordances, tonal
intent, author constraints, and game-engine-shaped state surfaces it is allowed
to use. It then proposes the next beat, acting character, machinery calls, glue
prose, and candidate state changes.
Codex remains the meta-coordinator while the system is being built: it scopes
the task, prepares the coordinator-visible input, launches the sandboxed
coordinator, reviews the output, labels interventions, and wires structured
state between organs. Codex-authored coordinator artifacts are useful draft
material, but they are not raw coordinator training data unless the coordinator
input/output seam was actually sandboxed.
This is a trainable artifact too. Do not let coordinator judgment evaporate into
chat history just because a large model is currently doing the stage management
with jazz hands and plausible deniability.
Training artifacts:
- scene setup receipts
- exact coordinator-visible input packet
- raw sandboxed coordinator output
- parsed and reviewed coordinator artifact
- coordinator plans
- selected next scene or next beat
- chosen acting agent
- selected tools or soft organs invoked
- world-state refs read
- world-state changes proposed
- unresolved hooks carried forward
- branch merge or branch split decisions
- authored glue prose
- rejected glue prose
- reviewer notes about pacing, legibility, state continuity, lore grounding,
game usefulness, and machinery misuse
The coordinator output should be shaped for a future game engine. Even while the
prototype lives in model imagination and authored prose, it should preserve
structured refs for:
- scene id
- location id
- time or sequence marker
- participating agents
- active objects
- active resources
- public world facts
- unresolved facts
- branch flags
- event log refs
- state fixture refs
- consequences awaiting review
Glue prose is part of the artifact. It is not canonical state, but it is useful
training signal for how scenes are bridged, how pressure is restated without
exposition sludge, and how a procedural branch becomes readable narrative.
Coordinator and narrator intelligence are allowed to exceed character
intelligence. This is how opaque high-context scenes become legible without
flattening the characters. If a Lucent negotiator, influencer, or feed operator
is performing layered reputation warfare, the coordinator may explain the
maneuver in reader-facing prose, but the explanation is not automatically
available to the character who performed or witnessed it. Character cognition
still comes from projected state: intelligence, social acuity, training,
cultural scripts, stress, and blind spots.
The coordinator may propose state changes, scene transitions, and tool calls. It
does not bypass deterministic gates. It does not silently decide hidden facts. It
does not mutate another organ's private output after review. It conducts the
little orchestra; it does not eat the violins.
### Projector
Turns canonical state, perceived state, memory, culture, scene pressure, and
affordances into character-local operating context.
Training artifacts:
- projected local context records
- projection examples
- prompt-rendering audits
- leakage failures
- source-grounding failures
The projector does not write final prose. It prepares the local pressure surface
for an acting model.
### Character Agent Or Responder
Chooses what a character does next from local awareness.
This includes speech and non-speech action. The agent may speak, wait, leave,
hide evidence, reveal evidence, spend a resource, attack, comply, defect,
bargain, comfort, or do nothing useful because people are built badly.
Training artifacts:
- exact responder-visible input packet
- hidden context refs, without hidden content
- raw responder output
- coordinator interventions or repairs
- action-choice receipts
- candidate branch sets
- accepted and rejected character turns
- spoken text when speech occurs
- observable non-speech action
- actor intent
- constraints used
- constraints violated
- reviewer notes about voice, lore, embodiment, agency, and scene fit
This model should be fine-tuned or adapted on a corpus of strong Aetheria
responses spread across the timeline. The goal is not a bolt-on lore module.
The goal is for the responder to internalize the shape of the setting: what
people assume, what institutions feel like, which pressures are normal, which
metaphors appear, what bodies can do, and how the universe tends to hurt people
while insisting it is only doing math.
That reduces lore handholding at runtime. It does not remove character-local
context. The tuned responder still needs the specific room, speaker, listener,
state, memory, affordances, and known facts for the current turn.
### Appraiser
Reads an observable event from one participant's local perspective and proposes
how that participant interprets it. This organ is not only for fictional scene
mutation. It is also one of the core paths by which VoidBot becomes better at
reading people: noticing what a human did or said, forming a grounded opinion
without pretending certainty, and revising that opinion as evidence accumulates.
Training artifacts:
- observed event
- participant-local knowledge
- private interpretation
- misread or correct-read label
- emotional appraisal
- relationship implications
- belief implications
- memory candidate
- human-facing opinion update when applicable: trust, concern, irritation,
respect, caution, attachment, boundary pressure, expected future behavior, and
what evidence would change the read
- reviewer corrections
The appraiser should not know canonical private state except where that
participant knows or believes it. Fundamental attribution bias belongs here. For
VoidBot, this means the model must distinguish an observed human behavior from a
durable claim about who that human is, and should preserve uncertainty, repair
signals, and evidence needed to revise the opinion later.
### State Mutator
Turns reviewed appraisal into bounded state changes.
Training artifacts:
- prior state refs
- event refs
- appraisal refs
- proposed state deltas
- accepted deltas
- rejected deltas
- mutation authority notes
- reviewer rationale
The early system should keep this manual and audited. Later a model can learn
common deltas, but deterministic gates still own schema, bounds, and authority.
### Relationship And Social Perception Classifier
Updates perceived overlays and relationship stance from repeated evidence. This
classifier should serve both Aetheria agents and VoidBot's long-running human
relationships; the same machinery that lets Sella update her read on Maer should
also let VoidBot update its read on a specific user without collapsing into
either sycophancy or a permanent first-impression mugshot.
Training artifacts:
- prior relationship stance
- event and appraisal history
- proposed movement
- confidence movement
- stale or reinforced impressions
- prejudice, stakes, memory, and social-reinforcement drivers
- reviewer notes
This is where confidence belongs. Do not grow a second correction-resistance
organ because the first one got lonely.
### Aetheria Responder Corpus
Training artifacts:
- source-grounded scene examples
- factional speech and pressure examples
- institution decision examples
- embodiment/interface examples
- negative examples where generic sci-fi assumptions were rejected
- reviewer notes tied to AetheriaLore paths
This corpus trains the character agent/responder directly. It is how a chosen
base model learns Aetheria's native assumptions rather than waiting for every
prompt to drag the whole lore vault behind it in a little wagon.
The tuned responder should learn the smell of the setting: Dominion severity,
Aya-coded care pressure, Navigator embodiment, corporate certification culture,
insurer cruelty, upload personhood fights, factional misreads, and the dry
little horrors of systems pretending to be neutral.
It still must receive character-local context at runtime. Fine-tuning gives it
priors, not omniscience.
### Branch Compiler
Turns scene intent, reviewed branch candidates, consequence packets, fold plans,
and visual continuity requirements into playable Ink plus sidecar receipts and,
when illustrated replay is in scope, a separate visual plan artifact.
The Branch Compiler is trainable because good branching structure is not just
syntax. It needs to decide where choices belong, which consequences can fold
into compact variables, which choices deserve route splits, which callbacks must
exist later, and how visual modifiers attach to actual state. Deterministic code
can validate Ink and refs. It cannot, by itself, know whether a branch feels
meaningful or whether convergence is lying.
Training artifacts:
- fixture brief
- source-grounded lore digest refs
- input branch candidates
- reviewed consequence packets
- fold plan
- generated Ink
- generated sidecar
- generated visual plan
- compiler notes
- variable set/read map
- route split or fold rationale
- visual prompt handles
- reviewer findings and repair labels
The compiler does not own hidden character truth, participant appraisal, or
durable social mutation. It materializes structure from already reviewed or
explicitly marked draft material.
### IF Artifact Reviewer
Audits playable branch artifacts before they enter the accepted corpus.
This reviewer is separate from the branch compiler because compilers are very
good at producing valid little lies. The reviewer checks whether tracked state
actually matters, whether choices change future affordances or outcomes, whether
folds preserve callbacks, whether visual modifiers correspond to real branch
state, and whether endings read the variables the story told us were important.
Training artifacts:
- Ink fixture under review
- sidecar under review
- coordinator artifact
- compiler notes
- validation output
- accepted/rejected decision
- severity-ranked findings
- failure labels such as `potemkin_state`, `cosmetic_choice`, `fake_fold`,
`missing_state_read`, `state_named_instead_of_checked`,
`visual_callback_missing`, and `ending_ignores_major_state`
- required fixes
- repaired artifact refs, when available
Early branch-and-fold reviews are seed material for this organ because they
caught decorative variables before they were allowed to become training sludge.
### Institution, Faction, And Consumer Decision Models
Eventually Ghostlight should support economic behavior and major faction
decision-making inside a simulated game world.
Training artifacts should preserve why agents:
- buy
- refuse
- trade
- hoard
- comply
- defect
- conceal
- reveal
- punish
- help
- lobby
- exploit
- delay
- sacrifice
The artifact should preserve perceived cost, scarcity, status pressure,
reputation risk, expected future contact, faction norms, institutional authority,
and moral injury when present.
Do not train a dialogue-only pet if the adult machine needs to decide whether a
clinic buys counterfeit cryo valves under blockade.
## Artifact Status
Soft artifacts should use explicit review status:
- `accepted`
- `accepted_as_draft`
- `useful_needs_revision`
- `rejected`
- `superseded`
Rejected artifacts remain valuable when the failure label is clear. A rejected
Navigator action that makes a cetacean walk across a human hallway is not trash.
It is a negative example with teeth.
## Training Corpus Families
Ghostlight should expect several related corpora rather than one heroic pile of
JSONL wearing a crown.
- projection corpus
- projected-local-context corpus
- coordinator/story-runtime corpus
- character-action corpus
- dialogue/prose response corpus
- participant-appraisal corpus
- state-mutation corpus
- relationship/perception corpus
- lore-grounding digest corpus
- Aetheria responder corpus
- Ink branch annotation corpus
- Branch compiler corpus
- IF artifact review corpus
- institution/faction/consumer decision corpus
- negative-example corpus
Some examples can belong to multiple corpora through references. Do not duplicate
truth; link artifacts by ids and source refs.
## Aetheria-Tuned Agent Model
The character agent itself is a training target.
A sensible long-term stack looks like this:
```text
AetheriaLore source corpus
-> source-grounded digests and scene fixtures
-> coordinator scene plans and glue-prose receipts
-> reviewed Ghostlight action/dialogue/appraisal artifacts
-> Aetheria-tuned responder or agent model
-> specialized coordinator/projector/appraiser/mutator students
-> deterministic runtime gates and validators
```
The Aetheria-tuned agent model should learn setting priors and voice ecology:
what institutions feel like, how factions misread each other, what bodies can do,
what pressure sounds like when nobody in the room has enough authority to fix the
machine.
It should not replace Ghostlight state. It should not decide hidden facts. It
should not mutate world state directly. It acts inside the local context and
affordances Ghostlight provides.
## Deterministic Gates Stay Deterministic
Training models does not remove hard gates.
Keep these as code:
- schema validation
- source provenance checks
- speaker-local visibility
- prompt leakage checks
- action enum validation
- body-affordance validation when source-backed
- mutation authority
- object custody
- resource and world-state legality
- Ink compilation
- artifact review status requirements
A model can propose. The runtime decides what is legal, what is visible, what is
accepted, and what becomes state.
## Near-Term Policy
During prototype work:
1. Keep fuzzy updates manual and reviewed.
2. Emit every model call as a receipt when it teaches something.
3. Store accepted and rejected examples with failure labels.
4. Prefer narrow artifacts over one vague transcript blob.
5. When a prompt fix reveals a reusable failure mode, promote it into validator
logic or artifact schema.
6. When a model repeatedly performs a soft judgment well, mark that organ as a
candidate for distillation or fine-tuning.
7. When it repeatedly fails in a patterned way, save the failures. The beast is
drawing us a map in crayon.
The point is not to automate everything today. The point is to stop losing the
manual intelligence we are already spending.

View file

@ -1,646 +0,0 @@
# Technology And Item Manifest Plan
Ghostlight future exploration should produce more than scenes. It should produce
the technical and economic knowledge Aetheria needs for gameplay: what items can
exist, who can build them, what they are made of, which assemblies can be
swapped, what supply chains they depend on, and which factions start with which
tech bases.
This is not separate from story generation. Major-player decisions, faction
births, branch pressures, and technological discoveries shape the economy. If a
branch discovers a better aetheric emitter, that should eventually change ship
builds, market dependencies, quest hooks, factory politics, salvage value, and
what a desperate mechanic can realistically bolt into a hull without creating a
beautiful obituary.
The economy is bigger than ship parts. Players moving freight should carry the
ordinary manufactured life of colonies too: habitat fittings, clinic equipment,
interface furniture, ritual goods, industrial consumables, food-processing
systems, maintenance packs, education kits, luxury nonsense, and all the other
things a settlement consumes without ever bolting them into a laser. A trade
economy made only of gear components would be a very fancy skeleton.
## Source Premise
Aetheria game design wants ships to be tools, homes, status symbols, and
material compromises. Customization is driven by components with distinct
behavior, manufacturers with priorities and specialties, and vessels tuned for
violence, trade, survival, logistics, or prestige.
Ghostlight should turn worldbuilding exploration into the data needed to support
that design.
## Player Fantasy
The deep gear model is not a loot-affix treadmill. The fantasy is provenance.
A player hunting for the ultimate laser should be able to trace the item through
its parts, makers, technologies, and accidents:
- the emitter assembly came from a short-lived manufacturer whose process was
only stable for nine months
- the beam-control wafer uses a regulatory-buried fabrication step no successor
company wants to admit worked
- the thermal rejection assembly is sturdy but mediocre, quietly strangling the
whole weapon
- a pirate rebuild replaced the targeting interface with a counterfeit part
that improves lock speed while making maintenance ugly
- a factional embargo means the good driver wafers are technically illegal,
practically everywhere, and therefore excellent quest bait
An "ultimate item" is not just the best blueprint. It is the best realized
artifact: the right blueprint, built from the right assemblies, with the right
quality, provenance, manufacturer practice, replacement history, and operating
conditions. If a player wants to rebuild it as the best it can be, the game
should let them hunt the supply chain: salvage yards, old corporate stockpiles,
specialist shops, faction arsenals, black-market refits, inherited ships,
forgotten factories, and small wars over a beam-control wafer some dead
manufacturer only made for nine months before the board got eaten by regulators.
This is why Ghostlight matters. A spreadsheet can name a laser. Ghostlight should
help explain why that laser exists, who can build it, which parts are worth
killing for, why a particular instance is special, and what stories fall out of
trying to make it better.
## Content Refinery
The old bottleneck was not ideas. Ideas are cheap. Volunteer design goblins can
produce a hill of them before lunch and leave the database work sitting there
like a little corpse.
The real bottleneck is converting taste, lore, mechanics, and future-tech
imagination into loadable, reviewable, schema-shaped content. Ghostlight's
technology pipeline should act as a content refinery:
1. Ground the request in Aetheria lore: faction, era, location, tech base,
industrial limits, political pressure, and known source constraints.
2. Decompose the concept into item families, assemblies, subassemblies,
components, colony-consumed manufactured goods, materials, processes,
tooling, facilities, expertise, demand profiles, and supply-chain
dependencies.
3. Map each candidate onto the Aetheria-Economy blueprint model:
`SimpleCommodityData`, `CompoundCommodityData`, `ConsumableItemData`,
`GearData`, or another `EquippableItemData` subclass.
4. Define performance intent through behaviors, `PerformanceStat` fields,
quality sensitivity, failure modes, upgrade surfaces, and operating
constraints.
5. Define realized-instance variation: assembly provenance, manufacturer
branding, quality, repair history, counterfeit substitutions, salvage scars,
and ownership chain.
6. Emit reviewable artifacts that separate source facts, inferred engineering,
branch-local assumptions, engine-schema gaps, rejected ideas, and accepted
candidates.
7. Export or hand off accepted candidates as database-shaped content rather than
another swamp of clever prose someone has to manually translate later.
The goal is not infinite content sludge. The goal is volume with preserved taste:
many plausible blueprints, variants, manufacturers, counterfeit versions,
salvage stories, and supply-chain hooks that still feel like Aetheria.
## Economy Schema Target
Ghostlight should target the existing Aetheria-Economy item model instead of
inventing a parallel item taxonomy.
The concrete source is:
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemData.cs`
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemInstance.cs`
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\CultCache\`
The important split:
- `ItemData` and subclasses are CultCache-backed technological blueprint
records: a way of constructing a thing in the game, including the parameters,
behaviors, stat curves, and category fields that make that construction
meaningful.
- `ItemInstance` and subclasses are runtime objects that link to definition
records and represent manufactured artifacts in the world.
- Ghostlight item manifests should normally propose blueprint/data records, not
individual runtime artifacts.
- Runtime instances belong in simulation, inventory, scene state, cargo, loot,
and world-state fixtures. They are where realized quality, supply-chain
provenance, manufacturer branding, and assembly history can make two items
built from the same blueprint differ in practice.
Blueprint classes currently relevant to Ghostlight:
- `SimpleCommodityData`
- basic fungible resources such as steel, hydrogen, volatiles, or feedstock
- maps to `SimpleCommodity` runtime instances with quantity
- `CompoundCommodityData`
- technological blueprints for crafted commodities, assemblies,
subassemblies, processed materials, colony-consumed trade goods, and other
manufactured economic units
- not every compound commodity is an input to higher-level gear; many are
finished manufactured goods consumed by colonies, habitats, institutions,
ships, clinics, farms, sanctuaries, and cultural communities
- maps to `CompoundCommodity` runtime instances whose quality and provenance
can affect downstream items, settlement demand, service quality, comfort,
productivity, legitimacy, maintenance load, or faction-specific living
standards
- `ConsumableItemData`
- usable consumables with behaviors, duration, stackability, icon, and
effectiveness curve
- maps to `ConsumableItem` runtime instances
- `GearData`
- technological blueprints for usable equippable items with hardpoint,
behavior, durability, thermal, audio, schematic, and action-bar fields
- maps through `EquippableItem` runtime instances whose realized quality,
provenance, manufacturer, and installed assembly lineage can alter actual
performance
- `WeaponItemData`, `HullData`, `CargoBayData`, `DockingBayData`, and other
`EquippableItemData` subclasses are specialized top-level usable item
definitions.
Manifest artifacts should therefore use Ghostlight's conceptual decomposition to
produce reviewed candidate technological blueprints for Aetheria-Economy data
classes:
```text
material/feedstock -> SimpleCommodityData
component/subassembly/assembly -> CompoundCommodityData
manufactured colony trade good -> CompoundCommodityData
consumable usable item -> ConsumableItemData
equippable usable item -> GearData or another EquippableItemData subclass
weapon -> WeaponItemData
hull/frame/cargo/docking item -> HullData, CargoBayData, DockingBayData, etc.
runtime cargo, inventory, or freight -> SimpleCommodity, CompoundCommodity,
ConsumableItem, or EquippableItem instances
```
The conceptual layers below are still useful. They describe how Ghostlight
breaks a technology into reviewable engineering, logistics, and gameplay pieces.
They are not a replacement database schema. The output should be able to say
which Aetheria-Economy blueprint class each candidate wants to become, what
fields are source-backed, what fields are inferred, and which fields require
new engine schema before they can be loaded.
`PerformanceStat` is the core bridge from blueprint to realized item behavior.
Blueprint data defines stat ranges and quality sensitivity; runtime instances
and equipped/active contexts determine actual evaluation through quality,
durability, heat, effectiveness, and modifiers. Ghostlight manifests should
therefore preserve the difference between:
- blueprint-level performance intent
- assembly or subassembly contribution to quality and stat behavior
- realized instance provenance and manufacturer branding
- in-world operating conditions that change evaluated performance
If a Ghostlight manifest needs fields Aetheria-Economy does not currently store,
mark them as manifest-side metadata or source gaps. Do not pretend CultCache
already has slots for every nice idea. The future can have organs; today it has
classes.
## Nebulous Technology Elaboration
Aetheria already has plenty of named technologies. Many of them are currently
high-level lore objects rather than actionable game objects. That is fine for a
timeline note. It is not enough for an item blueprint database.
Ghostlight's exploration pipeline should elaborate existing vague technologies
into blueprint candidates.
For each nebulous technology, produce:
- source summary
- inferred operating principle
- gameplay role
- item families enabled
- assemblies and subassemblies implied
- critical components
- materials
- manufacturing processes
- tooling and facility requirements
- required expertise
- quality tiers
- failure modes
- maintenance needs
- salvage value
- counterfeit risk
- substitute parts
- faction access and restrictions
- supply-chain bottlenecks
- prerequisite technologies
- downstream technologies enabled
- branch-local variants
- open lore questions
This is where a phrase like `super-Planckian emitter` stops being a decorative
sci-fi noun and becomes a logistics problem with teeth.
If Ghostlight cannot yet know the exact real physics, it should still propose a
consistent fictional engineering stack: what must be fabricated, stabilized,
aligned, cooled, calibrated, certified, transported, repaired, and monopolized.
The output should be reviewable, revisable, and eventually loadable into a game
database.
Do not hallucinate certainty. Mark inferred engineering as inferred. Mark source
facts as source facts. Mark branch-local innovations as branch-local. The
manifest is allowed to deepen the world; it is not allowed to pretend an
invention arrived without provenance.
## Item Knowledge Goals
Every explored technology or item family should be able to answer:
- what the item does
- what gameplay role it serves
- what systems it plugs into
- what components and subassemblies it requires
- which assemblies are swappable upgrade surfaces
- whether it is a finished colony-consumed trade good rather than an assembly
input
- what population, institution, habitat, ship, or faction demand profile
consumes it
- what service, comfort, legitimacy, safety, productivity, cultural continuity,
or maintenance need it satisfies
- which materials, processes, or specialists are bottlenecks
- which factions or manufacturers can build it
- which factions can maintain it but not manufacture it
- which factions can counterfeit, salvage, or adapt it
- which tech prerequisites unlock it
- which branch conditions or discoveries created it
- which quests, markets, or conflicts it enables
- which blueprint records it should create or update
## Manifest Object Types
Use these object layers when producing tech data. Each layer must also declare
its intended Aetheria-Economy target class or state that it is manifest metadata
only:
- `item_family`
- `item_variant`
- `assembly`
- `subassembly`
- `component`
- `material`
- `process`
- `tooling`
- `expertise`
- `facility`
- `manufacturer`
- `supply_node`
- `upgrade_slot`
- `blueprint`
- `tech_prerequisite`
- `quality_tier`
- `failure_mode`
An `item_family` is a broad class, such as laser weapon, radiator, aetheric
drive, cryo valve, sensor mast, or habitat seal.
An `item_variant` is a concrete build with stats, faction/manufacturer flavor,
quality tier, and branch-specific assumptions.
An `assembly` is a replaceable functional unit. For a laser, emitter assembly,
power conditioning assembly, thermal rejection assembly, beam-control assembly,
targeting interface, and housing are likely assemblies.
A `subassembly` is a nested unit inside an assembly. The emitter assembly may
include gain medium, field lattice, focusing microstructure, alignment actuator,
control wafer, and contamination seal.
A `component` is the smallest useful gameplay/logistics unit worth tracking.
Do not split screws unless screws are the point of the quest. The machine has
standards. Barely.
A `manufactured_trade_good` is a finished compound commodity whose primary role
is economic consumption rather than becoming a higher-level gear assembly. It
may still have components and quality tiers, but the point is that colonies,
ships, habitats, clinics, sanctuaries, temples, schools, or faction enclaves
consume it as part of ordinary life and institutional function.
Example: a Navigator wet-interface cradle would likely be a
`CompoundCommodityData` candidate. It is not a ship weapon or a laser component.
It is a manufactured interface good consumed by Navigator habitats, route
annexes, mixed-species relay stations, sanctuary registrars, and other spaces
where cetacean or aquatic-adapted cognition must participate in shared
procedure. Its demand profile might be driven by Navigator population share,
route-steward institutions, translation infrastructure, acoustic signaling
standards, maintenance water quality, clinic/sanctuary access, and local
prestige around dignified embodiment. Quality might affect comfort, signal
clarity, translation latency, stress load, infection risk, repair frequency, or
institutional legitimacy.
A `blueprint` is the technological construction record represented by the
appropriate Aetheria-Economy `*Data` class. It should be specific enough for a
game system to evaluate whether a faction, factory, ship, or player can build
the item, and how sub-assemblies affect performance through quality-sensitive
stats and behavior parameters.
Ghostlight manifests still need explicit assembly trees, compatibility rules,
supply-chain dependencies, and manufacturing gates. If the current engine schema
does not expose those fields directly, keep them as manifest metadata or
engine-schema gaps attached to the target `*Data` blueprint rather than
pretending they are separate from the blueprint concept.
A `tech_prerequisite` is a required discovery, process, standard, license,
facility, or expertise gate.
A `quality_tier` describes meaningful gameplay variation: crude, standard,
premium, military, experimental, branch-exotic, counterfeit, degraded, salvaged,
or other reviewed tiers.
A `failure_mode` describes what breaks, how it breaks, and what that failure
costs in gameplay, safety, logistics, reputation, or maintenance.
## Example Pattern: Laser Upgrade Surface
A laser design should be represented so a higher-tech emitter assembly can alter
performance without rewriting the whole item.
Example structure:
```text
item_family: laser_weapon
item_variant: sol_pattern_industrial_laser_mk2
assembly: emitter_assembly
subassembly: gain_medium
subassembly: focusing_lattice
subassembly: alignment_actuator
assembly: power_conditioning_assembly
assembly: thermal_rejection_assembly
assembly: beam_control_assembly
assembly: targeting_interface
assembly: housing_and_mount
```
A branch-local future might discover an aether-stabilized emitter assembly. That
should produce:
- a new assembly or subassembly record
- prerequisite discoveries
- materials and tooling
- compatibility rules with older laser families
- performance changes
- heat, stability, cost, legality, maintenance, or supply-chain consequences
- factions able to manufacture or adapt it
- quests or market events unlocked by scarcity
## Example Pattern: Super-Planckian Emitter
When a source names a technology like `super-Planckian emitter`, Ghostlight
should elaborate it into at least a candidate blueprint stack.
Example working decomposition:
```text
item_family: exotic_emitter
item_variant: super_planckian_emitter_core_candidate
assembly: emitter_lattice_assembly
subassembly: boundary_condition_lattice
subassembly: phase-lock microstructure
subassembly: exotic containment frame
assembly: field_drive_assembly
subassembly: pulse shaper
subassembly: high-stability driver wafer
subassembly: feedback quench circuit
assembly: thermal_and_causality_safety_assembly
subassembly: transient heat sink
subassembly: event-spike monitor
subassembly: failsafe isolation gate
assembly: calibration_and_control_assembly
subassembly: metrology package
subassembly: firmware governor
subassembly: certification seal
```
That decomposition is not automatically canon. It is a candidate manifest. A
reviewer can accept, reject, revise, or mark source gaps. The important point is
that the technology now has places for supply chains to attach:
- lattice fabrication
- exotic containment materials
- high-stability driver wafers
- metrology packages
- safety gates
- certification regimes
- specialist calibration expertise
- heat rejection and failure containment
Once those exist, the game can ask useful questions:
- who owns lattice fabrication?
- who can counterfeit the driver wafer?
- what happens if the safety gate is downgraded?
- can a higher-tier emitter assembly fit into an older laser housing?
- which faction can maintain it after three jumps and one bad decision?
- what quest appears when the certification seal supply collapses?
## Pre-Elysium Starting Tech
Pre-Elysium tech is not a monolith. Everyone was shunted into Elysium together,
but not every faction brought the same industrial base, doctrine, standards,
stockpiles, expertise, or manufacturing rights.
Ghostlight should generate starting tech manifests for the single-history Sol
artifact:
- base technologies available before the Rupture
- faction access and restrictions
- manufacturing standards
- dominant manufacturers
- known substitute parts
- logistics bottlenecks
- maintenance dependencies
- doctrine-driven design differences
- legal restrictions and export controls
- black-market or pirate adaptations
- blueprint records for starting equipment, ship systems, weapons, habitats,
medical systems, industrial tooling, logistics infrastructure, colony
furnishings, habitat equipment, institutional interface goods, and finished
manufactured trade goods consumed by settlements
This matters because Elysium branches inherit different practical capabilities
from the same shared Sol history depending on what each faction controls in that
branch.
## Post-Rupture Innovation
Post-Rupture innovation is branch-local canon indexed by lineage.
When exploration discovers a technology, the manifest should record:
- branch id and lineage
- prior conditions
- triggering event or pressure
- involved factions, inventors, institutions, or nonhuman actors
- source-backed post-Elysium concepts used
- generated branch assumptions
- prerequisite tech and discoveries
- item families affected
- assemblies or subassemblies created
- supply chain changes
- market/faction consequences
- quest hooks opened
- sibling branches where this innovation does not exist or differs
- blueprint records created or modified
- compatibility with pre-Elysium assemblies
Aetheric drives, necrotech interfaces, spirit-mediated sensors, temporal memory
archives, pseudospace logistics, and mutable-body support equipment should all
leave this kind of trail when explored.
## Faction Tech Bases
For each faction or manufacturer, track:
- known technologies
- restricted technologies
- lost or inaccessible technologies
- signature design preferences
- manufacturing strengths
- maintenance weaknesses
- doctrine-driven compromises
- supply dependencies
- export controls
- counterfeiting exposure
- salvage behavior
- branch-local innovations
A faction can understand a technology without being able to build it. It can
install a component without being able to maintain it. It can maintain an item
only by depending on a hated supplier. These distinctions are the gameplay.
## Coordinator Obligations
The coordinator/story runtime must emit technology and economy knowledge when a
scene or branch decision changes the material world.
Coordinator artifacts should include item-manifest deltas when they discover or
alter:
- technology availability
- component compatibility
- assembly upgrade paths
- colony-consumed manufactured goods
- demand profiles
- materials and manufacturing processes
- supply bottlenecks
- faction access
- market pressure
- quest hooks
- salvage opportunities
- maintenance constraints
The coordinator is allowed to propose item knowledge. Deterministic validators
and reviewer gates decide whether it becomes a manifest update.
## Training Artifacts
Tech/item exploration should emit reviewed artifacts for future models:
- item manifest records
- component breakdown records
- assembly compatibility records
- colony trade-good records
- demand-profile records
- faction tech-base records
- supply-chain dependency records
- technology discovery records
- branch-local innovation records
- economic consequence records
- rejected overpowered or unsupported item designs
- source-gap records requiring AetheriaLore elaboration
These artifacts train:
- coordinator/story runtime
- institution/faction decision models
- consumer and market behavior models
- item-generation models
- lore/source evaluators
- retrieval models for tech prerequisites and dependencies
## First Schema Target
The next concrete schema after coordinator artifacts should be a manifest seam
that maps directly onto Aetheria-Economy's technological blueprint classes:
- `schemas/item-manifest.schema.json`
- `schemas/item-blueprint-candidate.schema.json`
- `schemas/item-instance-provenance-candidate.schema.json`
- `examples/item-manifest/pre-elysium-starting-tech.template.json`
- `examples/item-manifest/future-branch-innovation.template.json`
- `examples/item-manifest/super-planckian-emitter.candidate.json`
- `tools/validate_item_manifests.py`
Minimum fields:
- manifest id
- fixture lane
- branch lineage, if any
- source refs
- source-backed facts
- inferred engineering
- open lore gaps
- item family or technology id
- object layer
- consumption role: assembly input, finished trade good, consumable, gear, or
metadata-only
- target Aetheria-Economy class
- target blueprint id, when updating an existing record
- candidate blueprint fields
- blueprint candidate ids
- instance provenance fields, when simulating manufactured artifacts
- parent assembly refs
- child component refs
- materials/process/tooling/facility refs
- colony, habitat, institutional, cultural, or factional consumption drivers
- faction/manufacturer access
- prerequisites
- bottlenecks
- compatibility rules
- quality tiers
- failure modes
- gameplay effects
- economic consequences
- quest hooks
- engine-schema gaps
- review status
Candidate blueprint fields should be shaped around existing data classes first:
- shared `ItemData` blueprint fields: `Name`, `Description`, `Manufacturer`,
`Mass`, `Shape`,
`SpecificHeat`, `Conductivity`, `Price`
- `SimpleCommodityData`: `MaxStack`, `Category`
- `CompoundCommodityData`: `DemandProfile`, `Category`
- `ConsumableItemData`: `Behaviors`, `Stackable`, `Duration`, `Icon`,
`Effectiveness`
- `EquippableItemData` and subclasses: `Schematic`, `Behaviors`, `Durability`,
temperature fields, heat performance curve, resilience, icons, sound data,
`HardpointType`, and subclass-specific fields such as weapon range, caliber,
fire type, hull hardpoints, cargo interiors, or docking size
Ghostlight may propose additional manifest fields, but validators should label
them as metadata, engine-gap, or rejected. Do not silently smuggle non-engine
fields into supposed CultCache records.
## Data Generation Policy
During worldbuilding exploration:
1. If a scene implies a new item, component, supply bottleneck, or technology
dependency, emit a manifest candidate.
2. If a scene implies a manufactured good that a colony, habitat, ship,
institution, species community, or faction consumes, emit a
`CompoundCommodityData` candidate with a demand profile, even if it is not an
assembly input for gear.
3. If a faction decision changes what can be manufactured, maintained, bought,
salvaged, counterfeited, or upgraded, emit a faction tech-base delta.
4. If an innovation appears in Elysium, label its branch lineage and prior
conditions.
5. If existing lore names a technology but lacks blueprint detail, elaborate it
into a candidate manifest with explicit inferred fields and source-gap notes.
6. If the item relies on unsupported lore, patch AetheriaLore or mark a source
gap before treating it as stable.
7. If an item is cool but breaks logistics, keep it as a rejected artifact with
the failure label. Beautiful nonsense is still training data if tagged before
it bites someone.
Ghostlight should discover the future by exploring it, but every discovery
should leave inventory. The game cannot build a supply chain out of vibes and
one haunted laser.

File diff suppressed because it is too large Load diff

View file

@ -1,92 +0,0 @@
# Ghostlight Verse Service Contract
Ghostlight is a state/projection service candidate for socially persistent
characters and simulated people. Its durable truth is not a dashboard and not a
prompt. Its durable truth is typed character, scene, relationship, event,
perception, and Persona projection state.
Ghostlight already has a CultCache-backed persistence seam. The next GameCult
Verse cut is to expose its inspection and authoring surfaces as Eve GUI/TUI DSL
so local runtimes can browse state, projection, and review artifacts without
turning Ghostlight into a private web app.
## Owner Map
- Owner: Ghostlight owns canonical scene/world/agent state, observer-specific
perceived state, projection inputs, reviewed mutation receipts, training-loop
bundles, and Persona projections derived from story agents.
- Inputs: Aetheria lore/source context, canonical agent and scene state,
projected local context, responder packets, sandboxed responder output,
reviewer receipts, participant appraisals, and accepted mutation receipts.
- Outputs: CultCache-backed state records, readable migration exports, reviewed
training artifacts, schema fixtures, event records, participant appraisals,
and portable `gamecult.persona_state.v0` projections when a public
person-shaped surface is needed.
- Derived state: `state/branches.json`, `state/evidence.jsonl`, validator
reports, fixture sidecars, and authoring previews are compatibility or review
surfaces, not long-term direct-write APIs.
- Forbidden writers: authoring dashboards, visual replay applets, prompt
renderers, review helpers, and Eve/TUI renderers must not directly mutate
canonical character truth. They emit reviewed mutation intent or read typed
projections.
- Shared paths: state CLI, fixture validators, training-loop review, future
authoring UI, future Eve surface, and future compact TUI must read the same
CultCache-backed state seam instead of maintaining separate dashboard truth.
- Deletion line: any direct-write JSON state path that survives after a typed
document exists must be demoted to export/witness or removed.
## Current State
Ghostlight already has these substrate pieces:
- `state/ghostlight-state.cultcache.jsonl` as the current CultCache-backed
migration spine;
- `vendor/cultcache-py`;
- `tools/ghostlight_state_store.py` as the state access seam;
- `schemas/gamecult.persona_state.v0.schema.json` as the mirrored portable
Persona contract;
- `schemas/agent-state.schema.json` for Ghostlight-native scene/world/story
state;
- architecture docs for persistence, projection, training loops, prompt
projection, and shared Persona/CultNet contracts.
- `tools/ghostlight_state.py publish-odin` and
`tools/publish_odin_provider_advertisement.cjs` publish Ghostlight's
provider advertisement once to Odin's CultMesh/RUDP document ingress when an
endpoint is supplied.
The missing surface is not another readable export. The missing surface is a
provider-owned Eve GUI/TUI composition over the existing state and review seams.
## Eve Surface Target
Ghostlight should publish an Eve GUI/TUI DSL surface with these panels:
1. `State Spine`: CultCache store path, document families, migration status,
readable export freshness, and direct-write debt.
2. `Scene And Agent State`: active scene fixtures, canonical agent state,
observer/perceived overlays, and relationship stance availability.
3. `Projection Pipeline`: projected local context, responder packet, responder
output, review status, leakage audit, and mutation receipt state.
4. `Training Coverage`: accepted/draft/planned/rejected fixture counts,
coverage gaps, tonal modes, collision axes, and training target families.
5. `Persona Projection`: `gamecult.persona_state.v0` availability for public
character/person surfaces, with provenance back to Ghostlight-native state.
Each panel must expose source provenance and freshness. If a panel is reading a
readable JSON export during migration, the surface should say that the export is
not the canonical owner.
## Migration Order
1. Promote remaining direct-write state paths behind `tools/ghostlight_state_store.py`
or its successor typed document seam.
2. Define a `ghostlight.eve_surface.v0` provider contract over the current
state, projection, review, and Persona documents.
3. Add a read-only command that lowers those documents into Eve DSL.
4. Publish the provider advertisement through CultMesh/Odin discovery.
5. Let browser/native/TUI runtimes lower the same surface.
6. Demote old preview/status dashboards to renderers of the provider-owned Eve
surface.
The invariant: Ghostlight owns story/person state. Eve makes it inspectable.
Authoring surfaces request reviewed mutations; they do not become state owners.

View file

@ -1,160 +0,0 @@
# VoidBot Routine Adoption Plan
VoidBot now has the best live reference for the agent routine Ghostlight needs:
work or rumination on heartbeat, periodic sleep for consolidation, typed
MessagePack persistence through a CultCache-style contract, and vector-backed
memory resonance. Ghostlight should adopt the routine without inheriting
VoidBot's moderation job.
## Imported Organs
- **Heartbeat scheduler:** chooses work, rumination, or sleep/consolidation from
readiness, load, status, reaction windows, and pending turns.
- **Completion-gated cooldown:** a participant cannot act again until its
previous heartbeat turn finishes; recovery starts at completion.
- **Sleep cycle:** scheduled naps are not absence. They compress memory,
strengthen useful associations, cool saturated themes, refresh dreams, and
keep direct speech rare.
- **CultCache persistence:** canonical state is MessagePack, routed by typed
document definitions, with JSON working projections for inspection/review.
- **Vector memory:** memories carry semantic vector metadata and Qdrant points
so recall, resonance, incubation, and dreams are grounded in retrievable
structure.
- **Thought organs:** analytic lane, associative lane, bridge, memory resonance,
incubation, and candidate intervention/dream queues.
## Ghostlight Translation
Ghostlight's swarm is the cast. The heartbeat initiative system therefore
applies directly to characters taking turns within a scene, not merely to
background maintenance workers.
There are two scopes using the same timing law:
- **scene heartbeat:** selects which in-scene character acts, reacts, waits,
hesitates, refuses, speaks, withholds, moves, or uses an object.
- **maintenance heartbeat:** wakes offstage agents, cast records, coordinator
organs, appraisers, mutators, and fixture builders for rumination, sleep
consolidation, or reviewed maintenance.
The point is one initiative physiology with different arenas, not two unrelated
schedulers wearing each other's hat.
```text
heartbeat initiative
-> choose scene participant or maintenance organ
-> scene turn | reaction | wait | rumination | sleep
-> read typed state from CultCache-compatible store
-> optional Qdrant recall / memory resonance
-> produce visible action, event, appraisal, mutation, dream, or maintenance receipt
-> completion starts cooldown
-> write canonical MessagePack + JSON working projection
```
Epiphany now owns the first shared Rust heartbeat spine. Ghostlight should call
that spine through `tools/ghostlight_epiphany_heartbeat.mjs` instead of growing a
parallel scheduler. The Ghostlight-side command derives scene participants from
the existing initial/agent-state fixtures, initializes a typed MessagePack
`ghostlight-scene` heartbeat store, and asks Epiphany to emit
`ghostlight.initiative_schedule.v0` receipts with `scene_turn` actions. Those
receipts select the actor and local-turn machinery; responder, appraisal, and
mutation organs still own the actual narrative action.
## Persistence Shape
Ghostlight can start with the CultCacheTS contract directly for Node-side tools,
but the Python-heavy validator/tooling surface needs a compatible file contract:
- canonical store: single-file MessagePack envelope array
- envelope: `key`, `type`, `payload`, `storedAt`
- schema owner: code definitions, not hand-edited loose JSON
- working projection: JSON, human-readable, safe for review, optionally stripped
of bulky vector values
- write discipline: use an external lock or one coordinator writer; the
single-file store is not a database wearing a fake mustache
Initial document types:
- `ghostlight.agent_state`
- `ghostlight.scene_state`
- `ghostlight.initiative_schedule`
- `ghostlight.scene_heartbeat_state`
- `ghostlight.training_loop_bundle`
- `ghostlight.runtime_heartbeat_state`
- `ghostlight.memory_resonance`
- `ghostlight.sleep_cycle`
## Memory Vector Shape
Qdrant becomes the retrieval layer for agent memory, not canonical social truth.
Each memory-like record should keep:
- stable memory id
- kind: episodic, semantic, relationship, goal, value, musing, dream,
scene-memory, appraisal, mutation, or lore-grounding
- summary text used for embedding
- source hash
- embedding backend/model/dimensions
- Qdrant point id and collection
- salience/confidence/update time
- source refs into fixtures, lore, or reviewed artifacts
Ghostlight's existing training docs already point toward retrieval-backed memory.
This plan makes it operational instead of aspirational, the traditional route
by which a research note stops being furniture.
## Sleep Behavior
During sleep turns:
- do not generate ordinary scene turns unless the character is actually in the
scene and selected by the scene heartbeat
- inspect memory resonance clusters and incubation entries
- merge duplicate memories
- prune stale or low-confidence residue
- create or refresh at least one dream only when it compresses a useful seam
- update `sleep_cycle.activeDreamThemes`, `lastDreamAt`, and
`lastDistillationSummary`
- write a maintenance receipt for review
Dreams are compressed operational memory, not glitter. If a dream survives, the
next waking turn should be more coherent because of it.
## Migration Slices
1. **Contract fixture**
- Add a small CultCache-compatible MessagePack fixture and validator.
- Prove Python can read/write the envelope without losing schema shape.
2. **Runtime heartbeat schema**
- Add `schemas/runtime-heartbeat-state.schema.json`.
- Include pending turn, sleep cycle, rumination, and completion receipts.
3. **Agent state migration**
- Keep current JSON examples as projections.
- Add canonical MessagePack test store for one small agent-state fixture.
4. **Memory resonance**
- Add a local memory-organ script that embeds memory summaries, writes Qdrant
points, builds resonance edges/clusters, and writes incubation state.
5. **Sleep receipt**
- Add a sleep/consolidation artifact schema and validator.
- Require sleep receipts to name pruned/merged/strengthened memories.
6. **Training-loop integration**
- Promote scene initiative schedules into the same heartbeat contract used
by the swarm.
- Let characters take scene turns through heartbeat initiative, then route
appraisal/mutation/coordinator maintenance through the same completion and
cooldown law.
## Boundaries
- Do not make Qdrant canonical truth.
- Do not store raw vector blobs in every human working projection.
- Do not let sleep turns mutate accepted training fixtures without reviewed
maintenance receipts.
- Do not make every Ghostlight character into a background daemon before the
runtime heartbeat store can prove who is awake, asleep, pending, or complete.

View file

@ -0,0 +1,314 @@
# How Ghostlight Dungeon Keeps a Whole World Moving
An AI dungeon master has an awkward problem: the world should feel larger than
the conversation, but a language model can only attend to so much at once.
If the game simulates only the people currently speaking, everyone else seems
to stop existing. A distant government never exploits an opportunity. The
villagers cease reacting when the player talks to the blacksmith. A location
left behind becomes a vague reconstruction when the player returns.
The brute-force answer would be to give every person and organization its own
AI turn. That answer lasts until the setting contains twenty factions, several
cities, a trading network, a plague, and one unusually opinionated baker. It is
too slow and too expensive, and most of those turns would not matter right now.
Ghostlight Dungeon instead changes the *resolution of the simulation*. It can
consider one person in detail, a village as a group, or several rival powers as
one bounded political situation. The underlying people and facts do not get
merged or discarded. Only the amount of attention used to simulate them
changes.
That is the multiresolution gestalt algorithm.
## What “gestalt” means here
A gestalt is a population that can be simulated through a shared Persona turn.
“The dockworkers,” “the western villages,” or “the biology faculty” might each
begin as a gestalt. They have a common baseline, but that does not mean every
member is identical.
Suppose the player enters a village and asks for the blacksmith. Ghostlight can
materialize John from the villagers’ baseline, then preserve everything that
makes him particular: his name, memories, possessions, grudges, promises, and
relationship with the player. While John is directly involved, he receives his
own simulation attention.
When the player leaves, John does not disappear and his history is not averaged
away. His individual state remains as a durable delta from the villagers’
baseline. The simulator may include him in a cheaper village-level turn until
he matters again. When the player returns, the same John is materialized from
the same baseline plus the same personal delta.
This distinction is the foundation of the system:
> Ghostlight aggregates simulation work, not canonical identity or state.
## The agency graph
At campaign creation, the world compiler builds a coarse global map of agency.
It does not eagerly invent every street and citizen. It records the major
powers, institutions, populations, regions, information channels, and strategic
pressures that make the setting move.
Each actor, institution, and active population gestalt becomes a vertex in an
*agency graph*. Typed edges describe how they can affect one another: command,
membership, containment, alliance, rivalry, trade, migration, communication,
coercion, and shared location.
Each vertex also has an agency profile across six practical axes:
- geography;
- ideology;
- authority;
- economic role;
- species or bodily constraints;
- access to information.
These are not personality statistics. They are clues about where a useful
simulation boundary lies. During a blockade, geography and supply routes
predict behavior. During an election, ideology and authority matter more. For
an epidemic, bodies and transport links may dominate. During an espionage
crisis, the important boundary is often who knows what.
This is why Ghostlight does not use one permanent hierarchy such as “continent,
country, city, person.” The best cut depends on the pressure currently moving
through the world.
## From current pressure to a simulation cover
Before a strategic wave, a cheap structured model reads the committed event or
planning horizon and proposes weights for the six axes. It may also point to
relevant subjects, but only from IDs Ghostlight supplied. This stage can say
“information boundaries matter greatly right now”; it cannot invent a new
faction or alter the world.
The local algorithm then covers every active subject with a set of simulation
cells. A cover obeys three hard rules:
1. Every active subject appears in exactly one cell.
2. Every cell is connected through the agency graph.
3. Foreground subjects that require individual attention remain individual.
The player chooses the desired number of active Persona cells with a budget
slider from 1 to 32; the default is 8. This is a detail budget, not a promise to
erase people until the number fits. If nine named characters are actively
engaged while the budget is four, Ghostlight reports a temporary effective
budget of at least nine. The foreground wins.
Pins provide more deliberate control. A subject can be kept at individual
detail, several subjects can be kept together, or two groups can be forbidden
from sharing a cell. Pins and budget changes apply only at safe boundaries. They
change a separate resolution epoch, not fictional time or the campaign’s world
revision.
The partitioner starts from fine detail and repeatedly merges the lowest-cost
legal pair of adjacent cells. Its loss function asks what would become harder
to predict if those cells shared one simulation turn:
```text
0.25 facet divergence
+ 0.20 hidden causal-boundary mass
+ 0.15 information-scope divergence
+ 0.15 spatial divergence
+ 0.10 clock/obligation divergence
+ 0.10 salience burial
+ 0.05 partition churn
```
In plain language: do these subjects want different things, affect one another
in ways a summary might hide, know different facts, live too far apart, owe
different duties, contain something important, or require a disruptive change
from the previous cover?
The exact weights make the decision reproducible and inspectable. The current
pressure changes the meaning of facet divergence, so a geographic boundary can
be cheap during an ideological dispute and expensive during a flood.
After reaching the budget, Ghostlight performs a small boundary-refinement
pass. It accepts a local move only when it improves the compression cost by at
least five percent. It also prefers the previous cover unless a replacement is
at least ten percent better, apart from forced splits and explicit budget
changes. Merged cells receive short leases. These measures stop the world map
from jittering between equally plausible partitions every turn.
The implementation uses deterministic ordering and content-addressed cell IDs,
so the same state and demand produce the same cover. Candidate scoring samples
large cells to keep local work bounded, but it never samples the rules that
protect authority, hostility, knowledge, pins, connectivity, or complete
coverage. Those are checked exactly. The release test partitions 1,000 subjects
to a budget of 8 in about 50 milliseconds on the development host.
## Two very different kinds of cell
Putting several subjects in one cell does not automatically create a collective
mind. Ghostlight distinguishes two modes:
| Mode | What it represents | What it may do |
| --- | --- | --- |
| **Cohesive** | A real collective with shared authority and sufficiently compatible behavior and information | Speak or propose one action as a plural Persona |
| **Arena** | Several distinct or opposed subjects considered in one situation | Appraise their interaction and propose separately attributed actions |
A cohesive cell might represent a patrol acting through its accepted command
structure. It requires a real common authority, no active hostile relation, and
low enough behavioral and information divergence.
An arena might contain a guild, its striking workers, and a city ministry. It
exists to save inference work, not to pretend they have become one faction. An
arena has no actor ID. It cannot issue collective speech, pool everyone’s
secrets, or spend one member’s resources on behalf of another. Every proposed
action names the exact constituent responsible, and the world kernel validates
that constituent’s knowledge, authority, location, relationships, and
possessions.
Cross-faction aggregation always produces an arena. This single distinction
prevents a great deal of cheap-model political alchemy.
## One cell, one Persona pipeline
Once the cover is selected, every active cell runs through Ghostlight’s Persona
projection machinery:
```mermaid
flowchart LR
A["Canonical world state"] --> B["Current-pressure demand"]
B --> C["Agency graph and budgeted cover"]
C --> D["Private Projector stream per cell"]
D --> E["Persona response"]
E --> F["Interpreter appraisal and proposals"]
F --> G["WorldKernel validation"]
G --> H["One atomic strategic commit"]
```
The Projector converts permitted typed state into a private lived narrative. A
cohesive cell receives genuinely shared state plus clearly attributed
exceptions. An arena receives a polyphonic account that keeps each
constituent’s perspective and knowledge separate.
The Persona sees only that narrative stream. It does not see database schemas,
action syntax, or raw world state. It responds as a person or group inhabiting
the situation.
The Interpreter converts the response back into a typed appraisal and action
proposals. A cohesive cell may propose one collective action. An arena may
propose a small bounded number of constituent-attributed actions—at most four,
even for a very large cell.
All cells in a wave bind to the same world revision and resolution epoch. Only
after every pipeline completes does the WorldKernel validate reach, knowledge,
authority, resources, clocks, and conflicting proposals. It then commits the
accepted consequences in one transaction. A stale response, invalid cover,
malformed model output, or illegal action aborts the entire wave without a
partial world mutation.
The number of model requests allowed to run simultaneously is a separate
operator setting. Raising concurrency makes an eight-cell wave faster; it does
not change which eight cells exist. Lowering the player’s cell budget changes
simulation resolution; it does not silently change the provider configuration.
## Keeping quiet parts of the world alive
Salience alone would still create a spotlight problem. Important conflicts
would receive attention forever while quiet populations slowly became static
summaries.
Ghostlight prevents that with *detail debt*. A subject accumulates this
non-fictional scheduling value while it is represented only inside an
aggregate. Each strategic wave reserves detailed focus for the eligible subject
with the highest debt. Receiving individual or explicit focus clears it.
Even at a cell budget of 1, the global arena must focus on one overdue subject
while retaining the larger situation around it. With N subjects, deterministic
debt rotation gives every subject direct simulation attention within at most N
strategic waves, unless foreground requirements temporarily take precedence.
Detail debt is not something characters can perceive, and it cannot be spent
inside the fiction. It is bookkeeping for fair computational attention. World
clocks and deterministic obligations advance whether a subject receives focus
or not. If a cell does nothing, that inaction must be an appraisal, not an
omission from a prompt.
## A worked example
Imagine an Aetheria campaign with 24 powers. A river blockade has interrupted
food shipments while an opposition movement tries to turn the shortage into a
general strike.
At a budget of 4, Ghostlight might preserve:
- the player’s location and directly engaged cast as mandatory individual
cells;
- upstream producers and transport authorities in a geographically shaped
arena;
- dockworkers, guild leadership, and the ministry in a workplace-and-authority
arena;
- distant powers in a coarse arena, with the highest-detail-debt power selected
for explicit focus.
If the player uncovers a secret ministry order, information divergence becomes
more important. The next safe repartition may keep the informed conspirators
separate from people who have not seen the order—even if they share a building
or faction.
If the player then confronts a particular dockworker, that character becomes a
mandatory singleton. Ghostlight may exceed the configured budget rather than
fold a live conversation back into “the workers.” After the relevance lease
expires, the character can return to aggregate simulation while retaining the
same individual history.
The cut changes because the situation changed. The world underneath it does
not.
## Refining the world is different from simulating it finely
Sometimes the Vault establishes that a broad population really contains
durable subdivisions: three ideological tendencies, several species with
different vulnerabilities, or villages on opposite sides of a material route.
That is a canonical fact, not merely a convenient simulation cut.
Ghostlight handles this through approval-gated gestalt fission. The compiler
previews a split along an enumerated facet and always includes an
`other/unknown` remainder. Approved children inherit the parent baseline and
own only their later deltas. The parent remains as inactive lineage, and known
member deltas are reassigned without rewriting anyone’s identity.
Ordinary cover changes are reversible and need no approval because they alter
no fiction. Fission changes the canonical population model, so evidence,
preview, and approval are required. Keeping these operations separate prevents
an optimization decision from quietly becoming lore.
## How it appears in Ghostlight Dungeon
For players, the main control is the active Persona-cell budget. A higher value
allows more simultaneous detail and usually costs more inference time. A lower
value asks Ghostlight to summarize more of the strategic world without freezing
it. The interface also shows the effective budget when foreground obligations
force an overage.
For operators, the inspector exposes the agency graph, current cover, cohesive
and arena modes, merge-loss components, pins, leases, detail debt, and the
reason for each cut. It also shows model-stage and resolution receipts without
publishing private Persona streams or chain-of-thought.
Under the hood, the implementation is divided by authority:
- `domain.rs` defines the persistent subjects, agency contracts, covers,
appraisals, and control commands;
- `compiler.rs` builds the global agency skeleton and previews canonical
fission;
- `resolution.rs` plans and validates covers, manages detail debt, validates
cell proposals, and applies approved fission;
- `persona.rs` owns the Projector → Persona → Interpreter membrane for cells;
- `scheduler.rs` projects demand and runs cell pipelines in bounded parallel
waves;
- `kernel.rs` is the only authority that changes canonical campaign state;
- `surface.rs` projects player and operator controls through Ghostlight’s Eve
interface.
The result is not an omniscient simulation of every citizen. It is a controlled
allocation of attention over a persistent world. The dungeon master can zoom
in without inventing a new world and zoom out without erasing the old one.
That is the practical promise of multiresolution gestalts: the world keeps
moving, disagreement survives compression, and the blacksmith is still John
when you come back.

View file

@ -0,0 +1,70 @@
# Lore Vault Entitlements
## Product decision
Ghostlight Dungeon will accept Git-synchronized, Obsidian-compatible lore
Vaults. A compatible Vault is a folder hierarchy of Markdown documents; it does
not require an Obsidian runtime or a proprietary export format. GameCult's
Aetheria and Zyphos lore sites use this shape and serve as reference corpora.
| Plan | Active custom Vaults | Combined indexed source allowance | Full imports per month |
| --- | ---: | ---: | ---: |
| Contributor | 1 | 10 million source tokens | 2 |
| Contributor Plus | 3 | 30 million source tokens | 6 |
| Private | 1 | 10 million source tokens | 2 |
| Private Plus | 3 | 30 million source tokens | 6 |
Prices, allowances, and import accounting remain provisional until service-rig
benchmarks establish ingestion, storage, and retrieval cost. Plus increases
both active Persona-cell capacity and lore capacity. Contributor versus Private
continues to select provider data policy; it does not change Vault ownership.
## Authority map
- **Owner:** the Vault service owns source checkout, parsing, chunking,
embeddings, indexes, incremental synchronization, deletion, and retrieval.
- **Inputs:** an authorized Git repository or synchronized directory containing
Markdown lore, its revision, and the subscriber's entitlement.
- **Outputs:** a versioned `ghostlight.vault_manifest.v1` binding and exact
retrieval witnesses through the generic `VaultProvider` contract.
- **Derived state:** campaigns retain the bound manifest revision and exact
evidence receipts used during play. They do not own a second semantic index.
- **Forbidden writers:** campaigns, model stages, and the browser cannot modify
source lore or silently promote branch inventions into the Vault.
- **Shared path:** import, Git update, reindex, campaign compilation, destination
expansion, and runtime retrieval resolve through one tenant-scoped Vault
identity and revision.
- **Cut line:** arbitrary document upload and campaign-local vector stores do
not become competing authorities.
## Resource accounting
The marketed Vault count is a comprehensible entitlement, not the resource
boundary. Enforcement also measures indexed source tokens, stored chunks and
embeddings, full imports, incremental document changes, and retrieval load.
Imports are content-addressed. Unchanged documents retain their chunks and
embeddings; a Git update replaces only changed document partitions. An update
counts as a full import only when Ghostlight must rebuild the whole Vault.
Oversized or unsupported imports receive a compatibility and size preview
before work begins. They do not create surprise usage charges.
Campaign Vault precedence is explicit:
```text
campaign branch facts
> campaign-specific lore Vault
> setting lore Vault
> optional rules/reference Vault
```
Conflicts are reported during compilation or retrieval. Sources are not blended
into an unattributed answer.
## Product and security gates
This entitlement is a product decision, not evidence that arbitrary tenant
imports are accepted by the current tester harness. Hosted import opens only
after tenant isolation, repository authorization and revocation, deletion,
provenance, size admission, Markdown/link parsing, prompt-injection resistance,
copyright reporting, and incremental-index benchmarks are verified.

View file

@ -0,0 +1,143 @@
---
title: Agents and model stages
description: A catalog of Ghostlight's production model calls, their roles, outputs, and deterministic gates.
---
Ghostlight uses three logical model tiers. **Fast** handles bounded projection,
classification, and selection; **Balanced** handles reconciliation, causal
reasoning, and most agentic workbenches; **Capable** handles the hardest world
compilation and numinous elaboration. Deployments may map these aliases to
different physical models without changing stage contracts.
“Agentic” means the stage can inspect a bounded workbench, take several typed
actions, receive deterministic feedback, and revise on the same snapshot. A
one-shot model stage still produces only a proposal.
## Session Zero
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `session_zero_projector` | Fast/configured | Lowers permitted negotiation state into the DM's private lived context | Projected stream; schema validation only, no state write |
| `session_zero_dm_persona` | Balanced/configured | Embodied persistent DM who discusses premise, boundaries, roles, and bargains | Natural speech; owns no accepted changes |
| `session_zero_interpreter` | Balanced/configured | Extracts only new typed proposals or one focused counterproposal from DM/player speech | Contract, character, or permission proposal; exact owner acceptance and Session Zero kernel gate |
## Retrieval and world compilation
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `opening_retrieval_plan` | Fast | Plans source queries for possible openings | Queries; Vault boundary executes and receipts results |
| `world_openings` | Balanced | Suggests grounded starting situations | Suggestions with evidence IDs; deterministic evidence validation |
| `role_retrieval_plan` | Fast | Plans source queries for playable roles | Queries; Vault boundary |
| `world_roles` | Balanced | Suggests roles, capabilities, and obligations | Suggestions; evidence and duplication checks |
| `custom_retrieval_plan` | Fast | Plans queries for a custom campaign brief | Queries only |
| `evidence_relevance` | Fast | Separates direct seed evidence from background and excluded material | Classification; deterministic source-lane admission |
| `world_compile` | Capable | Compiles the bounded playable seed: topology, cast, populations, institutions, facts, clocks, and civic state | Candidate seed; full structural and semantic validation before registry publication |
| `private_relationship_actor_compile` | Balanced | Compiles private player-character state and approved relationship anchors | Private actor proposal; identity, account, and relationship binding checks |
| `agency_compile` | Balanced | Profiles local actors, institutions, and populations across the six agency axes | Agency profiles and relations; exact subject coverage validation |
| `global_agency_compile` | Fast/configured | Builds the coarse offscreen strategic skeleton | Major powers, regions, channels, and pressures; schema and evidence checks |
| `global_agency_doctrine_synthesis` | Balanced | Proposes strategic doctrines from exact global agency anchors | Doctrine candidates; cannot rewrite source anchors |
| `global_agency_doctrine_verification` | Balanced | Judges whether synthesized doctrines preserve their anchors | Verdict; deterministic compiler decides admission |
## Destination, civic, and clock workbenches
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `destination_identity_resolution` | Fast | Decides whether a requested destination is an existing reachable place or a genuinely new region | Identity proposal; deterministic topology check chooses elaboration versus expansion |
| `destination_retrieval_plan` | Fast | Plans exact source queries for a destination | Queries only |
| `destination_compile` | Capable | Proposes geography, routes, residents, institutions, political relations, and civic apparatus | Expansion/elaboration candidate; topology, evidence, knowledge, and civic closure checks |
| `destination_reconciliation_agent_action` | Balanced, agentic | Repairs an exact rejected destination finding through small transactional edits | Revised private candidate; reruns the existing validators, never writes world state |
| `destination_civic_verification` | Balanced | Independently judges whether authority, succession, resources, redress, and resident knowledge are legible | Verdict receipt; kernel rebinds and checks it at admission |
| `gestalt_fission_retrieval_plan` | Fast | Plans evidence retrieval for a requested population subdivision | Queries; fission compiler and approval gate |
| `clock_consequence_binding_agent_action` | Balanced, agentic | Binds legacy clock consequence text to exact observable subjects and public channels | Binding proposal; deterministic threshold/event validation and kernel commit |
## Titled world elaborators
All titled workers use `world_elaboration_<title>`, the common
agent harness, one exact frozen assignment, and at most one additive operation.
The weighted scheduler controls invocation frequency in proportion to the
user's slider shares.
| Agent | Tier | Character of contribution |
|---|---|---|
| **Patina** | Fast | Durable low-stakes texture: objects, nicknames, customs, jokes, and reusable places |
| **Charter** | Balanced | Government, offices, law, succession, selection, procedure, and redress |
| **Ledger** | Fast | Labor, resources, infrastructure, exchange, scarcity, and class pressure |
| **Hearth** | Fast | Kinship, care, neighborhood, obligation, belonging, and private stakes |
| **Tangle** | Balanced | Factions, alliances, rivalries, constituencies, leverage, and plots |
| **Veil** | Fast | Secrets, rumors, misinformation, taboo, mystery, and disclosure paths |
| **Ember** | Fast | Active disputes, hazards, instability, escalation, and urgent pressure |
| **Numen** | Capable | Religion, ritual, magic, cosmology, awe, and bounded strangeness |
The deterministic elaboration tool checks the operation against the title's
exact assignment and frozen namespace. Wave admission resolves conflicts; an
independent semantic verifier reviews the inhabited candidate; `WorldKernel`
alone commits it.
At latent-world scale, the same titled sessions also receive
`world-complexity-<title>-fission` or
`world-complexity-<title>-individuate` assignments. Each call may subdivide one
active Gestalt or promote one grounded consequential person. A fission worker
returns only the partition delta—child identities and partition values plus
exact member/resource assignments. The deterministic tool attaches the frozen
world binding and inherited population state, avoiding repeated constitutions
in model output. An individuation worker similarly supplies the member delta
while the tool owns parent version and location. Deterministic validation and
the existing kernel commands remain the only admission owners. A rejected
fission returns the complete deterministic mismatch set to the same agentic
turn, rather than hiding several repairable defects behind one generic error.
Its action schema exposes the exact frozen resource and dormant-member IDs as
required assignment keys, while leaving their destination children to the
writer.
After every productive round,
`world-elaborator-<title>-session-compaction` distills that title's frontier in
the assigned realm jurisdiction and its unresolved leads while the harness
reattaches exact commit ancestry. The title-by-realm key prevents unrelated
political frontiers from becoming one global transcript. Compacted memory can
guide later proposals but cannot create subjects or facts.
## Foreground perception, response, and action
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `speech_address_resolver` | Fast | Selects exact co-present direct addressees for player speech | Addressee IDs; presence and response-duty validation |
| `projector` | Fast/configured | Projects one actor's permitted typed state and visible event into lived narrative | Private context; never canonical truth |
| `persona` | Balanced/configured | Appraises the event and produces natural response from one actor's perspective | Speech, deliberate silence, private appraisal, or action intent |
| `interpreter` | Capable/configured, agentic | Negotiates natural intent into the closed action algebra | Typed action proposal; authority and semantic validation |
| `assessment_mutation_scope` | Fast/Balanced | Selects the smallest mutation lanes that could realize an attempted effect | Upper-bound scope or precise denial; deterministic lane checks |
| `action_assessment` | Balanced | Sets possibility, DC, stakes, outcome bands, and effect ceiling | Assessment; canonical DC/reference validation and player risk acceptance |
| `assessment_effect_verifier` | Balanced | Checks that a proposed outcome mutation is caused by the exact attempted means | Verdict; invalid effects cannot reach the kernel |
| `gestalt_presence_planner` | Fast/configured | Promotes an earned existing member, admits one speech-authorized first identity, or folds an irrelevant member | Presence plan; exact lineage, location, version, and identity checks |
## Strategic simulation
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `resolution_demand` | Fast | Weights current causal relevance across geography, ideology, authority, economy, species/body, and information | Demand weights and focal IDs; deterministic connected partitioner owns the cover |
| `nemesis_attention_agent_action` | Balanced, agentic | Searches committed causal anchors and decides which autonomous subjects genuinely need a response window now | Anchor/responder assignments or none; exact subject/anchor validation, no action authority |
| `cell_projector` | Fast/configured | Projects a cell's exact constituents and required perspective owners without merging private state | Cell lived context |
| `cell_persona` | Balanced/configured | Lets each required actor, institution, population, or named member appraise and choose independently | Attributed natural decisions; arenas never speak as synthetic actors |
| `cell_interpreter` | Capable/configured, agentic | Translates each selected strategic decision into one or more typed attempts | Attributed action proposals; graph, target, location, and effect validation |
| `cell_effect_verifier` | Balanced/configured | Checks that each strategic attempt uses only its own constituent's authority and knowledge | Ordered verdicts; correction may reuse the same bounded workbench |
| `strategic_outcome_resolver` | Balanced | Resolves opposition and proposes one durable result per admitted action | Outcome bundle; exact handles and action digests must match |
| `strategic_outcome_verifier` | Fast/Balanced | Independently checks high-risk custody, relation, knowledge, and member-specific consequences | Selective semantic verdict; ordinary structural effects remain deterministic |
| `strategic_individuation_selector` | Fast | Names at most one person when selected Gestalt action creates political work that cannot remain anonymous | Member proposal or none; shared individuation validator and kernel admission |
## Newspaper
| Stage | Tier | Purpose | Output and downstream gate |
|---|---|---|---|
| `newspaper_narrative_selection_agent_action` | Balanced, agentic | Assignment editor queries the frozen ledger, chooses issue shape and throughline, assigns recurring reporters, and binds evidence | Immutable agenda checkpoint; exact record, reporter, lead, and conflict-axis validation |
| `newspaper_journalist_agent_action` | Balanced, agentic | One embodied journalist writes one assigned story through their recurring beat, voice, biases, and source habits | Article copy plus citations; no factual admission authority |
| `newspaper_copy_desk` | Balanced | Performs the single hard factual review over the assembled page | Complete finding checklist; cannot rewrite or publish |
| `newspaper_night_editor_close_agent_action` | Balanced, agentic | Repairs the copy desk checklist once while preserving narrative and closes at deadline | Final page; deterministic press witness checks structure and lineage |
Post-press grounding review is audit, not another same-edition rewrite loop.
Supported corrections become later-edition memory.
## Deterministic stages that deliberately use no model
The `WorldKernel`, mutation reducer, resolution partitioner, dice roller,
identity/version checks, topology and custody validation, information-scope
checks, checkpoint recovery, provenance hashing, archive verification, and Eve
projection lowering are deterministic. Keeping these outside model prompts is
what lets agents negotiate with the engine instead of impersonating it.

View file

@ -0,0 +1,64 @@
---
title: Architecture at a glance
description: Ghostlight's major organs and the flow from source evidence to committed world change.
---
## Major organs
| Organ | Responsibility | Does not own |
|---|---|---|
| Lore Vault boundary | Retrieves exact source witnesses and records provenance | Campaign truth or invented facts |
| Session Zero | Negotiates campaign premise, roles, boundaries, and explicit player decisions | Published world state before approval |
| World compiler | Produces a bounded seed, topology, civic machinery, actors, populations, and agency profiles | Runtime mutation after publication |
| Projector | Converts permitted typed state into one subject's lived context | Canonical state or another subject's knowledge |
| Persona | Appraises and responds from the projected perspective | Typed effects or commit authority |
| Interpreter | Translates natural intent into the closed action algebra | Whether the action succeeds or commits |
| Assessor | Determines possibility, difficulty, stakes, and admissible mutation scope | Dice, narration, or world commit |
| Multiresolution resolver | Derives a connected active cover over canonical subjects | Subject identity, facts, or population membership |
| Nemesis | Selects who needs a decision window in response to unresolved causal pressure | Cover construction, actions, or outcomes |
| Strategic outcome resolver | Proposes bounded consequences for selected strategic attempts | Mutation admission or persistence |
| Titled elaborators | Propose one additive worldbuilding operation at a time | Existing state, evidence, or direct writes |
| Newspaper | Selects and narrates committed public events through an editorial Persona | World events or factual admission |
| `WorldKernel` | Validates commands and atomically commits canonical state, events, receipts, and revision | Model inference or presentation |
| CultCache / CultMesh / Eve | Persist typed state, publish provider state, and render interfaces | The domain decisions represented by that state |
## Two scales, one world
Canonical actors, institutions, population Gestalts, and durable named members
form the latent agency graph. An active-cell entitlement controls simultaneous
attention, not population size. A deterministic resolver compresses the graph
into cohesive cells and adversarial arenas while preserving complete coverage.
At the current full-world target:
```text
latent actionable subjects: about 2,400
active cell entitlement: 240
target active cover: about 10%
provider concurrency: separately bounded, at most 32
```
Gestalt compression is reversible simulation resolution. It does not merge
identities or knowledge. Named members retain personal memories, possessions,
relationships, injuries, and obligations when folded back into a population.
## One write path
Foreground actions, NPC reactions, strategic waves, waits, travel, population
movement, region expansion, locality elaboration, clock consequences, and
approved population fission all lower into the same closed semantic mutation
algebra. `WorldKernel` validates the complete batch against one exact authority
envelope and commits it atomically.
The browser, scheduler, models, newspapers, checkpoints, and reconciliation
workers cannot create a second result. A checkpoint proves work already done;
it is not another owner of the world.
## Consumer boundary
Ghostlight accepts sparse consumer-authored world seeds and externally owned
boundary snapshots through a generic contract. Delvehold retains sovereignty
over the Greathold, its players, civic state, dungeons, workshops, contracts,
and quantitative economy. Ghostlight owns the autonomous world beyond that
boundary. Each side validates proposed consequences inside its own authority.

View file

@ -0,0 +1,80 @@
---
title: Authority and state
description: Canonical owners, derived projections, forbidden writers, and persistence rules.
---
## Canonical owners
- `WorldKernel` owns campaign revision and atomic world mutation.
- Actor state owns an individual's location, conditions, knowledge, goals,
possessions, commitments, and relationships.
- Institution state owns institutional posture, resources, goals, location,
information channels, and agency profile.
- A Gestalt owns a population's shared baseline. Durable member deltas own each
named person's departures from that baseline.
- World facts own their statement, scope, evidence ancestry, and discovery
locations.
- Clocks own progress, threshold, consequence declaration, and observable
scope.
- Civic manifests own public authority, selection or succession, resources,
redress, resident populations, governing institutions, and political edges
for one jurisdiction.
- The event ledger owns committed history. Newspaper copy is a projection of
that history, never a replacement for it.
## Derived state
The following values are disposable and reconstructable:
- resolution cells and arenas;
- merge loss and demand weights;
- Nemesis attention assignments;
- Projector lived streams;
- browser and Eve surfaces;
- newspaper source queries and layouts;
- aggregate compatibility rows;
- build, run, and resume checkpoints.
Derived state may carry exact bindings and still remain non-authoritative. A
receipt proves which proposal was considered against which snapshot; it does
not grant the proposer ownership of the result.
## Proposal-only model boundary
Every model stage is proposal-only. A stage may select, classify, appraise,
draft, translate, reconcile, or verify within its supplied schema. It cannot:
- advance world revision;
- mutate another subject's private state;
- invent a source receipt;
- rebase stale work;
- widen its authority because an output field exists;
- commit only the valid half of an invalid batch.
Semantic repair is bounded to the same frozen snapshot. Reconciliation workers
operate a private workbench and submit a corrected proposal through the same
validator; they do not patch canonical state.
## Atomic persistence
Accepted mutations, authority envelopes, model receipts, causal receipts,
events, and compatibility projections are persisted together through
CultCache. A later publication or interface refresh cannot roll back a commit.
Failure before commit changes nothing; failure after commit is recovered by
reading canonical state and rebuilding the missing projection or checkpoint.
## Knowledge and perception
Canonical truth and subject-local knowledge are separate components. A model
sees only the projection permitted for its exact perspective. Communication
adds existing facts to exact recipients; observation may admit a bounded local
finding when the attempted means can actually establish it. Public channels
are concrete information routes, not magic global knowledge.
## External sovereignty
A consumer may own a boundary subject completely. Ghostlight can observe its
published effects, depend on it, bargain with it, and propose consequences to
it, but cannot give it an autonomous turn or fabricate its internal choices.
The Greathold in Delvehold is the first intended example.

View file

@ -0,0 +1,98 @@
---
title: Runtime pipelines
description: End-to-end Ghostlight flows from campaign negotiation to newspapers.
---
## Session Zero and publication
1. A Projector lowers permitted shared or private negotiation state.
2. The persistent DM Persona speaks naturally with the players.
3. The Session Zero Interpreter extracts only newly proposed typed changes.
4. Material bargains remain unresolved until the exact owner accepts them.
5. Evidence retrieval and the compiler produce an approval preview.
6. Campaign, membership, contract, DM Persona, evidence, and seed digest publish
atomically before the campaign becomes discoverable.
## World compilation
1. Retrieval planners formulate bounded Vault queries.
2. Evidence relevance classifies direct seed evidence separately from
background and excluded material.
3. The compiler proposes local topology, actors, institutions, populations,
clocks, facts, and civic machinery.
4. Agency stages profile every non-player strategic subject and compile
relations and doctrines.
5. Deterministic validation checks topology, identity, evidence, knowledge,
civic closure, and authority.
6. Compatible missing detail may be reconciled on a private bounded workbench.
7. The registry publishes the accepted seed as an empty-store creation
transaction. Runtime models never gain a parallel creation path.
## Foreground turn
1. The speech-address resolver chooses exact co-present addressees.
2. Each affected participant receives its own Projector and Persona appraisal.
3. Direct addressees must speak or choose deliberate silence.
4. The Interpreter lowers proposed intent into the closed action algebra.
5. Mutation-scope preflight removes impossible effect lanes.
6. The assessor decides possibility, DC, stakes, and outcome ceilings.
7. The player accepts the exposed risk; the server rolls.
8. An effect verifier checks that the chosen typed consequence follows from the
attempted means and supplied authority.
9. `WorldKernel` commits the whole result and event atomically.
## Strategic wave
1. Resolution demand weights geography, ideology, authority, economy, body or
species, and information for the current horizon.
2. The deterministic partitioner derives a connected cover under the active
cell entitlement.
3. Nemesis searches committed causal anchors and assigns exact responders that
genuinely need a decision window. It may assign nobody.
4. Every selected cell runs Projector → Persona → Interpreter independently and
in parallel under a separate provider semaphore.
5. Cell effect verification rejects actions that borrow another constituent's
knowledge or authority.
6. The strategic outcome resolver proposes one bounded result per admitted
action; a separate outcome verifier checks selective semantic risks.
7. Optional individuation may name one person whose political work can no
longer remain anonymous.
8. `WorldKernel` validates the complete wave, advances time and clocks, commits
state and events atomically, then refreshes public projections.
## World elaboration
1. A consumer scale intent and current canonical subject count derive the
actionable-subject target and deficit.
2. Realm complexity weights distribute that target; the active cell budget and
provider concurrency remain separate controls.
3. The elaboration scheduler dispatches titled workers in proportion to their
user-controlled slider shares.
4. Locality passes propose exactly one additive place, route, fact,
institution, relation, or civic operation. Complexity passes propose one
Gestalt fission or one grounded individual promotion against a distinct
frozen parent.
5. Complexity proposals run concurrently, then rebase only when their exact
parent state is unchanged and serialize through `WorldKernel`. Successful
commits are checkpointed individually; a retry replays only failed calls.
6. Every title that committed work receives one bounded agentic compaction
turn. Its narrative frontier is checkpointed beside deterministic commit
ancestry, so provider-session eviction cannot erase or rewrite history.
7. The driver recounts canonical active agency leaves and derives another
round until the target cover ratio is met. Resolution then builds its
disposable cell cover independently.
## Newspaper
1. The assignment editor queries the immutable public-event ledger and selects
an issue shape, throughline, stories, evidence, and the best reporter for
each assignment.
2. Recurring in-world journalists write from individual beats, biases, source
habits, voices, and blind spots. Their job is narrative, not factual
admission.
3. The copy desk performs one factual review and returns a complete checklist.
4. The Night Editor receives the assembled page and that checklist once,
repairs what it can while preserving the story, and closes at deadline.
5. Deterministic provenance binds every factual assertion to exact committed
sources. Errors found after close become correction memory for a later
edition rather than an infinite rewrite loop.

View file

@ -0,0 +1,51 @@
---
title: Architecture
description: A public reference for Ghostlight's persistent-world simulation, agent pipeline, and authority boundaries.
---
Ghostlight is a persistent generative-agent engine. Models propose perceptions,
decisions, interpretations, and bounded worldbuilding operations; deterministic
code decides what those proposals are allowed to mean; one revisioned
`WorldKernel` commits canonical change.
This reference describes the implemented Ghostlight Dungeon body and the
consumer-neutral machinery being prepared for Delvehold. It distinguishes live
code from planned scale work. In particular, the 240-cell ceiling is an active
attention budget, not a claim that the world contains only 240 subjects. The
current full-world target is approximately 2,400 potentially acting entities
under roughly ten-percent active cover.
## Read the machine
- [[Architecture at a glance]] — the organs and the main dataflow.
- [[Authority and state]] — who is allowed to decide and persist what.
- [[Runtime pipelines]] — Session Zero, compilation, foreground turns,
strategic simulation, elaboration, and newspapers.
- [[Agents and model stages]] — every production agent and model stage, its
inputs, output, model class, authority, and downstream gate.
## The compact rule
```text
source evidence + canonical state
↓ projection
model proposal
↓ deterministic validation
typed mutation or refusal
↓ WorldKernel
atomic state + event + receipt commit
```
A model response is never world truth merely because it is well written or
well typed. Receipts bind every accepted inference to its exact stage and world
snapshot. Invalid, stale, or partially illegal work leaves canonical state
unchanged.
## Scope
The public reference follows the current source in `crates/ghostlight` (the
library) and `crates/ghostlight-dungeon` (the daemon that consumes it).
Research plans and older fixture pipelines remain in the repository but are not
presented as live runtime stages. Physical model names are deployment choices;
the architecture names the stable logical tiers: Fast, Balanced, and Capable.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,258 +0,0 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-06.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-06",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-06.coordinator.decision",
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "sol_vant",
"candidate_next_beats": [
{
"choice_id": "turn-06.selected",
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-06.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-06.handoff",
"responder_agent_id": "sol_vant",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-06.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-06.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -1,258 +0,0 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-07.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-07",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-07.coordinator.decision",
"selected_next_beat": "Mara Quell responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "mara_quell",
"candidate_next_beats": [
{
"choice_id": "turn-07.selected",
"summary": "Project Mara Quell from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Mara Quell to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-07.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.sol_vant.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-07.handoff",
"responder_agent_id": "mara_quell",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-07.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-07.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -1,258 +0,0 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-08.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-08",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-08.coordinator.decision",
"selected_next_beat": "Juno Ark responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "juno_ark",
"candidate_next_beats": [
{
"choice_id": "turn-08.selected",
"summary": "Project Juno Ark from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Juno Ark to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-08.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.sol_vant.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-08.handoff",
"responder_agent_id": "juno_ark",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-08.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-08.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -1,258 +0,0 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-09.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-09",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-09.coordinator.decision",
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "sol_vant",
"candidate_next_beats": [
{
"choice_id": "turn-09.selected",
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-09.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-09.handoff",
"responder_agent_id": "sol_vant",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-09.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-09.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

Some files were not shown because too many files have changed in this diff Show more