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
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
60 changed files with 7887 additions and 28875 deletions

1
.gitignore vendored
View file

@ -1,5 +1,4 @@
node_modules/
web/dist/
sidecar/claude-sdk/dist/
target/

3
.gitmodules vendored
View file

@ -1,3 +0,0 @@
[submodule "vendor/eve"]
path = vendor/eve
url = https://github.com/GameCult/Eve.git

View file

@ -5,7 +5,8 @@
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. `README.md` says
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.
## Where State Lives
@ -55,28 +56,21 @@ what exists today and what does not.
- 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`.
- Dungeon daemon: `crates/ghostlight-dungeon/src/` (`runtime.rs` for routes and
commands, `play.rs` for the play table, `eve.rs` for the surface).
Interface authority: `docs/architecture/ghostlight-eve-native-interface.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`.
- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale: `docs/architecture/ghostlight-dungeon-mvp.md`.
- 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.
## Build, Verify, Deploy
Ghostlight Dungeon ships to Linux on Yggdrasil. Idunn freezes an exact commit,
runs the locked native Linux tests, builds the daemon and web projection in
pinned containers, and seals the release. The steps are
`deployment/idunn/recipe.toml`; the owning runbook is
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
You are probably reading this on a Windows workstation. That is where the shell
is, not where the artifact runs. A Windows compile is not evidence about the
Linux release; build for the target, or let the deploy path's builder do it, and
say which one you used. Heavy builds and test runs go to Yggdrasil, not the
workstation. Deploy only through Idunn (`idunn up ghostlight`).
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`.
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
`F:\Projects\gamecult-ops` before acting.

684
Cargo.lock generated
View file

@ -37,15 +37,6 @@ dependencies = [
"subtle",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
dependencies = [
"memchr",
]
[[package]]
name = "android_system_properties"
version = "0.1.6"
@ -61,28 +52,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "async-stream"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476"
dependencies = [
"async-stream-impl",
"futures-core",
"pin-project-lite",
]
[[package]]
name = "async-stream-impl"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "async-trait"
version = "0.1.92"
@ -106,70 +75,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"axum-macros",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-macros"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "base64"
version = "0.22.1"
@ -182,12 +87,6 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bitflags"
version = "2.13.1"
@ -288,12 +187,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
@ -366,85 +259,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "cultmesh-rs"
version = "0.1.0"
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
dependencies = [
"anyhow",
"cultcache-rs",
"cultnet-rs",
"serde",
]
[[package]]
name = "cultnet-rs"
version = "0.1.0"
source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191"
dependencies = [
"aes-gcm",
"anyhow",
"base64 0.22.1",
"chrono",
"cultcache-rs",
"ed25519-dalek",
"hmac",
"rand 0.9.5",
"rmp-serde",
"rmpv",
"serde",
"serde_bytes",
"serde_json",
"sha2",
"socket2 0.5.10",
"uuid",
"windows-sys 0.61.2",
"zeroize",
]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
[[package]]
name = "digest"
version = "0.10.7"
@ -453,7 +267,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -467,30 +280,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]]
name = "errno"
version = "0.3.14"
@ -507,12 +296,6 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
@ -621,18 +404,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.3"
@ -642,7 +413,7 @@ dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"r-efi",
"rand_core 0.10.1",
"wasm-bindgen",
]
@ -683,44 +454,6 @@ dependencies = [
"zeroize",
]
[[package]]
name = "ghostlight-dungeon"
version = "0.1.0"
dependencies = [
"aes-gcm",
"anyhow",
"async-stream",
"async-trait",
"axum",
"base64 0.22.1",
"chrono",
"codex-connector",
"cultcache-rs",
"cultmesh-rs",
"cultnet-rs",
"ghostlight",
"ghostlight-persona-projection",
"hmac",
"jsonwebtoken",
"libc",
"rand 0.9.5",
"reqwest",
"rmp-serde",
"serde",
"serde_bytes",
"serde_json",
"sha2",
"tempfile",
"thiserror",
"tokio",
"tower",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
"zeroize",
]
[[package]]
name = "ghostlight-persona-projection"
version = "0.1.0"
@ -731,12 +464,15 @@ dependencies = [
]
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
name = "ghostlight-verse"
version = "0.1.0"
dependencies = [
"digest",
"anyhow",
"chrono",
"ghostlight",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
@ -772,24 +508,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.11.0"
@ -803,7 +527,6 @@ dependencies = [
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
@ -844,7 +567,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.5",
"socket2",
"tokio",
"tower-service",
"tracing",
@ -1010,27 +733,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "jsonwebtoken"
version = "9.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
dependencies = [
"base64 0.22.1",
"js-sys",
"pem",
"ring",
"serde",
"serde_json",
"simple_asn1",
]
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.189"
@ -1070,43 +772,12 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]]
name = "mio"
version = "1.2.2"
@ -1118,40 +789,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@ -1196,16 +833,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "pem"
version = "3.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
dependencies = [
"base64 0.22.1",
"serde_core",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@ -1218,16 +845,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]]
name = "polyval"
version = "0.6.2"
@ -1249,21 +866,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro2"
version = "1.0.107"
@ -1286,7 +888,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.5",
"socket2",
"thiserror",
"tokio",
"tracing",
@ -1302,7 +904,7 @@ dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand 0.10.2",
"rand",
"rand_pcg",
"ring",
"rustc-hash",
@ -1324,7 +926,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"socket2",
"tracing",
"windows-sys 0.61.2",
]
@ -1338,28 +940,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
dependencies = [
"rand_chacha",
"rand_core 0.9.5",
]
[[package]]
name = "rand"
version = "0.10.2"
@ -1371,16 +957,6 @@ dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@ -1390,15 +966,6 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
version = "0.10.1"
@ -1432,23 +999,6 @@ dependencies = [
"bitflags",
]
[[package]]
name = "regex-automata"
version = "0.4.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "reqwest"
version = "0.12.28"
@ -1530,8 +1080,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417"
dependencies = [
"rmp",
"serde",
"serde_bytes",
]
[[package]]
@ -1540,15 +1088,6 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.4"
@ -1615,12 +1154,6 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde"
version = "1.0.229"
@ -1674,17 +1207,6 @@ dependencies = [
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
@ -1708,15 +1230,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shlex"
version = "2.0.1"
@ -1733,27 +1246,6 @@ dependencies = [
"libc",
]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]]
name = "simple_asn1"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
dependencies = [
"num-bigint",
"num-traits",
"thiserror",
"time",
]
[[package]]
name = "slab"
version = "0.4.12"
@ -1766,16 +1258,6 @@ version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.5"
@ -1786,16 +1268,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
@ -1883,45 +1355,6 @@ dependencies = [
"syn 3.0.3",
]
[[package]]
name = "thread_local"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
dependencies = [
"cfg-if",
]
[[package]]
name = "time"
version = "0.3.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
[[package]]
name = "time-macros"
version = "0.2.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tinystr"
version = "0.8.4"
@ -1959,7 +1392,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.6.5",
"socket2",
"tokio-macros",
"windows-sys 0.61.2",
]
@ -2011,7 +1444,6 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -2022,23 +1454,13 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
dependencies = [
"bitflags",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower",
"tower-layer",
"tower-service",
"tracing",
"url",
]
@ -2060,7 +1482,6 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@ -2084,36 +1505,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@ -2128,12 +1519,6 @@ version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]]
name = "unicode-ident"
version = "1.0.24"
@ -2207,12 +1592,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version_check"
version = "0.9.5"
@ -2234,15 +1613,6 @@ version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasip2"
version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
@ -2512,12 +1882,6 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "wit-bindgen"
version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
name = "writeable"
version = "0.6.4"
@ -2547,26 +1911,6 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.56"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View file

@ -1,8 +1,8 @@
[workspace]
members = [
"crates/ghostlight",
"crates/ghostlight-dungeon",
"crates/ghostlight-persona-projection",
"crates/ghostlight-verse",
]
resolver = "2"
@ -13,19 +13,10 @@ version = "0.1.0"
[workspace.dependencies]
anyhow = "1"
aes-gcm = "0.10"
async-trait = "0.1"
async-stream = "0.3"
axum = { version = "0.8", features = ["macros"] }
chrono = { version = "0.4", features = ["serde"] }
base64 = "0.22"
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" }
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" }
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" }
jsonwebtoken = "9"
hmac = "0.12"
rand = "0.9"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
rmp-serde = "1.3"
serde = { version = "1", features = ["derive"] }
@ -33,9 +24,7 @@ serde_json = "1"
sha2 = "0.10"
thiserror = "2"
tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.6", features = ["fs", "trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
unicode-segmentation = "1"
uuid = { version = "1", features = ["v4", "serde"] }
zeroize = "1.8"

220
README.md
View file

@ -1,149 +1,121 @@
# Ghostlight
Ghostlight is a Rust library that keeps a simulated world honest, and a play
daemon built on it. A language model proposes; the library's kernel checks each
proposal against typed world state and admits or refuses it. Places, people,
knowledge, commitments, and time live in the kernel's journal, not in a model's
context window, so they survive restarts and long stories.
**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).
## What is here
**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.
| Part | Path | What it is |
|---|---|---|
| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. |
| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. |
| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. |
| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. |
| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. |
| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. |
| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. |
**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.
## How it works
- `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 kernel is sealed. A consumer reaches it through create/open, immutable
snapshots, command submission, typed receipts, and the runner and port entry
points. There is no public mutable state, ID issuer, reducer, or journal
writer, and the crate declares no cargo features. The seal is proven by
`compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by
`crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests,
and opportunities and asserts that each is refused with nothing committed.
- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a
command, is decided by one reducer, and commits atomically to a
digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb).
- Models author through typed tools and a repair loop
(`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A
refused proposal returns its mismatches to the model; it never edits state.
- A Persona speaks only prose. A Projector builds its view, and an Interpreter
lowers its words into typed proposals with exact source quotes
(`crates/ghostlight/src/controllers.rs`).
- Vault evidence for seeding comes from a read-only markdown directory reader
(`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the
directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so
changing it needs a restart
(`crates/ghostlight-dungeon/src/runtime.rs`).
- Other programs can propose changes to the characters they control through
`POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`,
`crates/ghostlight/src/consumer.rs`). The contract is
`docs/architecture/ghostlight-world-consumer-api.md`.
Start with those two documents, then `cargo doc -p ghostlight --open`.
## Ghostlight Dungeon
**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.
Dungeon is the single-player, DM-led client. A player signs in with Heimdall,
creates a world, seeds it from a vault, approves and activates it, and plays in
free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the
turn lifecycle; the interface is one Eve surface, `ghostlight.play`
(`crates/ghostlight-dungeon/src/eve.rs`, described in
`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is
not a developer, Dungeon is judged by whether someone can sit down and play.
## Elaborators
Where it runs:
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.
- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`.
Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery
and Heimdall's command-boundary record. The operating runbook is
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
- **Inference** is routed by model name: a `local/` prefix goes to an
OpenAI-compatible loopback endpoint
(`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`),
which in production is Bonsai on Raven's GPU reached through a loopback
tunnel; a `claude` prefix goes to the SDK sidecar
(`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`).
Ghostlight never reads, stores, or forwards a model credential.
- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and
derives authority from the world's owner and approvers.
## Direction (planned, not built)
## Direction
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 next work makes Ghostlight the simulation behind Aetheria's public story.
The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for:
## Ghostlight Dungeon (parked)
- a world loaded from the Aetheria verse, with factions and a cast;
- storylets for Aetheria;
- a persistent cast whose characters keep identity, memory, and commitments
across sessions, so serialized episodes can follow them;
- episodes a cheap pipeline can turn into a video stream, and channel
characters who later appear in the game.
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.
None of this exists yet. Nothing under `crates/` loads Aetheria lore, models
storylets, or produces an episode. Every event the kernel admits is meant to
have exactly one render path: ship action filmed in Aetheria, or a social verb
staged as an Aetheria conversation. Today a world can declare any affordance
kind, and the kernel does not enforce that restriction.
## Repo map
| 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. |
`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.
## Build and test
The release target is Linux on Yggdrasil; a Windows build says nothing about
it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the
verification:
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
workstation:
```sh
git submodule update --init vendor/eve
cargo build --locked --workspace
cargo test --locked -p ghostlight-persona-projection
cargo test --locked -p ghostlight
cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon
cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon
npm --prefix web ci
npm --prefix web test
npm --prefix web run build
cargo test --locked -p ghostlight-verse
```
The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test`
(also the root scripts `sdk:build` and `sdk:test`). Four Dungeon tests are
`#[ignore]`d because they drive the vendored browser lowering through
`tools/eve_client_bridge.mjs` and need Node beside cargo. The release
acceptance step runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
(ignored by default) against a real model.
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.
To run Dungeon locally: `npm run dungeon:run`, or
`cargo run -p ghostlight-dungeon -- --state-root PATH`. Configuration is by
`GHOSTLIGHT_*` environment variables; the recipe's `required_environment`
lists the ones a deployment sets.
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.
## Documents
- `AGENTS.md`: repository doctrine for agents.
- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture
of the world machine.
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the
kernel's current mechanism.
- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract.
- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface.
- `docs/architecture/ghostlight-library-extraction.md`,
`ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in
`-cut.md` and `-postmortem.md` are dated history.
- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line
predates the play-agent cuts; read it with `-cut.md` beside it, not as the
current play surface (`crates/ghostlight-dungeon/src/play.rs` is).
- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by
the play agent. Nothing in `crates/` implements it.
- `notes/fresh-workspace-handoff.md`: where things run and where open work is
recorded.
- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays
written before the rebuild. They describe campaigns, co-op, Nemesis
and strategic resolvers as organs; none of that exists in `crates/`. They do not
describe the running machine.
- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted
vaults; nothing in `crates/` implements it.
Runtime documents use MessagePack-backed CultCache. JSON appears only at schema
publication, browser, model-provider, and diagnostic boundaries.
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
publication, model-provider and diagnostic boundaries.

View file

@ -1,48 +0,0 @@
[package]
name = "ghostlight-dungeon"
version.workspace = true
edition.workspace = true
license.workspace = true
autobins = false
[[bin]]
name = "ghostlight-dungeon"
path = "src/main.rs"
[dependencies]
anyhow.workspace = true
aes-gcm.workspace = true
async-trait.workspace = true
async-stream.workspace = true
axum.workspace = true
chrono.workspace = true
base64.workspace = true
codex-connector.workspace = true
cultcache-rs.workspace = true
cultnet-rs.workspace = true
cultmesh-rs.workspace = true
jsonwebtoken.workspace = true
libc = "0.2"
hmac.workspace = true
ghostlight = { path = "../ghostlight" }
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
rand.workspace = true
reqwest.workspace = true
rmp-serde.workspace = true
serde.workspace = true
serde_json.workspace = true
serde_bytes = "0.11"
sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
tower-http.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
uuid.workspace = true
zeroize.workspace = true
[dev-dependencies]
ghostlight = { path = "../ghostlight", features = ["test-support"] }
tempfile = "3"
tokio = { workspace = true, features = ["test-util"] }
tower = { version = "0.5", features = ["util"] }

View file

@ -1,65 +0,0 @@
use std::{env, path::Path, process::Command};
fn main() {
println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT");
let manifest_dir =
env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR");
let repo_root = Path::new(&manifest_dir).join("../..");
emit_git_rerun_paths(&repo_root);
// Release tooling injects the clean-tree commit. Git discovery is only a
// convenience for local builds. The exact ref and reflog dependencies above
// keep that convenience from silently publishing stale provenance.
let commit = env::var("GHOSTLIGHT_BUILD_COMMIT")
.ok()
.filter(|value| is_commit(value))
.or_else(|| git_commit(&repo_root))
.expect("GhostlightDungeon builds require an exact 40-hex source commit");
println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}");
}
fn emit_git_rerun_paths(repo_root: &Path) {
for git_path in ["HEAD", "logs/HEAD", "packed-refs"] {
if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) {
emit_rerun_path(repo_root, &path);
}
}
if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"])
&& let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference])
{
emit_rerun_path(repo_root, &path);
}
}
fn emit_rerun_path(repo_root: &Path, git_path: &str) {
let path = Path::new(git_path);
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
repo_root.join(path)
};
println!("cargo:rerun-if-changed={}", resolved.display());
}
fn git_commit(repo_root: &Path) -> Option<String> {
git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value))
}
fn git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
let output = Command::new("git")
.args(args)
.current_dir(repo_root)
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()
.map(|value| value.trim().to_owned())
.filter(|value| !value.is_empty())
}
fn is_commit(value: &str) -> bool {
value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}

View file

@ -1,939 +0,0 @@
//! Local custody for authenticated Heimdall sessions.
//!
//! This store owns only browser-session continuity. It does not own world
//! selection, commands, idempotency, exports, or fictional state.
use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh};
use ghostlight::VerifiedPrincipalEvidence;
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
use anyhow::{Context, bail};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use chrono::{DateTime, Utc};
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{collections::BTreeMap, path::Path};
const STORE_TYPE: &str = "ghostlight.app_session_store.v2";
/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the
/// `app-sessions` state slot's schema against this constant.
pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2";
const STORE_KEY: &str = "primary";
const MAX_SESSION_RECORDS: usize = 1_024;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AppSessionState {
schema: String,
sessions: BTreeMap<String, AppSession>,
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct AppSession {
schema: String,
cookie_hash: String,
account_subject_hash: String,
heimdall_session_id: String,
access_revision: u64,
verified_capabilities: Vec<String>,
access_expires_at: DateTime<Utc>,
refresh_expires_at: DateTime<Utc>,
encrypted_refresh_claim: String,
created_at: DateTime<Utc>,
last_refresh_at: DateTime<Utc>,
revoked_at: Option<DateTime<Utc>>,
}
/// The clause by which the local commit refuses a refresh Heimdall executed.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub(crate) enum RefreshRefusal {
#[error("local session was revoked during refresh")]
Revoked,
#[error("local session moved to another revision since the refresh candidate was taken")]
MovedSinceCandidate,
#[error("refresh receipt revision went backwards")]
RevisionWentBackwards,
#[error("refresh receipt names a different Heimdall session")]
SessionId,
#[error("refresh receipt names a different account")]
Account,
}
pub(crate) struct RefreshCandidate {
pub(crate) cookie_hash: String,
pub(crate) account_subject_hash: String,
pub(crate) heimdall_session_id: String,
pub(crate) access_revision: u64,
pub(crate) refresh_claim: String,
}
pub(crate) struct AppSessionOwner {
store: OwnedRedbMessagePackBackingStore,
row: CultCacheEnvelope,
state: AppSessionState,
wrapping_key: [u8; 32],
healthy: bool,
}
impl AppSessionOwner {
pub(crate) fn open(
store_path: impl AsRef<Path>,
wrapping_key_path: impl AsRef<Path>,
) -> anyhow::Result<Self> {
let wrapping_key = read_wrapping_key(wrapping_key_path.as_ref())?;
let store = OwnedRedbMessagePackBackingStore::new(store_path.as_ref())?;
store
.validate_path_identity()
.context("app-session store path identity is invalid")?;
let rows = store.pull_all()?;
let (row, state) = match rows.as_slice() {
[] => {
let state = AppSessionState {
schema: STORE_SCHEMA.into(),
sessions: BTreeMap::new(),
};
let row = envelope(&state)?;
if !store.compare_and_swap_batch(&[], vec![row.clone()])? {
bail!("app-session store changed during initialization");
}
(row, state)
}
[row]
if row.r#type == STORE_TYPE
&& row.key == STORE_KEY
&& row.schema_id.as_deref() == Some(STORE_SCHEMA) =>
{
let state: AppSessionState =
rmp_serde::from_slice(&row.payload).context("app-session state is corrupt")?;
if rmp_serde::to_vec_named(&state)? != row.payload {
bail!("app-session state is not canonical");
}
if state.schema != STORE_SCHEMA {
bail!("app-session state schema is invalid");
}
validate_state(&state, &wrapping_key)?;
(row.clone(), state)
}
_ => bail!(
"app-session v2 store contains foreign or legacy records; start with a fresh store"
),
};
Ok(Self {
store,
row,
state,
wrapping_key,
healthy: true,
})
}
pub(crate) fn create_session(
&mut self,
input: VerifiedSessionAdmission,
) -> anyhow::Result<String> {
if !input
.capabilities()
.iter()
.any(|value| value == "app_access")
{
bail!("Heimdall session lacks app_access");
}
if input.account_id().trim().is_empty()
|| input.heimdall_session_id().trim().is_empty()
|| input.refresh_claim().is_empty()
|| input.refresh_expires_at() <= input.access_expires_at()
{
bail!("Heimdall session material is invalid");
}
let mut cookie = [0_u8; 32];
rand::rng().fill_bytes(&mut cookie);
let raw_cookie = URL_SAFE_NO_PAD.encode(cookie);
let cookie_hash = secret_hash(&raw_cookie);
let account_subject_hash = secret_hash(&format!("heimdall-account:{}", input.account_id()));
let now = Utc::now();
let session = AppSession {
schema: "ghostlight.app_session.v2".into(),
cookie_hash: cookie_hash.clone(),
account_subject_hash: account_subject_hash.clone(),
heimdall_session_id: input.heimdall_session_id().to_owned(),
access_revision: input.access_revision(),
verified_capabilities: input.capabilities().to_vec(),
access_expires_at: input.access_expires_at(),
refresh_expires_at: input.refresh_expires_at(),
encrypted_refresh_claim: wrap_refresh(
&self.wrapping_key,
&account_subject_hash,
input.heimdall_session_id(),
input.refresh_claim(),
)?,
created_at: now,
last_refresh_at: now,
revoked_at: None,
};
let mut next = self.state.clone();
for previous in next.sessions.values_mut().filter(|previous| {
previous.revoked_at.is_none()
&& previous.heimdall_session_id == input.heimdall_session_id()
}) {
previous.revoked_at = Some(now);
}
next.sessions.insert(cookie_hash, session);
self.commit(next)?;
Ok(raw_cookie)
}
pub(crate) fn is_healthy(&self) -> bool {
self.healthy
}
pub(crate) fn validate_custody(&mut self) -> anyhow::Result<()> {
self.ensure_owned()
}
pub(crate) fn account_for_cookie(
&mut self,
raw_cookie: &str,
now: DateTime<Utc>,
) -> anyhow::Result<Option<VerifiedPrincipalEvidence>> {
self.ensure_owned()?;
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
return Ok(None);
};
Ok((session.revoked_at.is_none()
&& session.access_expires_at > now
&& session.refresh_expires_at > now
&& session
.verified_capabilities
.iter()
.any(|value| value == "app_access"))
.then(|| {
VerifiedPrincipalEvidence::new(
session.account_subject_hash.clone(),
session.access_expires_at,
)
}))
}
pub(crate) fn revoke_cookie(&mut self, raw_cookie: &str) -> anyhow::Result<bool> {
self.ensure_owned()?;
let cookie_hash = secret_hash(raw_cookie);
let mut next = self.state.clone();
let Some(session) = next.sessions.get_mut(&cookie_hash) else {
return Ok(false);
};
if session.revoked_at.is_some() {
return Ok(false);
}
session.revoked_at = Some(Utc::now());
self.commit(next)?;
Ok(true)
}
pub(crate) fn revoke_cookie_hash(&mut self, cookie_hash: &str) -> anyhow::Result<bool> {
self.ensure_owned()?;
let mut next = self.state.clone();
let Some(session) = next.sessions.get_mut(cookie_hash) else {
return Ok(false);
};
if session.revoked_at.is_some() {
return Ok(false);
}
session.revoked_at = Some(Utc::now());
self.commit(next)?;
Ok(true)
}
pub(crate) fn session_for_logout(
&mut self,
raw_cookie: &str,
) -> anyhow::Result<Option<RefreshCandidate>> {
self.ensure_owned()?;
let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else {
return Ok(None);
};
match self.refresh_candidate(session) {
Ok(candidate) => Ok(Some(candidate)),
Err(error) => self.poison(error),
}
}
pub(crate) fn sessions_due_for_refresh(
&mut self,
now: DateTime<Utc>,
refresh_before: chrono::Duration,
) -> anyhow::Result<Vec<RefreshCandidate>> {
self.ensure_owned()?;
let candidates = self
.state
.sessions
.values()
.filter(|session| {
session.revoked_at.is_none()
&& session.refresh_expires_at > now
&& session.access_expires_at <= now + refresh_before
})
.map(|session| self.refresh_candidate(session))
.collect::<anyhow::Result<Vec<_>>>();
match candidates {
Ok(candidates) => Ok(candidates),
Err(error) => self.poison(error),
}
}
pub(crate) fn apply_refresh(
&mut self,
cookie_hash: &str,
expected_access_revision: u64,
input: VerifiedSessionRefresh,
) -> anyhow::Result<()> {
if !input
.capabilities()
.iter()
.any(|value| value == "app_access")
{
bail!("refreshed Heimdall session lacks app_access");
}
if input.refresh_claim().is_empty()
|| input.refresh_expires_at() <= input.access_expires_at()
{
bail!("refreshed Heimdall session material is invalid");
}
let mut next = self.state.clone();
let session = next
.sessions
.get_mut(cookie_hash)
.context("local session vanished during refresh")?;
if session.revoked_at.is_some() {
return Err(RefreshRefusal::Revoked.into());
}
if session.access_revision != expected_access_revision {
return Err(RefreshRefusal::MovedSinceCandidate.into());
}
// Heimdall's access_revision is a revocation epoch: refresh rotates
// the token at the same revision, so equal is the normal receipt and
// only a lower one is a refusal.
if input.access_revision() < expected_access_revision {
return Err(RefreshRefusal::RevisionWentBackwards.into());
}
if session.heimdall_session_id != input.heimdall_session_id() {
return Err(RefreshRefusal::SessionId.into());
}
if session.account_subject_hash
!= secret_hash(&format!("heimdall-account:{}", input.account_id()))
{
return Err(RefreshRefusal::Account.into());
}
session.access_revision = input.access_revision();
session.verified_capabilities = input.capabilities().to_vec();
session.access_expires_at = input.access_expires_at();
session.refresh_expires_at = input.refresh_expires_at();
session.encrypted_refresh_claim = wrap_refresh(
&self.wrapping_key,
&session.account_subject_hash,
&session.heimdall_session_id,
input.refresh_claim(),
)?;
session.last_refresh_at = Utc::now();
self.commit(next)
}
fn refresh_candidate(&self, session: &AppSession) -> anyhow::Result<RefreshCandidate> {
Ok(RefreshCandidate {
cookie_hash: session.cookie_hash.clone(),
account_subject_hash: session.account_subject_hash.clone(),
heimdall_session_id: session.heimdall_session_id.clone(),
access_revision: session.access_revision,
refresh_claim: unwrap_refresh(
&self.wrapping_key,
&session.account_subject_hash,
&session.heimdall_session_id,
&session.encrypted_refresh_claim,
)?,
})
}
fn commit(&mut self, mut next: AppSessionState) -> anyhow::Result<()> {
self.ensure_owned()?;
prune_sessions(&mut next, Utc::now());
let next_row = envelope(&next)?;
let swapped = self
.store
.compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]);
if !matches!(swapped, Ok(true)) {
return self.poison(anyhow::anyhow!(
"app-session commit outcome is uncertain; sole ownership was lost"
));
}
if self.store.validate_path_identity().is_err() {
return self.poison(anyhow::anyhow!(
"app-session store path changed after durable commit"
));
}
self.row = next_row;
self.state = next;
Ok(())
}
fn ensure_owned(&mut self) -> anyhow::Result<()> {
if !self.healthy {
bail!("app-session custody is poisoned; reopen the owner");
}
if self.store.validate_path_identity().is_err() {
self.healthy = false;
bail!("app-session store ownership was lost");
}
Ok(())
}
fn poison<T>(&mut self, error: anyhow::Error) -> anyhow::Result<T> {
self.healthy = false;
Err(error)
}
}
/// The idempotency key of the refresh that spends `refresh_claim`: a retry of
/// that refresh derives the same key, the next refresh (a new claim) a new one,
/// and the claim itself never leaves the process in it.
pub(crate) fn refresh_idempotency_key(refresh_claim: &str) -> String {
format!(
"refresh:{}",
secret_hash(&format!("heimdall-refresh-key:{refresh_claim}"))
)
}
pub(crate) fn secret_hash(value: &str) -> String {
format!("sha256:{:x}", Sha256::digest(value.as_bytes()))
}
fn envelope(state: &AppSessionState) -> anyhow::Result<CultCacheEnvelope> {
Ok(CultCacheEnvelope {
key: STORE_KEY.into(),
r#type: STORE_TYPE.into(),
payload: rmp_serde::to_vec_named(state)?,
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(STORE_SCHEMA.into()),
})
}
fn validate_state(state: &AppSessionState, wrapping_key: &[u8; 32]) -> anyhow::Result<()> {
if state.schema != STORE_SCHEMA {
bail!("app-session state schema is invalid");
}
for (key, session) in &state.sessions {
if key != &session.cookie_hash
|| !is_secret_hash(key)
|| !is_secret_hash(&session.account_subject_hash)
|| session.schema != "ghostlight.app_session.v2"
|| session.heimdall_session_id.trim().is_empty()
|| session.heimdall_session_id.trim() != session.heimdall_session_id
|| !session
.verified_capabilities
.iter()
.any(|value| value == "app_access")
|| session.refresh_expires_at <= session.access_expires_at
|| session.last_refresh_at < session.created_at
|| session
.revoked_at
.is_some_and(|revoked_at| revoked_at < session.created_at)
{
bail!("app-session state contains a noncanonical session");
}
unwrap_refresh(
wrapping_key,
&session.account_subject_hash,
&session.heimdall_session_id,
&session.encrypted_refresh_claim,
)
.context("app-session state contains an unreadable refresh claim")?;
}
Ok(())
}
fn is_secret_hash(value: &str) -> bool {
value.len() == 71
&& value.strip_prefix("sha256:").is_some_and(|hex| {
hex.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'))
})
}
fn prune_sessions(state: &mut AppSessionState, now: DateTime<Utc>) {
let retention_floor = now - chrono::Duration::days(7);
state.sessions.retain(|_, session| {
session.refresh_expires_at > retention_floor
&& session
.revoked_at
.is_none_or(|revoked_at| revoked_at > retention_floor)
});
if state.sessions.len() <= MAX_SESSION_RECORDS {
return;
}
let mut oldest = state
.sessions
.iter()
.map(|(key, session)| {
let live = session.revoked_at.is_none() && session.refresh_expires_at > now;
(live, session.last_refresh_at, key.clone())
})
.collect::<Vec<_>>();
oldest.sort();
for (_, _, key) in oldest
.into_iter()
.take(state.sessions.len() - MAX_SESSION_RECORDS)
{
state.sessions.remove(&key);
}
}
fn read_wrapping_key(path: &Path) -> anyhow::Result<[u8; 32]> {
let source = std::fs::read(path)
.with_context(|| format!("failed to read session wrapping key {}", path.display()))?;
let decoded = if source.len() == 32 {
source
} else {
URL_SAFE_NO_PAD
.decode(std::str::from_utf8(&source)?.trim())
.context("session wrapping key must be 32 raw bytes or base64url")?
};
decoded
.try_into()
.map_err(|_| anyhow::anyhow!("session wrapping key must be exactly 32 bytes"))
}
fn wrap_refresh(
key: &[u8; 32],
account_hash: &str,
session_id: &str,
claim: &str,
) -> anyhow::Result<String> {
let mut iv = [0_u8; 12];
rand::rng().fill_bytes(&mut iv);
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
let ciphertext = Aes256Gcm::new_from_slice(key)?
.encrypt(
(&iv).into(),
aes_gcm::aead::Payload {
msg: claim.as_bytes(),
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("failed to wrap Heimdall refresh claim"))?;
Ok(format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(iv),
URL_SAFE_NO_PAD.encode(ciphertext)
))
}
fn unwrap_refresh(
key: &[u8; 32],
account_hash: &str,
session_id: &str,
wrapped: &str,
) -> anyhow::Result<String> {
let (iv, ciphertext) = wrapped
.split_once('.')
.context("wrapped refresh claim is malformed")?;
let iv: [u8; 12] = URL_SAFE_NO_PAD
.decode(iv)?
.try_into()
.map_err(|_| anyhow::anyhow!("wrapped refresh IV is invalid"))?;
let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?;
let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}");
let plaintext = Aes256Gcm::new_from_slice(key)?
.decrypt(
(&iv).into(),
aes_gcm::aead::Payload {
msg: &ciphertext,
aad: aad.as_bytes(),
},
)
.map_err(|_| anyhow::anyhow!("failed to unwrap Heimdall refresh claim"))?;
String::from_utf8(plaintext).context("Heimdall refresh claim is not UTF-8")
}
#[cfg(test)]
mod tests {
use super::*;
/// Every `.rs` file under `src`, subdirectories included, with everything
/// a `#[cfg(test)]` attribute guards removed — whatever that item is, and
/// whatever a test module is called.
///
/// The item's extent is read from the layout: an attribute at indent `N`
/// guards lines until the first later line at indent `N` that closes a
/// block, or, for an item with no body, until the first line at indent `N`
/// that ends in `;` or `,`. This is a claim about rustfmt's output, not
/// about Rust's grammar, and it is the reason the scan does not try to
/// balance braces: braces inside string and character literals are not
/// block structure, and a scanner that counts them reads the end of a test
/// module in the wrong place.
fn production_sources() -> Vec<(std::path::PathBuf, String)> {
fn indent(line: &str) -> usize {
line.len() - line.trim_start().len()
}
fn strip_cfg_test(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let mut kept: Vec<&str> = Vec::new();
let mut index = 0;
while index < lines.len() {
let line = lines[index];
if line.trim() != "#[cfg(test)]" {
kept.push(line);
index += 1;
continue;
}
let guard = indent(line);
index += 1;
// A body opens on the attribute's own item; without one, the
// item ends at its first terminator.
let mut opened = lines
.get(index)
.is_some_and(|next| next.trim_end().ends_with('{'));
while index < lines.len() {
let body = lines[index];
index += 1;
let trimmed = body.trim();
if trimmed.is_empty() {
continue;
}
if opened {
if indent(body) == guard
&& (trimmed == "}" || trimmed == "};" || trimmed == "},")
{
break;
}
} else if indent(body) == guard
&& (trimmed.ends_with(';') || trimmed.ends_with(','))
{
break;
} else if indent(body) == guard && trimmed.ends_with('{') {
opened = true;
}
}
}
kept.join("\n")
}
let mut sources = Vec::new();
let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")];
while let Some(directory) = pending.pop() {
for entry in std::fs::read_dir(&directory).expect("the source tree reads") {
let path = entry.expect("a readable entry").path();
if path.is_dir() {
pending.push(path);
continue;
}
if path.extension().and_then(|value| value.to_str()) != Some("rs") {
continue;
}
let text = std::fs::read_to_string(&path)
.expect("the source reads")
.replace("\r\n", "\n");
sources.push((path, strip_cfg_test(&text)));
}
}
sources
}
/// True when the text immediately following the type's name calls its
/// `new`. Written as a scan rather than one literal so the qualified
/// `<Type>::new(` form and any whitespace the formatter may introduce
/// count the same as the plain path.
fn calls_new(tail: &str) -> bool {
let tail = tail.trim_start();
let tail = tail.strip_prefix('>').unwrap_or(tail).trim_start();
let Some(tail) = tail.strip_prefix("::") else {
return false;
};
let Some(tail) = tail.trim_start().strip_prefix("new") else {
return false;
};
tail.trim_start().starts_with('(')
}
/// The library owns `VerifiedPrincipalEvidence` and gives it a public
/// constructor, so the single-minter rule its private fields used to
/// enforce is this one: production mints it in `account_for_cookie` and
/// nowhere else, from a live cookie this owner holds custody of. A second
/// minter anywhere in Dungeon's production source is a second authority
/// over who the kernel believes is speaking.
///
/// The type is followed by name, so production may not give it a second
/// name: an `as` import or a type alias is refused outright rather than
/// counted, because a renamed minter is one this test cannot follow. What
/// it still cannot see is a call that never spells the type — a macro
/// expansion, or a generic instantiated elsewhere.
#[test]
fn soul_exactly_one_production_site_mints_verified_principal_evidence() {
// Assembled from halves so this test's own source is never a match,
// whatever the scan above decides about this module's extent.
let name = format!("VerifiedPrincipal{}", "Evidence");
let mut sites = Vec::new();
for (path, production) in production_sources() {
for (at, _) in production.match_indices(name.as_str()) {
let tail = &production[at + name.len()..];
assert!(
!tail.trim_start().starts_with("as "),
"{} renames {name} on import",
path.display()
);
if calls_new(tail) {
sites.push(path.clone());
}
}
for line in production.lines() {
assert!(
!(line.trim_start().starts_with("type ") && line.contains(name.as_str())),
"{} aliases {name} to another name: {line}",
path.display()
);
}
}
assert_eq!(
sites.len(),
1,
"production mints {name} in exactly one place: {sites:?}"
);
assert!(
sites[0].ends_with("app_session.rs"),
"the one minter is not this owner: {:?}",
sites[0]
);
}
fn session(claim: &str) -> VerifiedSessionAdmission {
VerifiedSessionAdmission::fixture(
"account-1",
"heimdall-session-1",
3,
Utc::now() + chrono::Duration::minutes(5),
Utc::now() + chrono::Duration::days(7),
claim,
)
}
#[test]
fn store_rejects_an_alternate_messagepack_shape() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [5_u8; 32]).unwrap();
let store_path = directory.path().join("app-sessions-v2.cc");
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
let state = AppSessionState {
schema: STORE_SCHEMA.into(),
sessions: BTreeMap::new(),
};
let row = CultCacheEnvelope {
key: STORE_KEY.into(),
r#type: STORE_TYPE.into(),
payload: rmp_serde::to_vec(&state).unwrap(),
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(STORE_SCHEMA.into()),
};
assert!(store.compare_and_swap_batch(&[], vec![row]).unwrap());
drop(store);
assert!(AppSessionOwner::open(&store_path, &key).is_err());
}
#[test]
fn stores_only_session_custody_and_reopens() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [7_u8; 32]).unwrap();
let store = directory.path().join("app-sessions-v2.cc");
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
let cookie = owner
.create_session(session("private-refresh-claim"))
.unwrap();
let account = owner
.account_for_cookie(&cookie, Utc::now())
.unwrap()
.unwrap();
let account_hash = account.account_subject_hash().to_owned();
drop(owner);
let mut reopened = AppSessionOwner::open(&store, &key).unwrap();
assert_eq!(
reopened
.account_for_cookie(&cookie, Utc::now())
.unwrap()
.unwrap()
.account_subject_hash(),
account_hash
);
assert!(reopened.revoke_cookie(&cookie).unwrap());
assert_eq!(
reopened.account_for_cookie(&cookie, Utc::now()).unwrap(),
None
);
}
#[test]
fn exact_heimdall_adoption_retry_rotates_the_only_live_cookie() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [9_u8; 32]).unwrap();
let store = directory.path().join("app-sessions-v2.cc");
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
let first = owner
.create_session(session("same-refresh-receipt"))
.unwrap();
let second = owner
.create_session(session("same-refresh-receipt"))
.unwrap();
assert_ne!(first, second);
assert_eq!(owner.account_for_cookie(&first, Utc::now()).unwrap(), None);
assert!(
owner
.account_for_cookie(&second, Utc::now())
.unwrap()
.is_some()
);
assert_eq!(
owner
.state
.sessions
.values()
.filter(|session| session.revoked_at.is_none())
.count(),
1
);
}
#[test]
fn rejects_legacy_or_foreign_store_instead_of_migrating_it() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [3_u8; 32]).unwrap();
let store_path = directory.path().join("app-sessions-v2.cc");
let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap();
let foreign = CultCacheEnvelope {
key: "primary".into(),
r#type: "app_session_store.v1".into(),
payload: vec![],
stored_at: Utc::now().to_rfc3339(),
schema_id: Some("ghostlight.app_session_store.v1".into()),
};
assert!(store.compare_and_swap_batch(&[], vec![foreign]).unwrap());
assert!(AppSessionOwner::open(&store_path, &key).is_err());
}
#[test]
fn a_refresh_for_a_session_that_moved_is_refused_by_name() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [5_u8; 32]).unwrap();
let mut owner =
AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap();
let cookie = owner
.create_session(VerifiedSessionAdmission::fixture(
"account",
"session",
1,
Utc::now() + chrono::Duration::minutes(1),
Utc::now() + chrono::Duration::days(1),
"claim-0",
))
.unwrap();
let receipt = VerifiedSessionRefresh::fixture(
"account",
"session",
1,
Utc::now() + chrono::Duration::minutes(1),
Utc::now() + chrono::Duration::days(1),
"claim-1",
);
let error = owner
.apply_refresh(&secret_hash(&cookie), 0, receipt)
.unwrap_err();
assert_eq!(
error.downcast_ref::<RefreshRefusal>(),
Some(&RefreshRefusal::MovedSinceCandidate)
);
}
#[test]
fn refresh_claim_is_encrypted_and_exactly_bound() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [11_u8; 32]).unwrap();
let store = directory.path().join("app-sessions-v2.cc");
let mut owner = AppSessionOwner::open(&store, &key).unwrap();
let cookie = owner
.create_session(session("needle-private-refresh"))
.unwrap();
let candidate = owner.session_for_logout(&cookie).unwrap().unwrap();
assert_eq!(candidate.refresh_claim, "needle-private-refresh");
drop(owner);
let bytes = std::fs::read(store).unwrap();
assert!(
!bytes
.windows("needle-private-refresh".len())
.any(|window| window == b"needle-private-refresh")
);
}
#[test]
fn replacing_the_store_path_revokes_session_authentication_until_reopen() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [19_u8; 32]).unwrap();
let path = directory.path().join("app-sessions-v2.cc");
let displaced = directory.path().join("displaced.cc");
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
let cookie = owner
.create_session(session("private-refresh-claim"))
.unwrap();
std::fs::rename(&path, &displaced).unwrap();
std::fs::File::create(&path).unwrap();
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
assert!(!owner.is_healthy());
std::fs::remove_file(&path).unwrap();
std::fs::rename(&displaced, &path).unwrap();
assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err());
drop(owner);
let mut reopened = AppSessionOwner::open(&path, &key).unwrap();
assert!(
reopened
.account_for_cookie(&cookie, Utc::now())
.unwrap()
.is_some()
);
}
#[test]
fn reopen_rejects_corrupt_inner_session_custody() {
let directory = tempfile::tempdir().unwrap();
let key = directory.path().join("session.key");
std::fs::write(&key, [23_u8; 32]).unwrap();
let path = directory.path().join("app-sessions-v2.cc");
let mut owner = AppSessionOwner::open(&path, &key).unwrap();
owner
.create_session(session("private-refresh-claim"))
.unwrap();
drop(owner);
let store = OwnedRedbMessagePackBackingStore::new(&path).unwrap();
let rows = store.pull_all().unwrap();
let mut state: AppSessionState = rmp_serde::from_slice(&rows[0].payload).unwrap();
state
.sessions
.values_mut()
.next()
.unwrap()
.encrypted_refresh_claim = "not-an-encrypted-claim".into();
let corrupt = envelope(&state).unwrap();
assert!(store.compare_and_swap_batch(&rows, vec![corrupt]).unwrap());
drop(store);
assert!(AppSessionOwner::open(&path, &key).is_err());
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
{
"schema": "ghostlight.eve_client_bridge_fixture.v1",
"steps": [
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-10-09T23:58:34.345Z",
"operation": {
"idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528",
"operationId": "world.create",
"routeHint": {
"sourceVersion": 0,
"transport": "https-json"
},
"schemaId": "ghostlight.world_create.v4"
},
"payload": {
"bindings": {
"brief": "A world captured for the committed Eve client bridge fixture.",
"jurisdictions": "[]",
"lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}",
"subject_label": "The Fixture Operator",
"targets": "{}",
"title": "Bridge Fixture World"
}
},
"providerId": "gamecult.ghostlight.dungeon",
"receiptSchema": "gamecult.eve.command_result.v1",
"schema": "gamecult.eve.command_invocation.v1",
"surfaceId": "ghostlight.play"
},
"label": "world.create"
},
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-10-09T23:58:35.759Z",
"operation": {
"idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b",
"operationId": "world.approve",
"routeHint": {
"sourceVersion": 1,
"transport": "https-json"
},
"schemaId": "ghostlight.world_approve.v0"
},
"payload": {},
"providerId": "gamecult.ghostlight.dungeon",
"receiptSchema": "gamecult.eve.command_result.v1",
"schema": "gamecult.eve.command_invocation.v1",
"surfaceId": "ghostlight.play"
},
"label": "world.approve"
},
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-10-09T23:58:37.240Z",
"operation": {
"idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2",
"operationId": "world.activate",
"routeHint": {
"sourceVersion": 2,
"transport": "https-json"
},
"schemaId": "ghostlight.world_activate.v0"
},
"payload": {},
"providerId": "gamecult.ghostlight.dungeon",
"receiptSchema": "gamecult.eve.command_result.v1",
"schema": "gamecult.eve.command_invocation.v1",
"surfaceId": "ghostlight.play"
},
"label": "world.activate"
},
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-10-09T23:58:38.611Z",
"operation": {
"idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39",
"operationId": "world.play",
"routeHint": {
"sourceVersion": 3,
"transport": "https-json"
},
"schemaId": "ghostlight.world_play.v0"
},
"payload": {
"bindings": {
"text": "The new owner looks around."
}
},
"providerId": "gamecult.ghostlight.dungeon",
"receiptSchema": "gamecult.eve.command_result.v1",
"schema": "gamecult.eve.command_invocation.v1",
"surfaceId": "ghostlight.play"
},
"label": "world.play"
}
],
"vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0"
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,21 +0,0 @@
mod app_session;
mod eve;
mod heimdall;
mod idunn_health;
mod mesh;
mod play;
mod runtime;
use anyhow::bail;
use std::path::PathBuf;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
let state_root = match args.as_slice() {
[flag, path] if flag == "--state-root" => Some(PathBuf::from(path)),
[] => None,
_ => bail!("usage: ghostlight-dungeon [--state-root PATH]"),
};
runtime::run(state_root).await
}

View file

@ -1,464 +0,0 @@
//! Derived CultMesh and Eve projection.
//!
//! The mesh publishes the world owner's current surface. It never pulls or
//! repairs world state and it has no command authority.
use crate::eve;
use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot};
use anyhow::Result;
use chrono::{DateTime, Utc};
use cultcache_rs::DatabaseEntry;
use cultmesh_rs::{
CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions,
publish_cultnet_messages_to_rudp_catalog,
};
use serde_json::{Value, json};
use std::{
net::SocketAddr,
path::Path,
sync::{
Arc, Condvar, Mutex,
atomic::{AtomicBool, Ordering},
},
thread,
};
pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon";
pub(crate) const SURFACE_ID: &str = "ghostlight.play";
pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands";
pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1";
pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1";
/// The repository that owns this surface, as the command receipt contract
/// requires of every result.
pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight";
const HEALTH_KEY: &str = "ghostlight:dungeon:health";
const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon";
const SURFACE_KEY: &str = "eve:surface:ghostlight.play";
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")]
struct EveSurfaceRecord {
#[cultcache(key = 0)]
value: Value,
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "gamecult.eve.provider_advertisement",
schema = "gamecult.eve.provider_advertisement.v1"
)]
struct EveProviderAdvertisementRecord {
#[cultcache(key = 0)]
value: Value,
}
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
#[cultcache(
type = "ghostlight.service_health",
schema = "ghostlight.service_health.v2"
)]
struct ServiceHealthRecord {
#[cultcache(key = 0)]
value: Value,
}
cultmesh_rs::cultmesh_documents!(GhostlightDocuments {
EveSurfaceRecord => "gamecult.eve.surface.v1",
EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1",
ServiceHealthRecord => "ghostlight.service_health.v2",
});
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct MeshRuntimeIdentity {
pub(crate) runtime_id: String,
pub(crate) service_id: String,
pub(crate) located_service: String,
}
impl Default for MeshRuntimeIdentity {
fn default() -> Self {
Self {
runtime_id: "ghostlight-dungeon".into(),
service_id: "ghostlight-dungeon".into(),
located_service: "local".into(),
}
}
}
#[derive(Clone)]
pub(crate) struct MeshPublisher {
node: Arc<Mutex<CultMeshNode>>,
remote: Option<RemoteReplication>,
identity: MeshRuntimeIdentity,
}
#[derive(Clone)]
struct RemoteReplication {
target: SocketAddr,
runtime_id: String,
pending: Arc<(Mutex<Option<Vec<cultnet_rs::CultNetMessage>>>, Condvar)>,
alive: Arc<AtomicBool>,
}
impl MeshPublisher {
pub(crate) fn open(
store_path: impl AsRef<Path>,
rudp_target: Option<SocketAddr>,
identity: MeshRuntimeIdentity,
) -> Result<Self> {
if let Some(parent) = store_path.as_ref().parent() {
std::fs::create_dir_all(parent)?;
}
let node = CultMesh::create_node(
store_path,
GhostlightDocuments,
CultMeshNodeOptions {
runtime_id: identity.runtime_id.clone(),
// Ghostlight derives its own interface from the local owner.
// Stale remote surfaces must never become startup authority.
pull_on_start: false,
},
)?;
let remote = rudp_target
.map(|target| RemoteReplication::start(target, identity.runtime_id.clone()))
.transpose()?;
Ok(Self {
node: Arc::new(Mutex::new(node)),
remote,
identity,
})
}
pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value {
provider_advertisement(&self.identity, updated_at)
}
pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result<Value> {
let surface = eve::mesh_surface(snapshot);
let world_state = eve::world_state(snapshot);
let version = eve::surface_version(snapshot);
let updated_at = Utc::now().to_rfc3339();
let health = json!({
"schema":"ghostlight.service_health.v2",
"status":"ok",
"worldState":world_state,
"surfaceVersion":version,
"updatedAtUtc":updated_at,
"runtime":self.identity.runtime_id,
"commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
});
let advertisement = provider_advertisement(&self.identity, &updated_at);
let mut node = self
.node
.lock()
.map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?;
let mut remote_messages = Vec::new();
self.put_and_stage(
&mut node,
ADVERTISEMENT_KEY,
&EveProviderAdvertisementRecord {
value: advertisement,
},
&mut remote_messages,
)?;
self.put_and_stage(
&mut node,
SURFACE_KEY,
&EveSurfaceRecord { value: surface },
&mut remote_messages,
)?;
// Health is the completion marker for this derived projection. A
// failed advertisement or surface write must leave the prior version
// visibly stale rather than publishing green first.
self.put_and_stage(
&mut node,
HEALTH_KEY,
&ServiceHealthRecord {
value: health.clone(),
},
&mut remote_messages,
)?;
drop(node);
if let Some(remote) = &self.remote {
remote.enqueue(remote_messages);
}
Ok(health)
}
pub(crate) fn health(&self) -> Result<Value> {
if self
.remote
.as_ref()
.is_some_and(|remote| !remote.is_alive())
{
anyhow::bail!("Odin projection worker stopped");
}
self.node
.try_lock()
.map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))?
.get_required::<ServiceHealthRecord>(HEALTH_KEY)
.map(|record| record.value)
}
fn put_and_stage<T>(
&self,
node: &mut CultMeshNode,
key: impl Into<String>,
value: &T,
remote_messages: &mut Vec<cultnet_rs::CultNetMessage>,
) -> Result<()>
where
T: DatabaseEntry + serde::Serialize,
{
let key = key.into();
node.put(&key, value)?;
if let Some(remote) = &self.remote {
remote_messages.push(node.create_rudp_document_message(
&key,
value,
&remote.options(),
)?);
}
Ok(())
}
}
pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value {
json!({
"schema":"gamecult.eve.provider_advertisement.v1",
"providerId":PROVIDER_ID,
"serviceId":identity.service_id,
"verseId":"gamecult.private",
"rootVerse":"gamecult",
"canonicalService":"ghostlight-dungeon",
"locatedService":identity.located_service,
"cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon",
"title":"Ghostlight Dungeon",
"kind":"narrative.simulation",
"updatedAtUtc":updated_at,
"freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000},
"schemas":[
"gamecult.eve.surface.v1",
"gamecult.eve.command_invocation.v1",
COMMAND_RESULT_SCHEMA,
"heimdall.access_gate_state.v1",
STATE_SCHEMA,
CONSUMER_PATCH_SCHEMA,
CONSUMER_RECEIPT_SCHEMA
],
"witnesses":[{
"kind":"source-commit",
"ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
}],
"surfaces":[{
"surfaceId":SURFACE_ID,
"schema":"gamecult.eve.surface.v1",
"url":"/api/eve/surfaces/ghostlight.play",
"transport":"https-json",
"status":"available",
"surfaceKind":"subject-scoped-narrative",
"interactionModel":"typed-command-receipts",
"worldInteraction":{
"projectionKind":"owner-derived",
"stateSchemas":[STATE_SCHEMA],
"commandBoundary":COMMAND_BOUNDARY,
"receiptSchema":COMMAND_RESULT_SCHEMA,
"loweringTargets":["browser","eve-native","tui"],
"ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands."
},
"requiresPlugins":[{
"pluginId":"gamecult.heimdall.access",
"versionRange":"^1.0.0",
"availability":"required",
"requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"]
}]
}],
"commands":[{
"command":COMMAND_BOUNDARY,
"transport":"https-json",
"summary":"Canonical Ghostlight Eve command boundary."
}]
})
}
impl RemoteReplication {
fn start(target: SocketAddr, runtime_id: String) -> Result<Self> {
let pending = Arc::new((
Mutex::new(None::<Vec<cultnet_rs::CultNetMessage>>),
Condvar::new(),
));
let worker_pending = pending.clone();
let worker_runtime_id = runtime_id.clone();
let alive = Arc::new(AtomicBool::new(true));
let worker_alive = alive.clone();
thread::Builder::new()
.name("ghostlight-odin-rudp".into())
.spawn(move || {
struct Liveness(Arc<AtomicBool>);
impl Drop for Liveness {
fn drop(&mut self) {
self.0.store(false, Ordering::Release);
}
}
let _liveness = Liveness(worker_alive);
loop {
let messages = {
let (lock, ready) = &*worker_pending;
let mut pending = lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
while pending.is_none() {
pending = ready
.wait(pending)
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
pending.take().unwrap_or_default()
};
if messages.is_empty() {
continue;
}
let options = remote_options(target, &worker_runtime_id);
if let Err(error) =
publish_cultnet_messages_to_rudp_catalog(&messages, options)
{
tracing::warn!(
document_count = messages.len(),
%target,
%error,
"Odin publication failed; retaining the newest derived projection for retry"
);
let (lock, ready) = &*worker_pending;
let mut pending = lock
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if pending.is_none() {
*pending = Some(messages);
}
let _ = ready
.wait_timeout(pending, std::time::Duration::from_secs(2))
.unwrap_or_else(|poisoned| poisoned.into_inner());
}
}
})?;
Ok(Self {
target,
runtime_id,
pending,
alive,
})
}
fn is_alive(&self) -> bool {
self.alive.load(Ordering::Acquire)
}
fn options(&self) -> CultMeshRudpDocumentPublishOptions {
remote_options(self.target, &self.runtime_id)
}
fn enqueue(&self, messages: Vec<cultnet_rs::CultNetMessage>) {
let (lock, ready) = &*self.pending;
let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
*pending = Some(messages);
ready.notify_one();
}
}
fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions {
let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id);
options.source_agent_id = Some(PROVIDER_ID.into());
options.source_role = Some("narrative-simulation".into());
options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()];
options
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn publishes_only_owner_derived_surface_and_health() {
let directory = tempfile::tempdir().unwrap();
let publisher = MeshPublisher::open(
directory.path().join("mesh-v2.cc"),
None,
MeshRuntimeIdentity::default(),
)
.unwrap();
let health = publisher.publish(None).unwrap();
assert_eq!(health["worldState"], "empty");
assert_eq!(health["surfaceVersion"], 0);
assert_eq!(publisher.health().unwrap(), health);
let node = publisher.node.lock().unwrap();
let surface = node
.get_required::<EveSurfaceRecord>(SURFACE_KEY)
.unwrap()
.value;
assert_eq!(surface["type"], "surface-state");
assert_eq!(surface["schema"], "gamecult.eve.surface.v1");
assert_eq!(surface["providerId"], PROVIDER_ID);
assert_eq!(surface["version"], 0);
assert!(
surface["updatedAtUtc"]
.as_str()
.is_some_and(|value| value.parse::<DateTime<Utc>>().is_ok())
);
assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]);
assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]);
assert!(
node.get_required::<EveProviderAdvertisementRecord>(ADVERTISEMENT_KEY)
.is_ok()
);
assert!(node.get_required::<ServiceHealthRecord>(HEALTH_KEY).is_ok());
}
#[test]
fn unavailable_odin_cannot_block_local_publication() {
let directory = tempfile::tempdir().unwrap();
let publisher = MeshPublisher::open(
directory.path().join("mesh-v2.cc"),
Some("127.0.0.1:1".parse().unwrap()),
MeshRuntimeIdentity::default(),
)
.unwrap();
let started = std::time::Instant::now();
publisher.publish(None).unwrap();
assert!(started.elapsed() < std::time::Duration::from_millis(500));
assert_eq!(publisher.health().unwrap()["worldState"], "empty");
}
#[test]
fn stopped_odin_worker_makes_runtime_health_non_green() {
let directory = tempfile::tempdir().unwrap();
let publisher = MeshPublisher::open(
directory.path().join("mesh-v2.cc"),
Some("127.0.0.1:1".parse().unwrap()),
MeshRuntimeIdentity::default(),
)
.unwrap();
publisher.publish(None).unwrap();
publisher
.remote
.as_ref()
.unwrap()
.alive
.store(false, Ordering::Release);
assert!(publisher.health().is_err());
}
#[test]
fn advertisement_has_one_surface_and_one_command_boundary() {
let directory = tempfile::tempdir().unwrap();
let publisher = MeshPublisher::open(
directory.path().join("mesh-v2.cc"),
None,
MeshRuntimeIdentity::default(),
)
.unwrap();
let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z");
assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1);
assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1);
assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID);
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,27 +0,0 @@
use std::{fs, path::Path, process::Command};
#[test]
fn embedded_commit_matches_checkout_head() {
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&repo_root)
.output()
.expect("git must be available for the provenance regression test");
let checkout_head = if output.status.success() {
String::from_utf8(output.stdout)
.expect("git commit must be UTF-8")
.trim()
.to_owned()
} else {
fs::read_to_string(repo_root.join(".ghostlight-source-commit"))
.expect("frozen source archive must carry its exact commit witness")
.trim()
.to_owned()
};
assert_eq!(
env!("GHOSTLIGHT_BUILD_COMMIT"),
checkout_head,
"the binary embedded stale Git provenance"
);
}

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

@ -524,3 +524,66 @@ fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
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

@ -469,7 +469,7 @@ mod tests {
AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind,
ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle,
EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef,
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref,
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex,
RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability,
WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph,
commit_digest, derive_boundaries, derive_cover, derive_opportunities,
@ -736,8 +736,7 @@ mod tests {
assert!(!graph.subjects.contains(&mirror.first));
let cover = derive_cover(
snapshot.world_id,
snapshot.now,
30,
TickIndex(0),
&opportunities,
&graph,
CoverBudget {

File diff suppressed because it is too large Load diff

View file

@ -11,7 +11,6 @@
//! The cover is a partition, never a filter: every active subject is in exactly
//! one cell every tick. The budget decides at what resolution, not whether.
use super::clock::FictionalMinutes;
use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16;
/// the name, not by a second namespace.
const COVER_NAMESPACE: &str = "ghostlight.cover.v1";
/// Monotone tick index. Derived from the world clock, never stored. It is
/// threaded into every derived command id, so a resumed tick over the same
/// snapshot re-derives the same ids and the kernel's idempotency ledger answers
/// with the original receipts instead of double-committing.
/// A tick's ordinal: the number of clock advances the world had committed when
/// the tick opened. The driver reads it from the world and freezes it in the
/// tick document; it is never derived from `now`, so it does not depend on the
/// span. It is threaded into every derived command id, so a resumed tick
/// re-submits the same ids and the kernel's idempotency ledger answers with the
/// original receipts instead of double-committing.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
)]
#[serde(transparent)]
pub struct TickIndex(pub u64);
impl TickIndex {
/// Floor division by the span one `AdvanceTime` carries. The driver submits
/// the clock *after* the cells run, so every cell in one tick sees the same
/// `now` and therefore the same index.
pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self {
Self(now.0 / u64::from(tick_minutes.max(1)))
}
}
/// How a subject was represented this tick. Derived from the cell, carried
/// nowhere in world state: representation is a compute budget, not world truth.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@ -254,7 +246,7 @@ impl AgencyGraph {
}
}
/// The partition. Pure over `(world, now, opportunities, graph, budget)`.
/// The partition. Pure over `(world, tick, opportunities, graph, budget)`.
///
/// `opportunities` arrives in the kernel's one attention order (pressure, then
/// attention debt, then id) and is never re-sorted and never filtered here: a
@ -262,13 +254,11 @@ impl AgencyGraph {
/// is a fact about cognition rather than a scheduling preference.
pub fn derive_cover(
world: WorldId,
now: FictionalMinutes,
tick_minutes: u32,
tick: TickIndex,
opportunities: &[DecisionOpportunity],
graph: &AgencyGraph,
budget: CoverBudget,
) -> Cover {
let tick = TickIndex::of(now, tick_minutes);
let active: Vec<&DecisionOpportunity> = opportunities
.iter()
.filter(|opportunity| opportunity.controller_mode != ControllerMode::Human)
@ -601,8 +591,7 @@ mod tests {
) -> Cover {
derive_cover(
WorldId::nil_for_test(),
FictionalMinutes(tick * 60),
60,
TickIndex(tick),
opportunities,
graph,
budget,

View file

@ -8,7 +8,7 @@
//! digest its verbs do not have.
use crate::SubjectKind;
use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry};
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
@ -63,10 +63,20 @@ 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.roles.len() == roles.len()
&& self.roles.iter().zip(roles).all(|(want, have)| {
want.role == have.role.0 && want.referent.ref_kind() == have.kind
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()
}
}
@ -165,6 +175,11 @@ impl GrammarBinding {
}
}
/// 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)
}

View file

@ -181,6 +181,11 @@ impl WorldJournal {
self.commits.get(&command_id)
}
/// Every commit the journal holds, in no particular order.
pub(super) fn commits(&self) -> impl Iterator<Item = &WorldCommit> {
self.commits.values()
}
#[cfg(test)]
pub(super) fn commit_count(&self) -> usize {
self.commits.len()

View file

@ -85,6 +85,7 @@ pub use consumer::{
};
pub use controllers::{
CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels,
ControllerOpenError,
ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork,
ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError,
ControllerWorkWrite,
@ -144,8 +145,11 @@ pub use table::{
};
pub use vault::{VaultError, VaultEvidenceSource};
pub use verse::{
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort,
ReplayInferencePort, RunField, RunId, RunStart,
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord,
RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim,
ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint,
TickReport, UnattributedCall, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
call_run_id, run_manual,
};
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch};
@ -1275,6 +1279,21 @@ pub struct OperatorEvent {
pub speaker_label: String,
pub speech: Option<Statement>,
pub display: Option<Statement>,
/// The affordance kind the act exercised.
pub verb: String,
/// How the world's grammar renders that verb; none in a world with no
/// grammar.
pub render_path: Option<RenderPath>,
/// What the act bound each of the verb's roles to, in the invocation's
/// order.
pub roles: Vec<BoundRole>,
}
/// One role an act bound, with the label of what it was bound to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoundRole {
pub role: String,
pub target: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -1565,7 +1584,43 @@ impl WorldKernel {
/// no consumer may read in full would be a loaded gun with a comment on it.
fn operator_log(&self) -> Result<Vec<OperatorEvent>, KernelError> {
self.journal.ensure_healthy()?;
operator_log(&self.state)
// An event records what the world did; the invocation, which names the
// bound roles, lives on the commit that produced it.
let invocations: BTreeMap<EventId, &DecisionInvocation> = self
.journal
.commits()
.filter_map(|commit| match &commit.effect {
WorldEffect::DecisionExercised {
invocation, event, ..
} => Some((event.id, invocation)),
_ => None,
})
.collect();
operator_log(&self.state, &invocations)
}
/// The tick driver's opportunity list, in the same order a snapshot
/// carries it. Separate from `snapshot` for the reason `agency_graph` is:
/// only the tick driver reads it.
fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, KernelError> {
self.journal.ensure_healthy()?;
Ok(order_opportunities(
&self.state,
derive_opportunities(&self.state)?,
))
}
/// How many clock advances the world has committed: the next tick's
/// ordinal, held by the world and read from its journal. Separate from
/// `snapshot` for the reason `opportunities` is: only the tick driver
/// reads it.
fn clock_advances(&self) -> Result<u64, KernelError> {
self.journal.ensure_healthy()?;
Ok(self
.journal
.commits()
.filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. }))
.count() as u64)
}
/// The scheduler's adjacency projection. Separate from `snapshot` for the
@ -3740,7 +3795,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent
/// The human operator's story feed, derived from the causal record. The events
/// log is not a perception surface: `knowledge` is the sole answer to what a
/// subject may act on and what its projection may contain.
fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
fn operator_log(
state: &WorldState,
invocations: &BTreeMap<EventId, &DecisionInvocation>,
) -> Result<Vec<OperatorEvent>, KernelError> {
state
.events
.iter()
@ -3749,6 +3807,30 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
.subjects
.get(&event.scope.subject_id)
.ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?;
let invocation = invocations.get(&event.id).ok_or_else(|| {
KernelError::Invariant("event has no committed invocation".into())
})?;
let entry = state
.affordance_catalog
.get(&event.affordance)
.ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?;
let roles = invocation
.bindings
.iter()
.map(|binding| {
let label = match binding.target {
Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()),
Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()),
Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()),
};
label
.map(|target| BoundRole {
role: binding.role.0.clone(),
target,
})
.ok_or_else(|| KernelError::Invariant("event binds no live referent".into()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(OperatorEvent {
revision: event.revision,
speaker: event.scope.subject_id,
@ -3761,6 +3843,13 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
.display
.and_then(|fact| state.facts.get(&fact))
.map(|record| record.statement.clone()),
verb: entry.kind.0.clone(),
render_path: state
.grammar
.as_ref()
.and_then(|grammar| grammar.verb(&entry.kind.0))
.map(|verb| verb.render_path),
roles,
})
})
.collect()
@ -11533,7 +11622,7 @@ mod soul_knowledge_tests {
// Nobody holds it.
assert!(kernel.state.knowledge.is_empty());
// The operator reads it anyway, with the speaker resolved by label.
let log = operator_log(&kernel.state).unwrap();
let log = kernel.operator_log().unwrap();
assert_eq!(
log,
vec![OperatorEvent {
@ -11542,6 +11631,9 @@ mod soul_knowledge_tests {
speaker_label: "The Yard Bystander".into(),
speech: Some(Statement::new(text).unwrap()),
display: None,
verb: "speak".into(),
render_path: None,
roles: Vec::new(),
}]
);
// And no subject snapshot carries it.

View file

@ -3,9 +3,10 @@ use super::{
ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation,
DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey,
KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId,
Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal,
patch::kernel_speak_grant, prepare_creation,
verse::{CreateVerseWorldIntent, VerseGenesisError},
};
use crate::VerifiedPrincipalEvidence;
use chrono::Utc;
@ -18,6 +19,21 @@ use tokio::task::JoinHandle;
/// other. A world that wants more declares them through the patch lane.
const GENESIS_PLACE: &str = "commons";
/// The commons every genesis declares, the one room its first subjects share.
pub(crate) fn commons_declaration() -> Declaration {
Declaration::Entity(EntityDeclaration {
handle: DraftHandle::new(GENESIS_PLACE),
label: "The Commons".into(),
kind: EntityKind::Place,
container: None,
})
}
/// Where a genesis subject stands.
pub(crate) fn commons_ref() -> Ref<EntityId> {
Ref::Draft(DraftHandle::new(GENESIS_PLACE))
}
const REQUEST_CAPACITY: usize = 32;
#[derive(Clone, Debug)]
@ -66,6 +82,16 @@ enum Request {
AgencyGraph {
reply: oneshot::Sender<Result<AgencyGraph, KernelError>>,
},
/// The tick driver's opportunity list. Separate from `Snapshot` so the
/// list a cover is derived from has one door, held by the driver.
Opportunities {
reply: oneshot::Sender<Result<Vec<DecisionOpportunity>, KernelError>>,
},
/// The tick ordinal's one door: how many clock advances the world has
/// committed. Held by the tick driver, as `Opportunities` is.
ClockAdvances {
reply: oneshot::Sender<Result<u64, KernelError>>,
},
ControllerReceipt {
command_id: CommandId,
opportunity: super::DecisionOpportunity,
@ -120,7 +146,7 @@ impl WorldMailbox {
kind,
controller,
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
position: Some(commons_ref()),
import_key: None,
})
};
@ -130,12 +156,7 @@ impl WorldMailbox {
// jurisdiction's population target. The owner subject lands in
// `Uncovered`, visible and reducing nothing.
let mut declarations = vec![
Declaration::Entity(EntityDeclaration {
handle: DraftHandle::new(GENESIS_PLACE),
label: "The Commons".into(),
kind: EntityKind::Place,
container: None,
}),
commons_declaration(),
declare(
"first-person",
input.human_subject_label,
@ -219,6 +240,20 @@ impl WorldMailbox {
.map_err(mailbox_outcome)
}
/// Creates the canonical Aetheria verse world through the same
/// authenticated creation `create` uses.
pub async fn create_verse(
&self,
intent: CreateVerseWorldIntent,
principal: &VerifiedPrincipalEvidence,
) -> Result<CreationReceipt, VerseGenesisError> {
let principal_id = PrincipalId::new(principal.account_subject_hash());
let input = intent.lower(principal_id.clone())?;
Ok(self
.create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id))
.await?)
}
/// A wall-clock read at the boundary a request crosses into the mailbox is
/// ingress, not reduce: the reducer stays deterministic over its journal,
/// and this is the one place a caller's own claim of continued authority
@ -461,6 +496,37 @@ impl WorldMailbox {
.map_err(MailboxError::Kernel)
}
/// The tick driver's opportunity list, in snapshot order. It is on
/// `WorldMailbox` and on no controller port, as `agency_graph` is.
pub async fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, MailboxError> {
let (reply, response) = oneshot::channel();
self.sender
.send(Request::Opportunities { reply })
.await
.map_err(|_| MailboxError::Unavailable)?;
response
.await
.map_err(|_| MailboxError::Unavailable)?
.map_err(MailboxError::Kernel)
}
/// The tick driver's ordinal: how many clock advances the world has
/// committed. It is on `WorldMailbox` and on no controller port, as
/// `opportunities` is.
pub async fn clock_advances(&self) -> Result<u64, MailboxError> {
let (reply, response) = oneshot::channel();
self.sender
.send(Request::ClockAdvances { reply })
.await
.map_err(|_| MailboxError::Unavailable)?;
response
.await
.map_err(|_| MailboxError::Unavailable)?
.map_err(MailboxError::Kernel)
}
pub async fn operator_log(&self) -> Result<Vec<OperatorEvent>, MailboxError> {
let (reply, response) = oneshot::channel();
self.sender
@ -930,6 +996,32 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver<Request>)
return;
}
}
Request::Opportunities { reply } => {
let result = match &owned {
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
OwnedWorld::Live(kernel) => kernel.opportunities(),
};
let owner_must_stop = result
.as_ref()
.is_err_and(KernelError::requires_owner_restart);
let _ = reply.send(result);
if owner_must_stop {
return;
}
}
Request::ClockAdvances { reply } => {
let result = match &owned {
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
OwnedWorld::Live(kernel) => kernel.clock_advances(),
};
let owner_must_stop = result
.as_ref()
.is_err_and(KernelError::requires_owner_restart);
let _ = reply.send(result);
if owner_must_stop {
return;
}
}
Request::ControllerReceipt {
command_id,
opportunity,

View file

@ -7916,7 +7916,8 @@ mod tests {
};
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
/// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
/// `parked/ghostlight-eve-surface`), which refuses a
/// `set_persona_material` run whose patch answers true here, since
/// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse
/// its JSON to decide the question itself.

View file

@ -1606,7 +1606,8 @@ mod tests {
// PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it
// from its own list too. This is now the complete 24-tool list.
// PA.f88: this is the library's own test fixture, kept in sync by hand.
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at
// `parked/ghostlight-eve-surface`) is the
// single policy owner of what the play agent may actually call; this
// copy pins nothing there and is not read by production code.
const PLAY_TOOLS: &[&str] = &[

View file

@ -256,7 +256,7 @@ enum DecodedRow {
Ledger(RunLedger),
}
fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
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)?;
@ -585,6 +585,16 @@ impl RecordingInferencePort {
})
}
/// 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)

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

@ -1,10 +1,19 @@
//! The Aetheria verse run's own organs. The verse binary and its run record
//! live 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 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

@ -1,188 +0,0 @@
schema = "gamecult.idunn.target_declaration.v1"
target = "ghostlight"
source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT"
required_gitlinks = ["vendor/eve"]
[[steps]]
id = "test-persona-boundary"
phase = "test"
runner = "rust-build"
required_environment = ["CARGO_HOME", "HOME"]
argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"]
[[steps]]
id = "test-world-library"
phase = "test"
runner = "rust-build"
required_environment = ["CARGO_HOME", "HOME"]
argv = ["cargo", "test", "--locked", "-p", "ghostlight"]
[[steps]]
id = "test-dungeon"
phase = "test"
runner = "rust-build"
required_environment = ["CARGO_HOME", "HOME"]
argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
[[steps]]
id = "build-daemon"
phase = "build"
runner = "rust-build"
required_environment = ["CARGO_HOME", "HOME"]
argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
[[steps]]
id = "install-web-dependencies"
phase = "prepare"
runner = "web-build"
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
argv = ["npm", "--prefix", "web", "ci"]
[[steps]]
id = "test-web"
phase = "test"
runner = "web-build"
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
argv = ["npm", "--prefix", "web", "test"]
[[steps]]
id = "build-web"
phase = "build"
runner = "web-build"
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
argv = ["npm", "--prefix", "web", "run", "build"]
[[steps]]
id = "accept-real-persona-membrane"
phase = "acceptance"
runner = "rust-acceptance"
argv = [
"cargo",
"test",
"--locked",
"-p",
"ghostlight",
"--lib",
"controllers::tests::real_local_model_cognition_modes_commit_speech",
"--",
"--ignored",
"--exact",
]
required_environment = [
"CARGO_HOME",
"GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG",
"GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID",
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
"HOME",
"GHOSTLIGHT_LOCAL_ENDPOINT",
]
[[artifacts]]
id = "ghostlight-dungeon"
source_kind = "runner-output"
runner = "rust-build"
source = "target/release/ghostlight-dungeon"
destination = "ghostlight-dungeon"
executable = true
[[artifacts]]
id = "web"
source_kind = "runner-output"
runner = "web-build"
source = "web/dist"
destination = "web"
[service]
executable_artifact = "ghostlight-dungeon"
required_adjacent_artifacts = ["web"]
arguments = [
{ kind = "literal", value = "--state-root" },
{ kind = "binding", name = "state_root" },
]
transport = "http"
route_required = true
required_environment = [
"GAMECULT_IDUNN_CANDIDATE_BIND",
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
"GHOSTLIGHT_DISCORD_GUILD_ID",
"GHOSTLIGHT_DISCORD_ROLE_ID",
"GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE",
"GHOSTLIGHT_HEIMDALL_BASE_URL",
"GHOSTLIGHT_LOCAL_ENDPOINT",
"GHOSTLIGHT_LOCATED_SERVICE",
"GHOSTLIGHT_ODIN_RUDP",
"GHOSTLIGHT_PLAY_MODEL",
"GHOSTLIGHT_PUBLIC_APP_URL",
"GHOSTLIGHT_SERVICE_ID",
"GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE",
]
optional_environment = ["RUST_LOG"]
[service.health]
contract = "ghostlight.cultnet-rudp-service-health"
[state]
schema_generation = "world-v3"
[[state.slots]]
id = "world"
relative_path = "world.cc"
kind = "cultcache-file"
schema = "ghostlight.world_state.consumer.v6"
writer = "process-bound-single-writer"
recovery = "preserve"
startup = "create-or-open-after-write-lease"
[[state.slots]]
id = "app-sessions"
relative_path = "service/app-sessions-v2.cc"
kind = "cultcache-file"
schema = "ghostlight.app_session_store.v2"
writer = "process-bound-single-writer"
recovery = "preserve"
startup = "create-or-open-after-write-lease"
[[state.slots]]
id = "controller-work"
relative_path = "service/controller-work.cc"
kind = "cultcache-file"
schema = "ghostlight.controller_work.v18"
writer = "process-bound-single-writer"
recovery = "preserve"
startup = "create-or-open-after-write-lease"
[[state.slots]]
id = "mesh-projection"
relative_path = "service/mesh-v2.cc"
kind = "cultcache-file"
schema = "gamecult.eve.surface.v1"
writer = "process-bound-single-writer"
recovery = "rebuildable"
startup = "create-or-open-after-write-lease"
[[provides]]
capability = "ghostlight.world-service"
schema = "ghostlight.world_state.consumer.v6"
compatibility = "consumer-v6"
[[provides]]
capability = "gamecult.eve.surface-provider"
schema = "gamecult.eve.surface.v1"
compatibility = "v1"
[[dependencies]]
kind = "shared-infrastructure"
capability = "odin.verse-rendezvous"
schema = "odin.verse-topology.v1"
compatibility = "v1"
startup = "before-promotion"

View file

@ -1,5 +1,7 @@
# 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
@ -624,9 +626,11 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's
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.create`'s current payload is `world_create.v4`: title, brief, targets,
jurisdiction roots and lens weights are required, and Dungeon's create surface
offers uniform weights as its editable default. `world.seed` runs one
`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:

View file

@ -1,100 +0,0 @@
# Ghostlight Eve-native interface
## Objective
Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the
provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`).
Eve owns editable bindings, command invocation, receipts, plugin composition,
and browser lowering. Ghostlight owns world creation, seeding, activation, play,
and world admission; Heimdall owns authentication and entitlement decisions.
```text
Ghostlight projection (eve.rs)
-> EveBrowserProviderHost
-> canonical Eve lowering
-> gamecult.eve.command_invocation.v1
-> Ghostlight command ingress (runtime.rs) or Heimdall private command plane
-> gamecult.eve.command_result.v1
-> authoritative surface refresh
```
## Authority map
- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort`
and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's
app-session owner (`app_session.rs`) binds a verified Heimdall subject to a
local cookie.
- Inputs: the projector reads only the caller's app session, the world
snapshot, and the caller's play-turn view. Command ingress reads one canonical
Eve invocation and derives the principal server-side.
- Outputs: one `gamecult.eve.surface.v1` document and one
`gamecult.eve.command_receipt.v1` inside a command result.
- Derived state: browser drafts, focus, command status, and rendered HTML are
projections or local interaction state. None grants access or mutates
fiction.
- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the
SSE stream cannot choose a principal or commit world state.
- Shared path: every operation resolves the authenticated account, then calls
the world mailbox, the seed port, or the play port.
SSE carries invalidation only and the host refetches the same surface.
## The surface
`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller
and world phase (`anonymous_surface`, `authenticated_surface` in
`crates/ghostlight-dungeon/src/eve.rs`):
| Caller and world | Content | Operations |
|---|---|---|
| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` |
| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) |
| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) |
| Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) |
| Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) |
`world.play` is the only human play action. The open question's identity rides
the play button's own action as an opaque token, never as an editable field.
`operation_schema` in `eve.rs` is the single table from operation id to schema.
## Editable values
Eve bindings are the input model. A component binds a typed value by stable
binding name, document identity, field path, value kind, and access mode.
Renderer-local composers use `local-draft`. An operation captures one or more
named binding values atomically.
The browser lowerer may use an HTML form for keyboard and accessibility
behavior. HTML form structure never enters Eve state or command payload
semantics. Accepted receipts may clear named drafts; rejection and stale
conflict preserve them. An omitted clear-binding list means clear the surface's
drafts; an explicit empty list means clear nothing. Ghostlight validates every
payload at ingress regardless of what the control advertises.
## Authentication membrane
Anonymous projection contains only `heimdall.access_gate`. Its begin and
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary
(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary`
record from Odin, validates it, and invokes the discovered route. Odin does not
proxy the command and never receives claims or completion payloads. The browser
retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`).
Ghostlight redeems the completion, validates the access claim, and creates a
local HttpOnly session cookie (`ghostlight_session`).
Routine requests use local verified session state, persisted in
`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall
session and `access_revision`, expiries, and a wrapped refresh claim.
`access_revision` is Heimdall's revocation epoch.
## Public boundary
- `GET /api/eve/provider`
- `GET /api/eve/surfaces/{surface_id}`
- `POST /api/eve/commands`
- `GET /api/eve/events`
- `GET /health`, a probe of the service's typed CultMesh health
- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback
CultNet doors (see `ghostlight-world-consumer-api.md` for the second)
Static assets in `web/` contain only the Eve provider host, the transport, the
Heimdall adapter, and the SSE invalidation hook.

View file

@ -1,5 +1,7 @@
# 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

View file

@ -1,5 +1,7 @@
# 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

View file

@ -63,6 +63,11 @@ 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

View file

@ -2,25 +2,21 @@
This is a pointer. It does not carry open work; the Eureka mind does.
## What runs
## What exists
Ghostlight Dungeon runs on Yggdrasil under Idunn at
`https://yggdrasil.gamecult.org/ghostlight/` (runtime `ghostlight-yggdrasil`,
target `ghostlight`, ref `main`, state root `/var/lib/gamecult/ghostlight-world-v2`).
`/health` names the commit it serves (`crates/ghostlight-dungeon/src/runtime.rs`).
The binary is `ghostlight-dungeon`; its inference reaches Bonsai on Raven
through a loopback tunnel, and sign-in goes through Heimdall, discovered via
Odin.
Nothing is deployed. The Dungeon (daemon, play table, Eve surface, browser host,
`vendor/eve`, Idunn recipe) is parked at the tag `parked/ghostlight-eve-surface`
and is not on main. A Thing 2 rebuild starts from that tag.
## How it deploys
What is in the tree is the kernel (`crates/ghostlight`), the persona projection
(`crates/ghostlight-persona-projection`) and the verse runner
(`crates/ghostlight-verse`). `cargo test` runs them on Linux; use the
`ygg-verify` stopgap on Yggdrasil, not the workstation, and say which host
you used.
Idunn is the only deploy path (`idunn up ghostlight`). The recipe is
`deployment/idunn/recipe.toml`; the runbook is
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. The Ghostlight daemon
cannot start without Odin: its Warming presence and process write lease go
through Odin. Preflight Odin's route health before a deploy. Do not invoke the
retired v1 actuator (`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`), its
wiring test, or `ghostlight-dungeon.service`.
The Idunn target `ghostlight` is braked and stopped, not retired. Its state is in
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. Do not run
`idunn up ghostlight`, the v1 actuator, or `ghostlight-dungeon.service`.
## Where open work is
@ -35,9 +31,10 @@ target and probed body facts are `notes/ghostlight-verse-target.md` and
1. `README.md`: what exists and what does not.
2. `AGENTS.md`: invariants and where things are.
3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture.
4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
3. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
current mechanism.
4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority
architecture. History until Thing 2 revives it.
5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play
agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216).
It is history; the mind says which findings are still open.

View file

@ -1,6 +1,8 @@
# Ghostlight Verse: Map
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2`
> Status: Dungeon, eve.rs and the bridge-test references below are parked at tag `parked/ghostlight-eve-surface`; line refs are to that tag.
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3`
(`notes/ghostlight-verse-target.md`). This page holds body facts, the model
page and rationale only. Cut specs, questions, rulings and follow-ups live in
the Eureka mind; query them there, never copy them here.
@ -9,6 +11,15 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09.
Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`.
Times are UTC unless marked local (UTC+2).
## Cut sequence
Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it.
- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key).
- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key).
- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain).
- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run).
## Body facts (probed 2026-10-09)
### Does main run on Yggdrasil?
@ -57,7 +68,7 @@ Times are UTC unless marked local (UTC+2).
### Does main build and pass?
Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised:
Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised:
- Rust (image `eureka-verify-rust`), the recipe's own steps:
`ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed,
@ -202,6 +213,67 @@ from 21:04 the denial forever. Mechanism:
code is flat under `crates/ghostlight/src/`. Dated cut documents and
postmortems keep their paths: they are history.
### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`)
- Three paths answer the same fact (the subject still holds one opportunity on
the bound scope, at a new digest), with two errors. A probe test run on
Yggdrasil returned these, with one provider call in total:
- The scope moves during round 0 of a fresh run. Round 1's
`ensure_scope_unchanged` returns `NoOpportunity`.
- A resume passing the row's own stale opportunity returns `NoOpportunity`
(same gate).
- A resume passing the fresh opportunity returns `OpportunityMismatch`, from
the digest clause of `row_answers_caller` at lookup.
- The driver's `classify` (`verse/run.rs` 641-651) labels the first two
`OpportunityGone` and the third `ScopeMoved`. That makes the first two
mislabels: the label's doc says `OpportunityGone` means the subject holds no
opportunity. Cause: when `select_scope`'s digest clause empties the match
list, it answers `NoOpportunity` (`controllers.rs` ~1552).
- In the narrative lane, the lookup digest clause is the only gate. A driver
resume with the fresh opportunity is refused there. Fork A's kernel-refusal
re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity.
- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now
returns `ScopeMoved`): `run_group` maps every `select_one` error to
`LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member
whose scope moved is dropped before any constituent, inference or submission
exists, so only the label is wrong; spend is identical and nothing branches on
the reason (`grouped-record-and-carry` keys on membership in `refused`). Own
cut: `cut-grouped-scope-moved-label.r1`.
### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`)
- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed
and CustodyUncertain arms (`controllers.rs` 3144, 3152) call
`row_answers_caller`, the only callers it has since `in-flight-gate`
(`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller
passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads
the world (source read: `self.select` is called only at fresh run, round
checks of the operational lane, and the other lanes' fresh paths).
- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged`
arm calls `interrupted`, which re-lowers once. A second move reads
`interrupted_from.is_some()` and ends the turn `overtaken`, with nothing
submitted and no further inference. The progression arm (~2375) refuses to
persist a second re-lowering even if the runner tried. Pinned by
`a_second_scope_change_after_the_re_lowering_spends_nothing`.
- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job;
script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup
arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved`
to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803
passed, 3 failed. The failures were
`a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference`
(the resumed row now called the provider and ran the test port dry),
`a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and
`an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No
verse driver test reached `ScopeMoved` through `OpportunityMismatch`.
- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut
it arises from `same_decision` (another decision), `select_scope`'s
controller-id check, and the Persona lane's forged-field refusals. The driver
cannot reach a `same_decision` mismatch, because `run_cell` derives the
command id from world, cell, subject and tick.
- **Dead code.** Both `awaits_world` methods have no production caller once
`row_answers_caller` goes. The operational one has been test-only since
`771a79d`. The only other reader is the `NeverSentStore` test fixture.
## Model page
One row per persistent kind phase 1 touches. No cell may be empty.
@ -216,6 +288,7 @@ One row per persistent kind phase 1 touches. No cell may be empty.
| v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling |
| Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question |
| Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems |
| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone |
### Where phase 2's verb sets plug in
@ -264,3 +337,16 @@ verbs as the target.
Dungeon tests that drive the real client, and nothing runs them. Fixing the
gate means a runner with both Rust and Node, which is Idunn's recipe and
runner surface: a follow-up, with each phase-1 Hands run doing them by hand.
- **A narrative resume has one detector, and it is the kernel** (ruling
`narrative-resume-relowers`). The caller's opportunity names the decision
and nothing more, in both lanes, through the one `same_decision`. The lanes
differ only in when the world is asked. The operational lane asks before each
round, because it has nothing to preserve. The narrative lane asks at submit,
because its prose and receipts survive to be lowered again. The price is the
remaining rounds plus at most one Interpreter call. `OpportunityMismatch`
then stops meaning a moved scope, so `classify` labels it `OpportunityGone`,
as the grouped lane already does. Keeping it on `ScopeMoved` would leave a
second, wrong answer to "did the scope move". The grouped selection label
becomes a pure function, because `AmbiguousOpportunity` cannot be built
through a live world, which offers one opportunity per scope. Pinning the
function is what closes follow-up `grouped-left-arms-pin`.

View file

@ -5,8 +5,14 @@ No live smoke exists in this tree. The ignored test this file once documented,
ran the production tick driver and the Active elaboration sweep against a real
CodexConnector; the play agent's Cut 1 (`d69e9d4`) deleted both, and the test
with them. A command that names it now matches nothing and exits 0. No
replacement exists. The real-model gate that does exist is the Idunn recipe's
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`).
replacement exists. The recipe that once gated a deploy on a real model is
gone from main, and so is its `accept-real-persona-membrane` step. The one real-model test
left is the ignored `controllers::tests::real_local_model_cognition_modes_commit_speech`
(`crates/ghostlight/src/controllers.rs`). It has no automated run: it needs a model server
on `GHOSTLIGHT_LOCAL_ENDPOINT` plus the `GHOSTLIGHT_CONTROLLER_*_MODEL`,
`GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID` and `GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG`
variables, and a person runs it by hand with `cargo test -p ghostlight -- --ignored
real_local_model_cognition_modes_commit_speech`.
What remains true and stays here: bringing up a real CodexConnector and a
built Claude SDK sidecar. The seed lane (`world.seed`) still runs real

View file

@ -2,11 +2,8 @@
"name": "ghostlight",
"private": true,
"version": "0.0.0",
"description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.",
"description": "Ghostlight: the Rust kernel for socially situated agents.",
"scripts": {
"dungeon:build": "cargo build --workspace && npm --prefix web run build",
"dungeon:test": "cargo test --workspace && npm --prefix web run build",
"dungeon:run": "cargo run -p ghostlight-dungeon",
"sdk:build": "npm --prefix sidecar/claude-sdk run build",
"sdk:test": "npm --prefix sidecar/claude-sdk test"
},

View file

@ -1,117 +0,0 @@
// Cut 10 (PA.f149): drives the real vendored Eve browser lowering
// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a
// surface document Dungeon actually served, so the command intent this
// prints is what a real client sends -- never a hand-built payload asserting
// its own shape is correct.
//
// stdin: {"surface": <the surface document>, "steps": [{"set": {node_id: text}}, {"click": node_id}]}
// stdout: one JSON line per intent the click(s) produced, in order.
//
// `set` types into a control by its Eve node id (the same id
// `eve.rs`'s own `json!({"id": ...})` calls carry), by finding the rendered
// element via `data-eve-node-id` (assignId) and dispatching a real `input`
// event, exactly as a user's keystroke would. `click` clicks a
// `control.button` node the same way. jsdom is resolved from the vendored
// package's own devDependencies (its own test suite uses the identical
// pattern in `test/host-isolation.test.mjs`) rather than adding a second copy
// to this repo.
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { createRequire } from "node:module";
const here = dirname(fileURLToPath(import.meta.url));
const loweringRoot = join(here, "..", "vendor", "eve", "packages", "eve-browser-lowering");
const vendorRequire = createRequire(join(loweringRoot, "package.json"));
const { JSDOM } = vendorRequire("jsdom");
const { EveBrowserDraftStore, renderEveSurface } = await import(
"file://" + join(loweringRoot, "dist", "index.js").replace(/\\/g, "/")
);
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id='host'></div></body></html>", {
url: "https://eve.bridge.test/",
pretendToBeVisual: true,
});
const names = [
"document", "window", "Element", "HTMLElement", "HTMLButtonElement",
"HTMLInputElement", "HTMLSelectElement", "HTMLOptionElement",
"HTMLTextAreaElement", "HTMLImageElement", "Event", "CustomEvent",
];
for (const name of names) globalThis[name] = dom.window[name];
return dom;
}
function cssEscape(value) {
return value.replace(/[^a-zA-Z0-9_-]/g, char => `\\${char}`);
}
async function readStdin() {
return new Promise((resolve, reject) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", chunk => { data += chunk; });
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", reject);
});
}
async function main() {
const { surface, steps, provider } = JSON.parse(await readStdin());
const dom = installDom();
const host = dom.window.document.getElementById("host");
const intents = [];
const draftStore = new EveBrowserDraftStore();
const options = {
draftStore,
clientId: "eve.bridge.test",
commandSink: intent => { intents.push(intent); },
// `commandBoundary`/`receiptSchema` are not carried on the surface
// document itself -- a real client learns them from the provider's own
// CultMesh advertisement (`mesh.rs::provider_advertisement`,
// `surfaces[].worldInteraction`), fetched separately. The caller passes
// that same shape through here.
provider,
};
renderEveSurface(surface, host, options);
for (const step of steps) {
if (step.set) {
for (const [nodeId, value] of Object.entries(step.set)) {
const wrapper = host.querySelector(`[data-eve-node-id="${cssEscape(nodeId)}"]`);
if (!wrapper) throw new Error(`bridge: no rendered control for node id ${nodeId}`);
// `assignId` stamps the Eve node id onto the field's own wrapper
// (`renderEditableControl`'s `<label>`), not the `<input>`/
// `<textarea>` inside it, so the real editable element is a
// descendant when the wrapper itself is not one.
const element = /^(INPUT|TEXTAREA|SELECT)$/.test(wrapper.tagName)
? wrapper
: wrapper.querySelector("input, textarea, select");
if (!element) throw new Error(`bridge: node id ${nodeId} has no editable control`);
// PA.f174: a real browser refuses keystrokes into a disabled
// control outright -- typing into it here without this check would
// silently capture a value no real client could ever have entered,
// hiding exactly the gap PA.f167 named untestable.
if (element.disabled) {
throw new Error(`bridge: node id ${nodeId}'s own control is disabled; a real client cannot type into it`);
}
element.value = value;
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
element.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
}
}
if (step.click) {
const element = host.querySelector(`[data-eve-node-id="${cssEscape(step.click)}"]`);
if (!element) throw new Error(`bridge: no rendered control for node id ${step.click}`);
element.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
}
}
dom.window.close();
process.stdout.write(intents.map(intent => JSON.stringify(intent)).join("\n") + "\n");
}
main().catch(error => {
process.stderr.write(`${error.stack || error}\n`);
process.exit(1);
});

1
vendor/eve vendored

@ -1 +0,0 @@
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0

View file

@ -1,18 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ghostlight Dungeon</title>
</head>
<body>
<a class="skip-link" href="#surface">Skip to Ghostlight</a>
<header>
<p class="eyebrow">GHOSTLIGHT / LIVING WORLDS</p>
<h1>Ghostlight Dungeon</h1>
<p id="status" role="status" aria-live="polite">Reading the table…</p>
</header>
<main id="surface" tabindex="-1"></main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1269
web/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,17 +0,0 @@
{
"name": "@gamecult/ghostlight-dungeon-web",
"private": true,
"type": "module",
"scripts": {
"build": "npm run typecheck && vite build",
"dev": "vite --host 127.0.0.1 --port 8832",
"test": "node test/static-boundary.mjs && node --experimental-strip-types --test test/heimdall-adapter.test.mjs",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
"ajv": "^8.17.1"
},
"devDependencies": { "typescript": "^5.6.3", "vite": "^7.1.2" }
}

View file

@ -1,11 +0,0 @@
# Heimdall access browser adapter provenance
- Owner: `GameCult/Heimdall`
- Plugin: `gamecult.heimdall.access`
- Source commit: `e8e2832`
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
- Vendoring mode: exact semantic copy with formatting only
Ghostlight embeds this adapter so immutable releases do not depend on a sibling
checkout at browser-build time. Authentication behavior remains Heimdall-owned;
changes are imported from the owner and this provenance is advanced with them.

View file

@ -1,139 +0,0 @@
// Vendored from GameCult/Heimdall commit e8e2832, plugin
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
// adapter because its release must remain self-contained.
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
export interface HeimdallAccessResumeTarget {
complete(handle: string): Promise<void>;
}
export interface HeimdallAccessResumeOptions {
appSlug: string;
}
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
return {
pluginId: "gamecult.heimdall.access",
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
schemas: ["heimdall.access_gate_state.v1"],
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
return value;
},
renderComponent(
component: { id?: string },
props: Record<string, unknown>,
): HTMLElement {
const view = document.createElement("section");
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
if (component.id) view.id = component.id;
view.setAttribute("role", "status");
view.setAttribute("aria-live", "polite");
const title = document.createElement("h2");
title.textContent = String(props.title || "Access");
const detail = document.createElement("p");
detail.textContent = String(props.detail || "");
view.append(title, detail);
const displayName = String(props.displayName || "");
if (displayName) {
const identity = document.createElement("p");
identity.className = "heimdall-identity";
identity.textContent = displayName;
view.append(identity);
}
return view;
},
async consumeCommandResult(
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
): Promise<void> {
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
const handle = String(pluginPayload.payload.handle || "");
const navigation = object(pluginPayload.payload.navigation);
const url = new URL(String(navigation.url || ""));
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
: [];
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
}
sessionStorage.setItem(ATTEMPT_KEY, handle);
window.location.assign(url.toString());
return;
}
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
const status = String(pluginPayload.payload.status || "");
if (status === "authenticated" || status === "denied" || status === "expired") {
sessionStorage.removeItem(ATTEMPT_KEY);
}
}
},
};
}
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
});
export async function resumeHeimdallAccess(
target: HeimdallAccessResumeTarget,
options: HeimdallAccessResumeOptions,
): Promise<boolean> {
const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug);
const storedHandle = sessionStorage.getItem(ATTEMPT_KEY);
if (returned) {
clearHeimdallBrowserReturn();
if (returned.status === "error") {
sessionStorage.removeItem(ATTEMPT_KEY);
throw new Error(returned.message);
}
if (storedHandle && storedHandle !== returned.handle) {
sessionStorage.removeItem(ATTEMPT_KEY);
throw new Error("Heimdall returned an authentication attempt other than the one this browser started.");
}
sessionStorage.setItem(ATTEMPT_KEY, returned.handle);
}
const handle = returned?.status === "success" ? returned.handle : storedHandle;
if (!handle) return false;
await target.complete(handle);
return true;
}
export function readHeimdallBrowserReturn(
href: string,
appSlug: string,
): { status: "success"; handle: string } | { status: "error"; message: string } | undefined {
const url = new URL(href);
const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
const status = parameters.get("heimdall_status");
if (!status) return undefined;
if (parameters.get("heimdall_handoff_kind") !== "browser_completion"
|| parameters.get("heimdall_app_slug") !== appSlug) {
return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." };
}
if (status !== "success") {
return {
status: "error",
message: parameters.get("heimdall_error_description")
|| parameters.get("heimdall_error")
|| "Heimdall authentication failed.",
};
}
// The attempt id is the handle the app's backend completes over Heimdall's
// private command plane. The completion code in the same fragment is a
// distinct random secret for the HTTP redeem route; this adapter neither
// needs nor forwards it.
const attemptId = parameters.get("heimdall_attempt_id") || "";
if (!attemptId) {
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
}
return { status: "success", handle: attemptId };
}
function clearHeimdallBrowserReturn(): void {
history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
}
function object(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}

View file

@ -1,197 +0,0 @@
import {
EveBrowserProviderHost,
type EveBrowserProviderTransport,
type EveCommandIntent,
type EveProviderAdvertisement,
type EveProviderSurfaceAdvertisement,
type EveSurfaceDocument,
} from "@gamecult/eve-browser-lowering";
import { heimdallAccessBrowserAdapter, resumeHeimdallAccess } from "./heimdall-access-adapter";
import "@gamecult/eve-browser-lowering/styles.css";
import "./style.css";
class GhostlightEveTransport implements EveBrowserProviderTransport {
private provider: EveProviderAdvertisement | undefined;
private sourceVersion = 0;
private authenticatedSurface = false;
async providerAdvertisement(): Promise<EveProviderAdvertisement> {
this.provider = await fetchJson<EveProviderAdvertisement>("api/eve/provider");
return this.provider;
}
async surface(surface: EveProviderSurfaceAdvertisement): Promise<EveSurfaceDocument> {
const surfaceId = surface.surfaceId || "ghostlight.play";
const document = await fetchJson<EveSurfaceDocument>(`api/eve/surfaces/${encodeURIComponent(surfaceId)}`);
this.sourceVersion = Math.max(0, Math.trunc(document.version || 0));
this.authenticatedSurface = !containsAnonymousAccessGate(document);
return document;
}
hasAuthenticatedSurface(): boolean {
return this.authenticatedSurface;
}
async submitCommand(intent: EveCommandIntent): Promise<unknown> {
const result = await fetchJson<any>("api/eve/commands", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(intent),
});
return result;
}
private advertisedTransport(): { transport?: string } {
const commands = (this.provider as { commands?: Array<{ command?: string; transport?: string }> } | undefined)?.commands;
const transport = commands?.find(command => command.command === "ghostlight.eve.commands")?.transport;
return transport ? { transport } : {};
}
async completeAuthentication(handle: string): Promise<any> {
if (!this.provider) await this.providerAdvertisement();
return await this.submitCommand({
schema: "gamecult.eve.command_invocation.v1",
providerId: this.provider?.providerId || "gamecult.ghostlight.dungeon",
surfaceId: "ghostlight.play",
operation: {
operationId: "heimdall.auth.complete",
schemaId: "heimdall.auth_complete_command.v1",
idempotencyKey: crypto.randomUUID(),
// The route transport is the one the provider advertises for its
// command boundary, as the vendored client takes it from a command
// descriptor; Ghostlight refuses an invocation without it.
routeHint: { sourceVersion: this.sourceVersion, ...this.advertisedTransport() },
},
payload: { handle },
issuedAt: new Date().toISOString(),
clientId: "ghostlight.browser",
commandBoundary: "ghostlight.eve.commands",
receiptSchema: "gamecult.eve.command_result.v1",
});
}
}
async function fetchJson<T = any>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
const response = await fetch(input, { cache: "no-store", credentials: "same-origin", ...init });
const body = await response.json().catch(() => undefined);
if (!response.ok) {
const message = body && typeof body.message === "string"
? body.message
: `Ghostlight refused the request (${response.status}).`;
throw new Error(message);
}
return body as T;
}
function containsAnonymousAccessGate(value: unknown): boolean {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some(containsAnonymousAccessGate);
const node = value as Record<string, unknown>;
if (node.kind === "heimdall.access_gate") {
const props = node.props && typeof node.props === "object" && !Array.isArray(node.props)
? node.props as Record<string, unknown>
: {};
return props.state === "anonymous";
}
return Object.values(node).some(containsAnonymousAccessGate);
}
function requiredElement<T extends HTMLElement>(id: string): T {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) throw new Error(`Missing browser host #${id}.`);
return element as T;
}
const surfaceHost = requiredElement<HTMLElement>("surface");
const status = requiredElement<HTMLElement>("status");
const api = new GhostlightEveTransport();
const host = new EveBrowserProviderHost(surfaceHost, api, {
body: document.body,
clientId: "ghostlight.browser",
// PA.f191: this used to be 0 (no poll at all), relying entirely on the
// `revision` SSE subscription below for every live update. A dropped
// stream — closed by an intermediary, or never subscribed at all (see
// `trySubscribeToRevisions` below) — then froze the card with no recovery
// short of a manual reload. This poll is deliberately low-frequency: it is
// a fallback net, not the primary channel, and `host.refresh()` is already
// a no-op when the surface version has not moved.
pollMs: 5_000,
requestedSurfaceId: "ghostlight.play",
source: "Ghostlight",
statusElement: status,
pluginAdapters: [heimdallAccessBrowserAdapter],
});
window.addEventListener("unhandledrejection", event => {
event.preventDefault();
status.textContent = event.reason instanceof Error ? event.reason.message : String(event.reason);
});
// PA.f191: `api.hasAuthenticatedSurface()` reflects whatever the *last*
// `surface()` fetch saw, and that flag can flip from false to true well
// after this module's own startup sequence finishes — 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,
// never observed by this file directly) authenticates on a timer this file
// has no direct hook into. A one-shot check right after startup only ever
// caught the redirect flow (`resumeHeimdallAccess`'s own `complete`
// callback, below); the in-page flow left the tab with no `revision`
// subscription for the rest of the session, so every answer after the first
// was refused as stale and the card looked frozen. `trySubscribeToRevisions`
// is idempotent and cheap to call speculatively — `hasAuthenticatedSurface()`
// is a field read — so it is called at every point this file can observe an
// authentication transition, plus on a short recheck interval to catch the
// in-page flow it cannot observe directly any other way.
let revisionsSubscribed = false;
function trySubscribeToRevisions(): void {
if (revisionsSubscribed || !api.hasAuthenticatedSurface()) return;
revisionsSubscribed = true;
const events = new EventSource("api/eve/events");
events.addEventListener("revision", () => void host.refresh());
events.addEventListener("error", () => {
status.textContent = "Live revision notices are unavailable; your authoritative surface remains safe to refresh.";
});
}
await host.start();
trySubscribeToRevisions();
const resumeTarget = {
complete: async (handle: string) => {
const result = await api.completeAuthentication(handle);
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
}
await host.refresh();
trySubscribeToRevisions();
},
};
await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" });
trySubscribeToRevisions();
// A player who finishes Discord in another tab or app and switches back to
// this one, rather than following Heimdall's "Return to the app" link, never
// reloads this page, so the load-time resume above never sees the result.
// Re-running the same resume when the tab becomes visible asks Heimdall about
// the attempt this tab started; it answers pending or authenticated, and the
// adapter drops the stored attempt once the answer is final.
let resuming = false;
document.addEventListener("visibilitychange", () => {
if (document.visibilityState !== "visible" || resuming || api.hasAuthenticatedSurface()) return;
resuming = true;
resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" })
.catch(error => {
status.textContent = error instanceof Error ? error.message : String(error);
})
.finally(() => {
resuming = false;
});
});
// The in-page gate's own completion never calls back into this file: it
// resolves inside the vendored host's `submit()` (a command dispatch) and
// its own `window.setTimeout(() => void host.refresh(), 100)`, which is the
// one place `api.hasAuthenticatedSurface()` gets re-evaluated for that flow.
// This is the only vantage point this file has on that transition; the
// pollMs fallback above shares the same "cheap, idempotent, safe to run
// speculatively" reasoning.
window.setInterval(trySubscribeToRevisions, 2_000);

View file

@ -1,25 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:opsz,wght@8..144,400;8..144,600&family=IBM+Plex+Mono:wght@400;600&display=swap');
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}
body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}
header,#surface{width:min(1180px,calc(100% - 2rem));margin:auto}
header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}
h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}
.eyebrow,#status,.field-label,button,pre{font-family:"IBM Plex Mono",monospace}
.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}
#status{color:#9aa69f;font-size:.78rem}
#surface{padding:1.5rem 0 5rem;min-height:12rem}
.eve-surface{display:grid;gap:1rem}
.card,.pane,.heimdall-access{padding:1rem;border:1px solid #66543d;background:#17201ddd}
.cultui-field{display:block;margin:.75rem 0}
.field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}
textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}
textarea{min-height:7rem}
button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}
button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}
pre,.cultui-code{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font:.72rem/1.5 "IBM Plex Mono",monospace}
.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}
.cultui-list-item{border-top:1px solid #354039;padding:.8rem 0}
.cultui-list-item-label,.cultui-list-item-status{font-family:"IBM Plex Mono",monospace;color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}
.cultui-tabs [role="tablist"]{display:flex;gap:.4rem;position:sticky;top:0;background:#0c1110e8;z-index:2}
.eve-command-result{margin:1rem 0;padding:1rem;border:1px solid #66543d;background:#17201d}
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,#surface{width:min(100% - 1rem,1180px)}.card,.pane,.heimdall-access{padding:.75rem}}

View file

@ -1,106 +0,0 @@
import assert from "node:assert/strict";
import { afterEach, test } from "node:test";
import {
heimdallAccessBrowserAdapter,
readHeimdallBrowserReturn,
resumeHeimdallAccess,
} from "../src/heimdall-access-adapter.ts";
const attemptKey = "gamecult.heimdall.access.attempt";
const originalGlobals = {
history: globalThis.history,
sessionStorage: globalThis.sessionStorage,
window: globalThis.window,
};
afterEach(() => {
restoreGlobal("history", originalGlobals.history);
restoreGlobal("sessionStorage", originalGlobals.sessionStorage);
restoreGlobal("window", originalGlobals.window);
});
test("browser return parser completes by attempt id and never reads the completion code", () => {
const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76";
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle });
assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined);
assert.equal(readHeimdallBrowserReturn(returnUrl(handle), "another-app")?.status, "error");
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight"), { status: "success", handle });
assert.ok(!JSON.stringify(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight")).includes("secret-redeem-code"));
assert.equal(readHeimdallBrowserReturn(returnUrl("", "secret-redeem-code"), "ghostlight")?.status, "error");
});
test("fresh browser context redeems the URL witness and scrubs it", async () => {
const handle = "0be30b8c-b599-4920-b8b8-733f5e7bc275";
const storage = memoryStorage();
const replaced = [];
installBrowserGlobals(returnUrl(handle), storage, replaced);
const completed = [];
assert.equal(await resumeHeimdallAccess({ complete: async value => completed.push(value) }, { appSlug: "ghostlight" }), true);
assert.deepEqual(completed, [handle]);
assert.equal(storage.getItem(attemptKey), handle);
assert.deepEqual(replaced, ["/ghostlight/"]);
await heimdallAccessBrowserAdapter.consumeCommandResult({
schemaId: "heimdall.auth_completion_status.v1",
payload: { status: "authenticated" },
});
assert.equal(storage.getItem(attemptKey), null);
});
test("conflicting stored and returned attempts never reach completion", async () => {
const storage = memoryStorage([[attemptKey, "stored-handle"]]);
installBrowserGlobals(returnUrl("returned-handle"), storage, []);
let completed = false;
await assert.rejects(
resumeHeimdallAccess({ complete: async () => { completed = true; } }, { appSlug: "ghostlight" }),
/other than the one this browser started/,
);
assert.equal(completed, false);
assert.equal(storage.getItem(attemptKey), null);
});
function installBrowserGlobals(href, storage, replaced) {
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage });
Object.defineProperty(globalThis, "history", {
configurable: true,
value: { replaceState: (_state, _title, url) => replaced.push(url) },
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { location: { href, pathname: "/ghostlight/", search: "" } },
});
}
function restoreGlobal(name, value) {
if (value === undefined) delete globalThis[name];
else Object.defineProperty(globalThis, name, { configurable: true, value });
}
// Heimdall issues a random completion code distinct from the attempt id, as
// the real callback does; the fixture must not restate the old equality.
function returnUrl(attemptId, completionCode = `code-for-${attemptId}`) {
const parameters = new URLSearchParams({
heimdall_status: "success",
heimdall_provider: "discord",
heimdall_handoff_kind: "browser_completion",
heimdall_app_slug: "ghostlight",
heimdall_mode: "sign_in",
heimdall_attempt_id: attemptId,
heimdall_completion_code: completionCode,
});
return `https://yggdrasil.gamecult.org/ghostlight/#${parameters}`;
}
function memoryStorage(initial = []) {
const values = new Map(initial);
return {
get length() { return values.size; },
clear: () => values.clear(),
getItem: key => values.get(key) ?? null,
key: index => [...values.keys()][index] ?? null,
removeItem: key => { values.delete(key); },
setItem: (key, value) => { values.set(key, value); },
};
}

View file

@ -1,36 +0,0 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
const forbidden = [
"/api/session-zero",
"/api/governance",
"/api/command",
"/api/auth/heimdall",
"renderSession",
"renderCampaign",
"payload.fields",
'kind: "form"',
'"kind":"form"',
];
function sourceFiles(directory) {
return readdirSync(directory)
.flatMap(name => {
const path = join(directory, name);
return statSync(path).isDirectory() ? sourceFiles(path) : [path];
})
.filter(path => /\.(ts|tsx|js|mjs)$/.test(path));
}
const violations = sourceFiles(fileURLToPath(new URL("../src", import.meta.url)))
.flatMap(path => {
const source = readFileSync(path, "utf8");
return forbidden.filter(token => source.includes(token)).map(token => `${path}: ${token}`);
});
if (violations.length) {
throw new Error(`Ghostlight browser crossed its Eve boundary:\n${violations.join("\n")}`);
}
console.log("Ghostlight browser contains only the Eve provider boundary.");

View file

@ -1,12 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"noEmit": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}

View file

@ -1,15 +0,0 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
export default defineConfig({
resolve: {
alias: {
"@gamecult/eve-contracts": fileURLToPath(
new URL("../vendor/eve/packages/eve-contracts/dist/index.js", import.meta.url),
),
"ajv/dist/2020.js": fileURLToPath(
new URL("./node_modules/ajv/dist/2020.js", import.meta.url),
),
},
},
});