Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:34:20 +02:00
74 changed files with 13597 additions and 29627 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.

702
Cargo.lock generated

File diff suppressed because it is too large Load diff

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

@ -12,6 +12,7 @@ cultcache-rs.workspace = true
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
reqwest.workspace = true
rmp-serde.workspace = true
rmpv = "1.3"
serde.workspace = true
serde_bytes = "0.11"
serde_json.workspace = true
@ -19,6 +20,8 @@ sha2.workspace = true
thiserror.workspace = true
tokio.workspace = true
tracing.workspace = true
unicode-normalization = "0.1.25"
unicode-properties = "0.1.4"
uuid.workspace = true
zeroize.workspace = true

View file

@ -0,0 +1,297 @@
//! Aetheria's catalog file, read once into the two things Ghostlight takes from
//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records
//! keyed for import.
//!
//! The catalog is Aetheria's: this reader opens it read-only and never writes
//! it, and it ignores every schema it does not mirror. The mirrors below decode
//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction`
//! v1 by integer key: a MessagePack-keyed object is an array indexed by key,
//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored
//! and a field it drops is an error naming the record and the key. A record
//! that does not decode, an enum value outside the mirror and a verb that
//! breaks the grammar's name or role rules are errors, never skipped rows.
use crate::ImportKey;
use crate::grammar::{
GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath,
};
use cultcache_rs::SingleFileMessagePackBackingStore;
use rmpv::Value;
use std::collections::BTreeMap;
use std::path::Path;
const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar";
const VERB_SCHEMA: &str = "aetheria.verse_verb";
const FACTION_SCHEMA: &str = "aetheria.faction";
const VERB_NAME_MAX: usize = 48;
const ROLE_NAME_MAX: usize = 32;
/// The invoking subject, never a role.
const ACTOR_ROLE: &str = "actor";
/// What Ghostlight takes from Aetheria's catalog.
#[derive(Clone, Debug, PartialEq)]
pub struct AetheriaCatalog {
pub grammar: GrammarBinding,
/// In record-key order.
pub factions: Vec<AetheriaFaction>,
}
/// One `aetheria.faction` record. `allegiance` is carried and read by no
/// consumer yet.
#[derive(Clone, Debug, PartialEq)]
pub struct AetheriaFaction {
pub key: ImportKey,
pub name: String,
pub short_name: String,
pub description: String,
pub allegiance: BTreeMap<ImportKey, f32>,
}
/// Which of the grammar's name or role rules a verb broke.
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
pub enum VerbRule {
#[error("verb name is not canonical")]
Name,
#[error("role name is not canonical")]
RoleName,
#[error("role name is reserved for the invoking subject")]
ReservedRole,
#[error("role name is declared twice")]
DuplicateRole,
}
/// Why a catalog could not be read. Records are named by record key and fields
/// by integer key; no error carries a value the catalog held.
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum CatalogReadError {
#[error("the catalog file cannot be read")]
Unreadable,
#[error("the catalog holds no aetheria.verse_grammar global")]
NoGrammar,
#[error("the catalog holds more than one aetheria.verse_grammar record")]
MultipleGrammars,
#[error("record {record:?} field {field} is missing or not the mirrored type")]
MalformedRecord { record: String, field: u8 },
#[error("record {record:?} field {field} is not a canonical import key")]
NonCanonicalKey { record: String, field: u8 },
#[error("record {record:?} is a second aetheria.faction under one record key")]
DuplicateFaction { record: String },
#[error("record {record:?} field {field} is not a known render path")]
UnknownRenderPath { record: String, field: u8 },
#[error("record {record:?} field {field} is not a known referent kind")]
UnknownReferentKind { record: String, field: u8 },
#[error("verb record {record:?} breaks a grammar rule: {rule}")]
InvalidVerb { record: String, rule: VerbRule },
#[error("the verbs do not bind a grammar: {0}")]
Grammar(#[from] GrammarError),
}
/// Reads the catalog at `path` without writing it. An absent file is
/// `Unreadable`, not an empty catalog.
pub fn read_aetheria_catalog(path: impl AsRef<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
let path = path.as_ref();
std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?;
let mut rows = SingleFileMessagePackBackingStore::new(path)
.pull_all_read_only_snapshot()
.map_err(|_| CatalogReadError::Unreadable)?;
rows.sort_by(|a, b| a.key.cmp(&b.key));
let mut revision = None;
let mut verbs = Vec::new();
let mut factions = Vec::new();
for row in &rows {
match row.r#type.as_str() {
GRAMMAR_SCHEMA => {
if revision.is_some() {
return Err(CatalogReadError::MultipleGrammars);
}
let cells = Cells::of(&row.key, &row.payload)?;
revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?);
}
VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?),
FACTION_SCHEMA => {
let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?;
if factions
.last()
.is_some_and(|seen: &AetheriaFaction| seen.key == faction.key)
{
return Err(CatalogReadError::DuplicateFaction {
record: row.key.clone(),
});
}
factions.push(faction);
}
_ => {}
}
}
let revision = revision.ok_or(CatalogReadError::NoGrammar)?;
Ok(AetheriaCatalog {
grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?,
factions,
})
}
/// A record's payload: the positional array of its keyed fields.
struct Cells<'a> {
record: &'a str,
cells: Vec<Value>,
}
impl<'a> Cells<'a> {
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
let mut payload = payload;
match rmpv::decode::read_value(&mut payload) {
Ok(Value::Array(cells)) => Ok(Self { record, cells }),
_ => Err(CatalogReadError::MalformedRecord {
record: record.to_owned(),
field: 0,
}),
}
}
fn malformed(&self, field: u8) -> CatalogReadError {
CatalogReadError::MalformedRecord {
record: self.record.to_owned(),
field,
}
}
fn non_canonical(&self, field: u8) -> CatalogReadError {
CatalogReadError::NonCanonicalKey {
record: self.record.to_owned(),
field,
}
}
fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> {
self.cells
.get(usize::from(field))
.ok_or_else(|| self.malformed(field))
}
fn int(&self, field: u8) -> Result<i64, CatalogReadError> {
self.cell(field)?
.as_i64()
.ok_or_else(|| self.malformed(field))
}
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
self.cell(field)?
.as_str()
.map(str::to_owned)
.ok_or_else(|| self.malformed(field))
}
/// A string Aetheria may leave null; null reads as empty.
fn text_or_empty(&self, field: u8) -> Result<String, CatalogReadError> {
match self.cell(field)? {
Value::Nil => Ok(String::new()),
Value::String(text) => text
.as_str()
.map(str::to_owned)
.ok_or_else(|| self.malformed(field)),
_ => Err(self.malformed(field)),
}
}
fn array(&self, field: u8) -> Result<&Vec<Value>, CatalogReadError> {
self.cell(field)?
.as_array()
.ok_or_else(|| self.malformed(field))
}
}
fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> {
let invalid = |rule| CatalogReadError::InvalidVerb {
record: cells.record.to_owned(),
rule,
};
let name = cells.text(1)?;
if !canonical_name(&name, VERB_NAME_MAX) {
return Err(invalid(VerbRule::Name));
}
let render_path = match cells.int(2)? {
1 => RenderPath::ShipAction,
2 => RenderPath::Conversation,
_ => {
return Err(CatalogReadError::UnknownRenderPath {
record: cells.record.to_owned(),
field: 2,
});
}
};
let mut roles: Vec<GrammarRole> = Vec::new();
for role in cells.array(3)? {
let role = Cells {
record: cells.record,
cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?,
};
let role_name = role.text(1).map_err(|_| cells.malformed(3))?;
if !canonical_name(&role_name, ROLE_NAME_MAX) {
return Err(invalid(VerbRule::RoleName));
}
if role_name == ACTOR_ROLE {
return Err(invalid(VerbRule::ReservedRole));
}
if roles.iter().any(|seen| seen.role == role_name) {
return Err(invalid(VerbRule::DuplicateRole));
}
let referent = match role.int(2).map_err(|_| cells.malformed(3))? {
1 => GrammarReferentKind::Person,
2 => GrammarReferentKind::Faction,
3 => GrammarReferentKind::Population,
4 => GrammarReferentKind::Place,
5 => GrammarReferentKind::Cargo,
_ => {
return Err(CatalogReadError::UnknownReferentKind {
record: cells.record.to_owned(),
field: 3,
});
}
};
roles.push(GrammarRole {
role: role_name,
referent,
});
}
Ok((name, GrammarVerb { render_path, roles }))
}
/// A lower-case letter, then lower-case letters, digits and underscores.
fn canonical_name(name: &str, max_len: usize) -> bool {
let mut chars = name.chars();
name.len() <= max_len
&& chars.next().is_some_and(|c| c.is_ascii_lowercase())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
}
fn read_faction(key: &str, cells: &Cells) -> Result<AetheriaFaction, CatalogReadError> {
let own = ImportKey::new(FACTION_SCHEMA, key);
if !own.is_canonical() {
return Err(cells.non_canonical(0));
}
let Some(allegiance) = cells.cell(12)?.as_map() else {
return Err(cells.malformed(12));
};
let mut standing = BTreeMap::new();
for (other, weight) in allegiance {
let other = other.as_str().ok_or_else(|| cells.malformed(12))?;
let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?;
let other = ImportKey::new(FACTION_SCHEMA, other);
if !other.is_canonical() {
return Err(cells.non_canonical(12));
}
standing.insert(other, weight as f32);
}
Ok(AetheriaFaction {
key: own,
name: cells.text(1)?,
short_name: cells.text_or_empty(2)?,
description: cells.text_or_empty(3)?,
allegiance: standing,
})
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,589 @@
use super::*;
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
use serde_json::{Value, json};
/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`)
/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`.
const FIXTURE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/aetheria-verse-catalog.cc"
);
fn fixture_rows() -> Vec<CultCacheEnvelope> {
SingleFileMessagePackBackingStore::new(FIXTURE)
.pull_all_read_only_snapshot()
.unwrap()
}
fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope {
rows.iter_mut().find(|row| row.key == key).unwrap()
}
/// Re-encodes one record's positional payload after `edit`.
fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec<Value>)) {
let row = row_of(rows, key);
let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else {
panic!("{key} is not a positional record");
};
change(&mut cells);
row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap();
}
fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.cc");
let mut store = SingleFileMessagePackBackingStore::new(&path);
for row in rows {
store.push(row).unwrap();
}
(dir, path)
}
fn read(rows: &[CultCacheEnvelope]) -> Result<AetheriaCatalog, CatalogReadError> {
let (_dir, path) = catalog_of(rows);
read_aetheria_catalog(&path)
}
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
GrammarVerb {
render_path: path,
roles: roles
.iter()
.map(|(role, referent)| GrammarRole {
role: (*role).into(),
referent: *referent,
})
.collect(),
}
}
/// What Aetheria's fixture writer authored, spelled out by hand.
fn authored_verbs() -> Vec<(String, GrammarVerb)> {
vec![
(
"haul".into(),
verb(
RenderPath::ShipAction,
&[
("cargo", GrammarReferentKind::Cargo),
("destination", GrammarReferentKind::Place),
],
),
),
(
"attack".into(),
verb(
RenderPath::ShipAction,
&[("target", GrammarReferentKind::Faction)],
),
),
("speak".into(), verb(RenderPath::Conversation, &[])),
]
}
fn faction_key(record: &str) -> ImportKey {
ImportKey::new("aetheria.faction", record)
}
#[test]
fn reads_the_aetheria_written_fixture() {
let catalog = read_aetheria_catalog(FIXTURE).unwrap();
let identity = catalog.grammar.identity();
assert_eq!(identity.schema_id, "aetheria.verse_grammar");
assert_eq!(identity.revision, 1);
for (kind, expected) in authored_verbs() {
assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}");
}
assert!(catalog.grammar.verb("mine").is_none());
assert_eq!(
catalog.factions,
vec![
AetheriaFaction {
key: faction_key("faction-adrasteia"),
name: "Adrasteia".into(),
short_name: "ADR".into(),
description: "First.".into(),
allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]),
},
AetheriaFaction {
key: faction_key("faction-brannoch"),
name: "Brannoch".into(),
short_name: "BRN".into(),
description: "Second.".into(),
allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]),
},
]
);
}
#[test]
fn grammar_digest_matches_a_binding_built_by_hand() {
let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap();
let read = read_aetheria_catalog(FIXTURE).unwrap().grammar;
assert_eq!(read.identity().digest, by_hand.identity().digest);
assert_eq!(read, by_hand);
// The digest follows the verbs: a role the file drops is a different grammar.
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| {
cells[3].as_array_mut().unwrap().pop();
});
assert_ne!(
read_catalog_digest(&rows),
by_hand.identity().digest,
"dropping a role did not move the digest"
);
}
fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String {
read(rows).unwrap().grammar.identity().digest
}
#[test]
fn refuses_a_catalog_without_a_grammar() {
let mut rows = fixture_rows();
rows.retain(|row| row.r#type != "aetheria.verse_grammar");
assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar));
}
#[test]
fn refuses_a_second_grammar_record() {
let mut rows = fixture_rows();
let mut second = rows
.iter()
.find(|row| row.r#type == "aetheria.verse_grammar")
.unwrap()
.clone();
second.key.push_str("-again");
rows.push(second);
assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars));
}
#[test]
fn refuses_an_unknown_render_path() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3));
let error = read(&rows).unwrap_err();
assert_eq!(
error,
CatalogReadError::UnknownRenderPath {
record: "verb-haul".into(),
field: 2
}
);
assert!(!error.to_string().contains('3'), "the value was echoed");
}
#[test]
fn refuses_an_unknown_referent_kind() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-attack", |cells| {
cells[3][0][2] = json!(9);
});
assert_eq!(
read(&rows),
Err(CatalogReadError::UnknownReferentKind {
record: "verb-attack".into(),
field: 3
})
);
}
#[test]
fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() {
let broken = |mutate: fn(&mut Vec<Value>)| {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", mutate);
read(&rows).unwrap_err()
};
let invalid = |rule| CatalogReadError::InvalidVerb {
record: "verb-haul".into(),
rule,
};
assert_eq!(
broken(|cells| cells[1] = json!("Haul")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("1haul")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("")),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[1] = json!("h".repeat(49))),
invalid(VerbRule::Name)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("Cargo")),
invalid(VerbRule::RoleName)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("c".repeat(33))),
invalid(VerbRule::RoleName)
);
assert_eq!(
broken(|cells| cells[3][0][1] = json!("actor")),
invalid(VerbRule::ReservedRole)
);
assert_eq!(
broken(|cells| cells[3][1][1] = json!("cargo")),
invalid(VerbRule::DuplicateRole)
);
}
#[test]
fn the_longest_canonical_names_are_admitted() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| {
cells[1] = json!(format!("h{}9", "_9".repeat(23)));
cells[3][0][1] = json!(format!("c{}", "z".repeat(31)));
});
let catalog = read(&rows).unwrap();
assert_eq!(catalog.grammar.identity().revision, 1);
}
#[test]
fn two_verbs_with_one_name_do_not_bind() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack"));
assert_eq!(
read(&rows),
Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb))
);
}
#[test]
fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() {
let malformed = |record: &str, field| CatalogReadError::MalformedRecord {
record: record.into(),
field,
};
let mut rows = fixture_rows();
row_of(&mut rows, "faction-brannoch").payload =
rmp_serde::to_vec(&json!("not an array")).unwrap();
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[1] = Value::Null
});
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[12] = json!({"faction-adrasteia": "heavy"});
});
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12));
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
let mut rows = fixture_rows();
let grammar = rows
.iter()
.find(|row| row.r#type == "aetheria.verse_grammar")
.unwrap()
.key
.clone();
edit(&mut rows, &grammar, |cells| cells[1] = json!(-1));
assert_eq!(read(&rows), Err(malformed(&grammar, 1)));
}
#[test]
fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() {
let mut rows = fixture_rows();
edit(&mut rows, "verb-speak", |cells| cells.push(json!("later")));
edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7)));
let mut stranger = rows[0].clone();
stranger.key = "ship-1".into();
stranger.r#type = "aetheria.ship".into();
stranger.schema_id = Some("sha256:not-mirrored".into());
stranger.payload = vec![0xc1];
rows.push(stranger);
assert_eq!(
read(&rows).unwrap(),
read_aetheria_catalog(FIXTURE).unwrap()
);
}
#[test]
fn a_null_short_name_or_description_reads_as_empty() {
let mut rows = fixture_rows();
edit(&mut rows, "faction-adrasteia", |cells| {
cells[2] = Value::Null;
cells[3] = Value::Null;
});
let catalog = read(&rows).unwrap();
assert_eq!(catalog.factions[0].short_name, "");
assert_eq!(catalog.factions[0].description, "");
}
#[test]
fn an_absent_file_is_unreadable_not_an_empty_catalog() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(
read_aetheria_catalog(dir.path().join("missing.cc")),
Err(CatalogReadError::Unreadable)
);
}
#[test]
fn reading_leaves_the_catalog_and_its_directory_untouched() {
let (dir, path) = catalog_of(&fixture_rows());
let bytes = std::fs::read(&path).unwrap();
let listing = |dir: &std::path::Path| {
let mut names: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
names.sort();
names
};
let before = listing(dir.path());
read_aetheria_catalog(&path).unwrap();
read_aetheria_catalog(&path).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), bytes);
assert_eq!(listing(dir.path()), before, "the reader created a file");
}
#[test]
fn every_referent_code_maps_to_its_own_kind() {
// The fixture binds only Faction, Cargo and Place; Person and Population
// are authored here at the role's integer key, each read back as itself.
for (code, expected) in [
(1, GrammarReferentKind::Person),
(2, GrammarReferentKind::Faction),
(3, GrammarReferentKind::Population),
(4, GrammarReferentKind::Place),
(5, GrammarReferentKind::Cargo),
] {
let mut rows = fixture_rows();
edit(&mut rows, "verb-attack", |cells| {
cells[3][0][2] = json!(code)
});
let catalog = read(&rows).unwrap();
assert_eq!(
catalog.grammar.verb("attack"),
Some(&verb(RenderPath::ShipAction, &[("target", expected)])),
"code {code}"
);
}
}
fn faction_with_key(record: &str) -> Vec<CultCacheEnvelope> {
let mut rows = fixture_rows();
row_of(&mut rows, "faction-brannoch").key = record.into();
rows
}
#[test]
fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
for bad in [
"faction-cafe\u{301}",
"faction-brannoch ",
"faction-\u{202e}brannoch",
"",
] {
assert_eq!(
read(&faction_with_key(bad)),
Err(CatalogReadError::NonCanonicalKey {
record: bad.into(),
field: 0
}),
"{bad:?}"
);
}
}
#[test]
fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] {
let mut rows = fixture_rows();
edit(&mut rows, "faction-brannoch", |cells| {
cells[12] = json!({ bad: 0.5 });
});
assert_eq!(
read(&rows),
Err(CatalogReadError::NonCanonicalKey {
record: "faction-brannoch".into(),
field: 12
}),
"{bad:?}"
);
}
}
/// The fixture's snapshot bytes with the record rows changed by `change`, for
/// what the store's own writer will not produce: it keeps one row per record
/// key and stores rows in key order.
fn spliced_catalog(
change: impl FnOnce(&mut Vec<rmpv::Value>),
) -> (tempfile::TempDir, std::path::PathBuf) {
let bytes = std::fs::read(FIXTURE).unwrap();
let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap()
else {
panic!("snapshot is not a tuple");
};
let rmpv::Value::Array(records) = &mut snapshot[2] else {
panic!("snapshot holds no records");
};
change(records);
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("catalog.cc");
let mut spliced = Vec::new();
rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap();
std::fs::write(&path, spliced).unwrap();
(dir, path)
}
#[test]
fn two_faction_rows_under_one_record_key_are_refused() {
let (_dir, path) = spliced_catalog(|records| {
let twin = records
.iter()
.find(|record| record[0].as_str() == Some("faction-adrasteia"))
.unwrap()
.clone();
records.push(twin);
});
assert_eq!(
read_aetheria_catalog(&path),
Err(CatalogReadError::DuplicateFaction {
record: "faction-adrasteia".into()
})
);
}
#[test]
fn factions_are_read_in_record_key_order_whatever_the_file_order() {
let (_dir, path) = spliced_catalog(|records| {
records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str()));
assert_eq!(records[0][0].as_str(), Some("verb-speak"));
});
let names: Vec<_> = read_aetheria_catalog(&path)
.unwrap()
.factions
.into_iter()
.map(|faction| faction.name)
.collect();
assert_eq!(names, ["Adrasteia", "Brannoch"]);
}
#[test]
fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() {
for not_a_map in [
json!([]),
json!(["faction-adrasteia"]),
Value::Null,
json!(1),
] {
let mut rows = fixture_rows();
let shape = not_a_map.clone();
edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape);
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: 12
}),
"{not_a_map}"
);
}
}
/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]`
/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1).
#[test]
fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
let mut rows = fixture_rows();
let row = row_of(&mut rows, "faction-adrasteia");
let rmpv::Value::Array(mut cells) =
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
else {
panic!("not positional");
};
cells.push(rmpv::Value::Binary(vec![1, 0, 7]));
cells.push(rmpv::Value::Ext(
-1,
vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80],
));
let mut payload = Vec::new();
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
row.payload = payload;
assert_eq!(
read(&rows).unwrap(),
read_aetheria_catalog(FIXTURE).unwrap()
);
}
/// A faction record re-encoded with `change` applied to its decoded cells, for
/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes.
fn with_cells(key: &str, change: impl FnOnce(&mut Vec<rmpv::Value>)) -> Vec<CultCacheEnvelope> {
let mut rows = fixture_rows();
let row = row_of(&mut rows, key);
let rmpv::Value::Array(mut cells) =
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
else {
panic!("not positional");
};
change(&mut cells);
let mut payload = Vec::new();
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
row.payload = payload;
rows
}
#[test]
fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() {
for key in [
rmpv::Value::from(5),
rmpv::Value::Nil,
rmpv::Value::Binary(b"faction-adrasteia".to_vec()),
] {
let rows = with_cells("faction-brannoch", |cells| {
cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]);
});
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: 12
})
);
}
}
#[test]
fn text_that_is_not_utf8_is_malformed_by_key_and_field() {
// The marker is overwritten in the encoded bytes with an invalid sequence of
// the same length, since a decoded string cannot hold one.
const MARKER: &str = "ZZZZ";
for field in [1usize, 3] {
let mut rows = with_cells("faction-brannoch", |cells| {
cells[field] = rmpv::Value::from(MARKER);
});
let payload = &mut row_of(&mut rows, "faction-brannoch").payload;
let at = payload
.windows(MARKER.len())
.position(|window| window == MARKER.as_bytes())
.unwrap();
payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
assert_eq!(
read(&rows),
Err(CatalogReadError::MalformedRecord {
record: "faction-brannoch".into(),
field: field as u8
}),
"field {field}"
);
}
}

View file

@ -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,
@ -518,6 +518,7 @@ mod tests {
},
controller,
position: Some(Ref::Existing(commons)),
import_key: None,
})
};
WorldPatch {
@ -735,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 {
@ -1195,6 +1195,7 @@ mod tests {
controller: NewController::External { consumer },
affordances: BTreeSet::new(),
position: Some(Ref::Existing(commons)),
import_key: None,
})])
};
// A binding to another consumer is outside its ground.
@ -1573,6 +1574,7 @@ mod tests {
controller,
affordances,
position: Some(Ref::Existing(mirror.commons)),
import_key: None,
})])
};
let pairing = |result: Result<SubmitReceipt, KernelError>| {

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

@ -2158,6 +2158,7 @@ mod tests {
})
.collect(),
receipt_digest: "sha256:receipt".into(),
usage: None,
}
}

View file

@ -0,0 +1,209 @@
//! The action grammar a world is bound to at creation.
//!
//! A grammar is the closed set of affordance kinds a world may declare, each
//! with its role signature and the one render path its acts take. The grammar's
//! home is the Aetheria repo; the kernel holds only this binding: the verbs it
//! enforces, and a content digest that names exactly which grammar that was.
//! The digest is derived here and nowhere else, so a binding cannot claim a
//! digest its verbs do not have.
use crate::SubjectKind;
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
/// How an act of one verb reaches the player: filmed in the engine as a ship
/// action, or shown as Aetheria's portrait-and-choices conversation.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum RenderPath {
ShipAction,
Conversation,
}
/// What a grammar role may be bound to, in the grammar's own vocabulary. Each
/// lowers to exactly one kernel `RefKind`.
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(rename_all = "snake_case")]
pub enum GrammarReferentKind {
Person,
Faction,
Population,
Place,
Cargo,
}
impl GrammarReferentKind {
fn ref_kind(self) -> RefKind {
match self {
Self::Person => RefKind::Subject(Some(SubjectKind::Person)),
Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)),
Self::Population => RefKind::Subject(Some(SubjectKind::Population)),
Self::Place => RefKind::Entity(EntityKind::Place),
Self::Cargo => RefKind::Entity(EntityKind::Resource),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct GrammarRole {
pub role: String,
pub referent: GrammarReferentKind,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct GrammarVerb {
pub render_path: RenderPath,
/// In signature order: the order is part of the verb.
pub roles: Vec<GrammarRole>,
}
impl GrammarVerb {
/// Whether a declared role list is this verb's signature: same names, same
/// referent kinds, same order.
pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool {
self.role_specs() == roles
}
/// This verb's signature as the kernel declares it: the one lowering of a
/// grammar role, so declaring a verb and admitting a declaration cannot
/// disagree.
pub(crate) fn role_specs(&self) -> Vec<RoleSpec> {
self.roles
.iter()
.map(|role| RoleSpec {
role: Role(role.role.clone()),
kind: role.referent.ref_kind(),
})
.collect()
}
}
/// Why a bound grammar refuses an affordance entry.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum GrammarRefusal {
KindNotInGrammar,
RolesDisagree,
SpeakNotConversation,
}
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
pub enum GrammarError {
#[error("grammar schema id is empty")]
EmptySchemaId,
#[error("grammar declares one verb kind twice")]
DuplicateVerb,
#[error("grammar digest does not match its verbs")]
DigestMismatch,
}
/// Which grammar a world is bound to: its schema id, revision and content
/// digest. What a consumer reads; the verb table stays in the binding.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GrammarIdentity {
pub schema_id: String,
pub revision: u32,
pub digest: String,
}
/// The grammar a world was created under. Immutable: the only constructor
/// derives the digest, and deserialisation re-derives it and refuses a mismatch.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(try_from = "RawBinding")]
pub struct GrammarBinding {
schema_id: String,
revision: u32,
digest: String,
verbs: BTreeMap<String, GrammarVerb>,
}
#[derive(Deserialize)]
struct RawBinding {
schema_id: String,
revision: u32,
digest: String,
verbs: BTreeMap<String, GrammarVerb>,
}
impl TryFrom<RawBinding> for GrammarBinding {
type Error = GrammarError;
fn try_from(raw: RawBinding) -> Result<Self, GrammarError> {
let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?;
if bound.digest != raw.digest {
return Err(GrammarError::DigestMismatch);
}
Ok(bound)
}
}
impl GrammarBinding {
/// The one constructor. Verbs arrive in any order and are keyed by kind;
/// the digest is the sha256 of the canonical encoding of the kind-sorted
/// verbs, so the same verbs bind to the same digest however they were listed.
pub fn new(
schema_id: impl Into<String>,
revision: u32,
verbs: impl IntoIterator<Item = (String, GrammarVerb)>,
) -> Result<Self, GrammarError> {
let schema_id = schema_id.into();
if schema_id.is_empty() {
return Err(GrammarError::EmptySchemaId);
}
let mut table = BTreeMap::new();
for (kind, verb) in verbs {
if table.insert(kind, verb).is_some() {
return Err(GrammarError::DuplicateVerb);
}
}
let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes");
let digest = format!("sha256:{:x}", Sha256::digest(encoded));
Ok(Self {
schema_id,
revision,
digest,
verbs: table,
})
}
pub fn identity(&self) -> GrammarIdentity {
GrammarIdentity {
schema_id: self.schema_id.clone(),
revision: self.revision,
digest: self.digest.clone(),
}
}
/// Every verb, in kind order.
pub(crate) fn verbs(&self) -> impl Iterator<Item = (&str, &GrammarVerb)> {
self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb))
}
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
self.verbs.get(kind)
}
/// The one grammar check. The resolver turns a refusal into a mismatch and
/// `admit_resolved`, the only writer of the affordance catalog, into an
/// invariant error, so no path adds an entry without passing it.
pub(crate) fn refusal(
&self,
kind: &AffordanceKindName,
roles: &[RoleSpec],
) -> Option<GrammarRefusal> {
let Some(verb) = self.verb(&kind.0) else {
return Some(GrammarRefusal::KindNotInGrammar);
};
if !verb.admits_roles(roles) {
return Some(GrammarRefusal::RolesDisagree);
}
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
return Some(GrammarRefusal::SpeakNotConversation);
}
None
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,745 @@
use super::*;
use crate::patch::{
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind,
Mismatch, OutcomeBand, Precondition, Role, RoleSpec,
};
use crate::tests::{auth_principal, command, creation, owner};
use crate::{
CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt,
WorldId, WorldKernel, WorldPatch,
};
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
GrammarVerb {
render_path: path,
roles: roles
.iter()
.map(|(role, referent)| GrammarRole {
role: (*role).into(),
referent: *referent,
})
.collect(),
}
}
/// What the fixture world's genesis needs: the kernel speak entry and the
/// fixture's own `convene`. Anything else a test wants is added to it.
fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding {
let mut verbs = vec![
("speak".to_owned(), verb(RenderPath::Conversation, &[])),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
];
verbs.extend(
extra
.iter()
.map(|(kind, v)| ((*kind).to_owned(), v.clone())),
);
GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap()
}
fn bound(grammar: Option<GrammarBinding>, title: &str) -> CreateWorld {
let mut world = creation(CommandId::new(), title);
world.grammar = grammar;
world
}
fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration {
Declaration::Affordance(AffordanceDeclaration {
handle: DraftHandle::new(handle),
kind: AffordanceKindName(kind.into()),
roles: roles
.iter()
.map(|(role, kind)| RoleSpec {
role: Role((*role).into()),
kind: *kind,
})
.collect(),
// Speech with one audience is the smallest entry the resolver does
// not refuse as inert; the grammar check does not read it.
preconditions: vec![Precondition::CanBroadcast {
via: AudienceSpec::Colocated,
}],
effect_slots: Vec::new(),
outcome_bands: vec![OutcomeBand {
weight: 1,
effects: Vec::new(),
}],
carries_speech: true,
})
}
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
WorldPatch {
declarations,
operations: Vec::new(),
evidence: Vec::new(),
}
}
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
match result {
Err(KernelError::PatchRejected(set)) => set,
Err(other) => panic!("expected a patch refusal, got {other:?}"),
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
}
}
/// Each call gets its own store path: kernels are created, not reopened.
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let directory = tempfile::tempdir().unwrap();
let result = WorldKernel::create(
directory.path().join("world.cc"),
world,
&auth_principal(owner()),
)
.map(|(kernel, _)| kernel);
(directory, result)
}
fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
CallerId::Principal(owner()),
CommandBody::AdmitPatch {
answers: None,
patch,
},
),
&auth_principal(owner()),
)
}
fn person() -> RefKind {
RefKind::Subject(Some(SubjectKind::Person))
}
fn place() -> RefKind {
RefKind::Entity(EntityKind::Place)
}
fn not_in_grammar(handle: &str) -> Vec<Mismatch> {
vec![Mismatch::AffordanceKindNotInGrammar {
handle: DraftHandle::new(handle),
}]
}
fn disagrees(handle: &str) -> Vec<Mismatch> {
vec![Mismatch::AffordanceRolesDisagreeWithGrammar {
handle: DraftHandle::new(handle),
}]
}
#[test]
fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() {
// Genesis: a declared kind the binding lacks is refused.
let mut world = bound(Some(grammar_with(&[])), "Outside");
world.patch.declarations.push(declare("wave", "wave", &[]));
assert_eq!(rejected(create(world).1), not_in_grammar("wave"));
// A later AdmitPatch: the same kind is refused, and a verb the grammar
// does carry is admitted by the same path.
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside"));
let mut kernel = kernel.unwrap();
assert_eq!(
rejected(admit(
&mut kernel,
patch_of(vec![declare("wave", "wave", &[])])
)),
not_in_grammar("wave")
);
let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() {
let extra = [
(
"meet",
verb(
RenderPath::ShipAction,
&[
("who", GrammarReferentKind::Person),
("where_", GrammarReferentKind::Place),
],
),
),
("hail", verb(RenderPath::Conversation, &[])),
];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures"));
let mut kernel = kernel.unwrap();
for (label, roles) in [
("renamed", vec![("whom", person()), ("where_", place())]),
("rekinded", vec![("who", person()), ("where_", person())]),
("swapped", vec![("where_", place()), ("who", person())]),
("short", vec![("who", person())]),
(
"long",
vec![("who", person()), ("where_", place()), ("why", person())],
),
] {
let set = rejected(admit(
&mut kernel,
patch_of(vec![declare(label, "meet", &roles)]),
));
assert_eq!(set, disagrees(label), "{label}");
}
// A role on a verb the grammar declares with none.
let set = rejected(admit(
&mut kernel,
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
));
assert_eq!(set, disagrees("hail"));
// The exact signature is admitted.
let receipt = admit(
&mut kernel,
patch_of(vec![declare(
"meet",
"meet",
&[("who", person()), ("where_", place())],
)]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() {
let kinds = [
(GrammarReferentKind::Person, person()),
(
GrammarReferentKind::Faction,
RefKind::Subject(Some(SubjectKind::Institution)),
),
(
GrammarReferentKind::Population,
RefKind::Subject(Some(SubjectKind::Population)),
),
(GrammarReferentKind::Place, place()),
(
GrammarReferentKind::Cargo,
RefKind::Entity(EntityKind::Resource),
),
];
for (referent, own) in &kinds {
let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))];
for (_, offered) in &kinds {
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents"));
let mut kernel = kernel.unwrap();
let result = admit(
&mut kernel,
patch_of(vec![declare("act", "act", &[("with", *offered)])]),
);
if offered == own {
assert!(
matches!(result, Ok(SubmitReceipt::Applied(_))),
"{referent:?} refused its own kind"
);
} else {
assert_eq!(
rejected(result),
disagrees("act"),
"{referent:?} admitted {offered:?}"
);
}
}
}
}
#[test]
fn grammar_world_without_speak_refuses_the_kernel_speak_grant() {
let no_speak = GrammarBinding::new(
"aetheria.grammar",
1,
[("convene".to_owned(), verb(RenderPath::Conversation, &[]))],
)
.unwrap();
assert_eq!(
rejected(create(bound(Some(no_speak), "Mute")).1),
not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE)
);
// The kernel entry is held to the signature as well as the kind.
let loud = GrammarBinding::new(
"aetheria.grammar",
1,
[
(
"speak".to_owned(),
verb(
RenderPath::Conversation,
&[("to", GrammarReferentKind::Person)],
),
),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
],
)
.unwrap();
assert_eq!(
rejected(create(bound(Some(loud), "Loud")).1),
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
);
}
#[test]
fn unbound_world_keeps_the_open_catalog() {
let mut world = bound(None, "Open");
world
.patch
.declarations
.push(declare("wave", "wave", &[("at", person())]));
let (_dir, kernel) = create(world);
let mut kernel = kernel.unwrap();
let receipt = admit(
&mut kernel,
patch_of(vec![declare("beacon", "beacon", &[])]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let snapshot = kernel.snapshot().unwrap();
assert!(snapshot.grammar.is_none());
assert!(snapshot.affordances.len() >= 4);
assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none()));
// The state row of a world with no grammar carries no grammar key, so its
// bytes and digests are those of a world written before grammars existed.
let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap();
let keys: std::collections::BTreeMap<String, serde::de::IgnoredAny> =
rmp_serde::from_slice(&encoded).unwrap();
assert!(!keys.contains_key("grammar"));
assert!(keys.contains_key("brief"));
}
#[test]
fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() {
let extra = [("dock", verb(RenderPath::ShipAction, &[]))];
let grammar = grammar_with(&extra);
let identity = grammar.identity();
let (_dir, kernel) = create(bound(Some(grammar), "Reports"));
let mut kernel = kernel.unwrap();
admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap();
let snapshot = kernel.snapshot().unwrap();
assert_eq!(snapshot.grammar, Some(identity));
let path_of = |kind: &str| {
snapshot
.affordances
.iter()
.find(|a| a.entry.kind.0 == kind)
.unwrap_or_else(|| panic!("{kind} is in the catalog"))
.render_path
};
assert_eq!(path_of("dock"), Some(RenderPath::ShipAction));
assert_eq!(path_of("convene"), Some(RenderPath::Conversation));
assert_eq!(path_of("speak"), Some(RenderPath::Conversation));
}
#[test]
fn a_reopened_grammar_world_still_holds_its_grammar() {
let grammar = grammar_with(&[]);
let identity = grammar.identity();
let (directory, kernel) = create(bound(Some(grammar), "Reopened"));
let kernel = kernel.unwrap();
let world_id = kernel.snapshot().unwrap().world_id;
drop(kernel);
let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap();
assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity));
assert_eq!(
rejected(admit(
&mut reopened,
patch_of(vec![declare("wave", "wave", &[])])
)),
not_in_grammar("wave")
);
}
#[test]
fn the_bound_grammar_is_part_of_the_world_state_digest() {
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested"));
let kernel = kernel.unwrap();
let bound_digest = crate::state_digest(&kernel.state).unwrap();
let mut state = kernel.state.clone();
state.grammar = None;
let open_digest = crate::state_digest(&state).unwrap();
state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))]));
let other_digest = crate::state_digest(&state).unwrap();
assert_ne!(bound_digest, open_digest);
assert_ne!(bound_digest, other_digest);
}
#[test]
fn grammar_digest_is_derived_and_order_free() {
let forward = [
("alpha".to_owned(), verb(RenderPath::ShipAction, &[])),
(
"beta".to_owned(),
verb(
RenderPath::Conversation,
&[("to", GrammarReferentKind::Person)],
),
),
("gamma".to_owned(), verb(RenderPath::Conversation, &[])),
];
let mut reversed = forward.clone();
reversed.reverse();
let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap();
let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap();
assert_eq!(a.digest, b.digest);
assert_eq!(a, b);
// Content-derived: each part of a verb moves the digest.
let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| {
let mut verbs = forward.to_vec();
edit(&mut verbs);
GrammarBinding::new("aetheria.grammar", 1, verbs)
.unwrap()
.digest
};
assert_ne!(
a.digest,
changed(&|v| v[0].1.render_path = RenderPath::Conversation)
);
assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into()));
assert_ne!(
a.digest,
changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction)
);
assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into()));
assert_ne!(
a.digest,
changed(&|v| {
v.pop();
})
);
// Role order is part of a verb.
let ordered = |roles: &[(&str, GrammarReferentKind)]| {
GrammarBinding::new(
"g",
1,
[("meet".to_owned(), verb(RenderPath::ShipAction, roles))],
)
.unwrap()
.digest
};
assert_ne!(
ordered(&[
("a", GrammarReferentKind::Person),
("b", GrammarReferentKind::Place)
]),
ordered(&[
("b", GrammarReferentKind::Place),
("a", GrammarReferentKind::Person)
])
);
// The identity reads the same fields back.
let identity = a.identity();
assert_eq!(identity.schema_id, "aetheria.grammar");
assert_eq!(identity.revision, 1);
assert_eq!(identity.digest, a.digest);
}
#[test]
fn a_tampered_grammar_digest_fails_to_deserialise() {
let binding = grammar_with(&[]);
let value = serde_json::to_value(&binding).unwrap();
// Untouched: the round trip holds.
let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap();
assert_eq!(back, binding);
// A digest the verbs do not have.
let mut forged = value.clone();
forged["digest"] = "sha256:00".into();
assert!(serde_json::from_value::<GrammarBinding>(forged.clone()).is_err());
// A verb edited under the digest it already had.
let mut edited = value;
edited["verbs"]["convene"]["render_path"] = "ship_action".into();
assert!(serde_json::from_value::<GrammarBinding>(edited).is_err());
// The named encoding a state row uses refuses it the same way.
let bytes = rmp_serde::to_vec_named(&forged).unwrap();
assert!(rmp_serde::from_slice::<GrammarBinding>(&bytes).is_err());
}
#[test]
fn a_grammar_names_each_verb_once_and_has_a_schema() {
assert_eq!(
GrammarBinding::new("", 1, Vec::new()).unwrap_err(),
GrammarError::EmptySchemaId
);
assert_eq!(
GrammarBinding::new(
"g",
1,
[
("hail".to_owned(), verb(RenderPath::Conversation, &[])),
("hail".to_owned(), verb(RenderPath::ShipAction, &[])),
]
)
.unwrap_err(),
GrammarError::DuplicateVerb
);
}
/// The fixture world was written by the code at base 367e2eb, which has no
/// grammar field anywhere. Reopening it must reproduce the digests that code
/// recorded.
#[test]
fn existing_world_reopens_with_unchanged_digests() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("world.cc");
std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap();
let world_id: WorldId =
serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap();
let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens");
let snapshot = kernel.snapshot().unwrap();
let recorded = include_str!("../../tests/fixtures/base_world.digests");
let mut lines = recorded.lines();
assert_eq!(snapshot.revision.to_string(), lines.next().unwrap());
assert_eq!(snapshot.state_digest, lines.next().unwrap());
assert_eq!(
snapshot.last_commit_digest.as_deref(),
Some(lines.next().unwrap())
);
assert!(snapshot.grammar.is_none());
// Its state still derives the recorded digest from its bytes.
assert_eq!(
crate::state_digest(&kernel.state).unwrap(),
snapshot.state_digest
);
}
fn submit_as(
kernel: &mut WorldKernel,
caller: CallerId,
answers: Option<crate::PatchAnswer>,
patch: WorldPatch,
) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
caller.clone(),
CommandBody::AdmitPatch { answers, patch },
),
&crate::AuthenticatedCaller::fixture(caller),
)
}
/// A bound Draft world whose revision has moved past genesis: one owner patch
/// that adds a dead-end place the elaborator can answer.
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
let mut kernel = kernel.unwrap();
let commons = *kernel.state.entities.keys().next().unwrap();
admit(
&mut kernel,
patch_of(vec![
Declaration::Entity(crate::patch::EntityDeclaration {
handle: DraftHandle::new("dead-end"),
label: "The Unwalked Road".into(),
kind: EntityKind::Place,
container: None,
}),
Declaration::Route(crate::patch::RouteDeclaration {
handle: DraftHandle::new("gate"),
label: "The Field Gate".into(),
from: crate::patch::Ref::Existing(commons),
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
access: crate::patch::AccessKind::Public,
cost: crate::patch::Cost(1),
}),
]),
)
.unwrap();
let dead_end = *kernel
.state
.entities
.iter()
.find(|(_, record)| record.label == "The Unwalked Road")
.map(|(id, _)| id)
.unwrap();
(dir, kernel, dead_end)
}
type Door = (&'static str, CallerId, Option<crate::PatchAnswer>);
/// Each door refuses an outside kind and a wrong role signature, typed, and the
/// refusal leaves the revision where it was. The owner and the consumer declare
/// unanswered only in Draft; Play and the elaborator act in Active.
fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec<Door>) {
for (door, caller, answers) in doors {
let revision = kernel.state.revision;
let kind = submit_as(
kernel,
caller.clone(),
answers.clone(),
patch_of(vec![declare("wave", "wave", &[])]),
);
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
let roles = submit_as(
kernel,
caller,
answers,
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
);
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
assert_eq!(
kernel.state.revision, revision,
"{door} refusal moved the world"
);
}
}
#[test]
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
assert_eq!(kernel.state.revision, 1);
let draft_doors = vec![
("owner", CallerId::Principal(owner()), None),
(
"consumer",
CallerId::System(crate::SystemCapability::Consumer {
consumer: crate::ConsumerId::of_name("grammar-door"),
}),
None,
),
];
refuse_at_each_door(&mut kernel, draft_doors);
crate::tests::activate(&mut kernel);
assert!(
kernel.state.revision > 1,
"the world has moved past genesis"
);
let answer = crate::derive_boundaries(&kernel.state)
.unwrap()
.into_iter()
.find(|boundary| {
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
if *place == dead_end)
})
.expect("the dead end is an unelaborated destination");
let active_doors = vec![
(
"play",
CallerId::System(crate::SystemCapability::Play),
None,
),
(
"elaborator",
CallerId::System(crate::SystemCapability::Elaborator {
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(crate::PatchAnswer::Boundary(answer)),
),
];
refuse_at_each_door(&mut kernel, active_doors);
// The same path admits what the grammar carries, so the refusals above are
// the grammar's and not a closed door.
let receipt = submit_as(
&mut kernel,
CallerId::System(crate::SystemCapability::Play),
None,
patch_of(vec![declare("hail", "hail", &[])]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
}
#[test]
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
let ship = |roles: &[(&str, GrammarReferentKind)]| {
GrammarBinding::new(
"aetheria.grammar",
1,
[
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
],
)
.unwrap()
};
assert_eq!(
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
}]
);
// A ShipAction speak with a role fails on the signature first and is still
// refused: no variant of speak but zero roles, Conversation, is admitted.
assert_eq!(
rejected(
create(bound(
Some(ship(&[("to", GrammarReferentKind::Person)])),
"Loud"
))
.1
),
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
);
// The control: Conversation speak binds.
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
}
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
/// a world with no grammar must not install an entry the bound grammar lacks:
/// the catalog's only writer holds the grammar check itself.
#[test]
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
let extra = [
(
"meet",
verb(
RenderPath::ShipAction,
&[("who", GrammarReferentKind::Person)],
),
),
("hail", verb(RenderPath::Conversation, &[])),
];
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged"));
let kernel = kernel.unwrap();
let mut twin = kernel.state.clone();
twin.grammar = None;
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
crate::patch::resolve_patch(
&twin,
CommandId::new(),
&patch_of(vec![declare("probe", kind, roles)]),
None,
None,
)
.expect("the unbound twin admits any canonical kind")
};
for (label, resolved, admitted) in [
("outside kind", resolve("wave", &[]), false),
("wrong roles", resolve("meet", &[("whom", person())]), false),
("carried verb", resolve("hail", &[]), true),
] {
let mut state = kernel.state.clone();
let catalog = state.affordance_catalog.len();
let result = crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&crate::WorldEffect::PatchAdmitted {
answers: None,
resolved,
},
);
if admitted {
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
} else {
assert!(
matches!(result, Err(KernelError::Invariant(_))),
"{label}: {result:?}"
);
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
}
}
}

View file

@ -0,0 +1,752 @@
//! One record, one subject: admission keeps the import-key mapping injective at
//! every door and at every revision, and a world nobody imported into is
//! byte-for-byte what it was before keys existed.
use crate::patch::{
AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind,
ImportKey, Mismatch, Ref, RouteDeclaration,
};
use crate::tests::{activate, auth_principal, command, creation, owner};
use crate::{
CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey,
KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt,
SystemCapability, WorldKernel, WorldPatch,
};
use std::collections::BTreeSet;
fn key(schema: &str, record: &str) -> ImportKey {
ImportKey::new(schema, record)
}
fn alpha() -> ImportKey {
key("aetheria.ship", "Alpha")
}
/// The fixture genesis with its witness imported from `alpha()`.
fn keyed_creation(title: &str) -> CreateWorld {
let mut world = creation(CommandId::new(), title);
for declaration in &mut world.patch.declarations {
if let Declaration::Subject(subject) = declaration
&& subject.handle == DraftHandle::new("persona")
{
subject.import_key = Some(alpha());
}
}
world
}
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let directory = tempfile::tempdir().unwrap();
let result = WorldKernel::create(
directory.path().join("world.cc"),
world,
&auth_principal(owner()),
)
.map(|(kernel, _)| kernel);
(directory, result)
}
fn keyed_world() -> (tempfile::TempDir, WorldKernel) {
let (dir, kernel) = create(keyed_creation("Keyed"));
(dir, kernel.unwrap())
}
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
match result {
Err(KernelError::PatchRejected(set)) => set,
Err(other) => panic!("expected a patch refusal, got {other:?}"),
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
}
}
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
WorldPatch {
declarations,
operations: Vec::new(),
evidence: Vec::new(),
}
}
fn submit_as(
kernel: &mut WorldKernel,
caller: CallerId,
answers: Option<PatchAnswer>,
patch: WorldPatch,
) -> Result<SubmitReceipt, KernelError> {
let snapshot = kernel.snapshot().unwrap();
kernel.submit(
command(
&snapshot,
CommandId::new(),
caller.clone(),
CommandBody::AdmitPatch { answers, patch },
),
&crate::AuthenticatedCaller::fixture(caller),
)
}
fn commons(kernel: &WorldKernel) -> EntityId {
*kernel.state.entities.keys().next().unwrap()
}
/// A mirror subject: the one shape every door may declare, since it needs no
/// catalog grant.
fn mirror(handle: &str, import_key: Option<ImportKey>, place: EntityId) -> Declaration {
Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new(handle),
label: format!("The {handle}"),
kind: SubjectKind::Person,
controller: NewController::External {
consumer: ConsumerId::of_name("import-key"),
},
affordances: BTreeSet::new(),
position: Some(Ref::Existing(place)),
import_key,
})
}
fn held(handle: &str) -> Mismatch {
Mismatch::ImportKeyHeld {
handle: DraftHandle::new(handle),
}
}
fn duplicate(handle: &str) -> Mismatch {
Mismatch::DuplicateImportKey {
handle: DraftHandle::new(handle),
}
}
fn malformed(handle: &str) -> Mismatch {
Mismatch::MalformedImportKey {
handle: DraftHandle::new(handle),
}
}
fn keys_in(kernel: &WorldKernel) -> Vec<ImportKey> {
kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.filter_map(|subject| subject.import_key)
.collect()
}
#[test]
fn one_patch_cannot_declare_two_subjects_with_one_import_key() {
// At genesis.
let mut world = creation(CommandId::new(), "Twins");
for declaration in &mut world.patch.declarations {
if let Declaration::Subject(subject) = declaration
&& (subject.handle == DraftHandle::new("persona")
|| subject.handle == DraftHandle::new("operator"))
{
subject.import_key = Some(alpha());
}
}
let (_dir, result) = create(world);
assert_eq!(rejected(result), vec![duplicate("operator")]);
// In a later patch, with the key unheld.
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let revision = kernel.state.revision;
let beta = Some(key("aetheria.ship", "Beta"));
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![
mirror("first", beta.clone(), place),
mirror("second", beta, place),
]),
);
assert_eq!(rejected(error), vec![duplicate("second")]);
assert_eq!(kernel.state.revision, revision, "a refusal moved the world");
}
/// The fixture world past genesis but still in Draft: the owner has added an
/// unwalked place the elaborator can later answer.
fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) {
let (dir, mut kernel) = keyed_world();
let place = commons(&kernel);
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![
Declaration::Entity(EntityDeclaration {
handle: DraftHandle::new("dead-end"),
label: "The Unwalked Road".into(),
kind: EntityKind::Place,
container: None,
}),
Declaration::Route(RouteDeclaration {
handle: DraftHandle::new("gate"),
label: "The Field Gate".into(),
from: Ref::Existing(place),
to: Ref::Draft(DraftHandle::new("dead-end")),
access: AccessKind::Public,
cost: Cost(1),
}),
]),
)
.unwrap();
let dead_end = *kernel
.state
.entities
.iter()
.find(|(_, record)| record.label == "The Unwalked Road")
.map(|(id, _)| id)
.unwrap();
(dir, kernel, dead_end)
}
/// Each door names the held key, typed, and the refusal leaves the revision
/// where it was. The owner and the consumer declare in Draft only; Play and the
/// elaborator act in Active.
fn refuse_held_key_at_each_door(
kernel: &mut WorldKernel,
place: EntityId,
doors: Vec<(&str, CallerId, Option<PatchAnswer>)>,
) {
for (door, caller, answers) in doors {
let revision = kernel.state.revision;
let result = submit_as(
kernel,
caller,
answers,
patch_of(vec![mirror("copy", Some(alpha()), place)]),
);
assert_eq!(rejected(result), vec![held("copy")], "{door}");
assert_eq!(
kernel.state.revision, revision,
"{door} refusal moved the world"
);
}
}
fn consumer() -> CallerId {
CallerId::System(SystemCapability::Consumer {
consumer: ConsumerId::of_name("import-key"),
})
}
#[test]
fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() {
let (_dir, mut kernel, dead_end) = advanced_keyed_world();
assert!(kernel.state.revision > 0, "the world has moved past genesis");
refuse_held_key_at_each_door(
&mut kernel,
dead_end,
vec![
("owner", CallerId::Principal(owner()), None),
("consumer", consumer(), None),
],
);
activate(&mut kernel);
let answer = crate::derive_boundaries(&kernel.state)
.unwrap()
.into_iter()
.find(|boundary| {
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
if *place == dead_end)
})
.expect("the dead end is an unelaborated destination");
refuse_held_key_at_each_door(
&mut kernel,
dead_end,
vec![
("play", CallerId::System(SystemCapability::Play), None),
(
"elaborator",
CallerId::System(SystemCapability::Elaborator {
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(PatchAnswer::Boundary(answer.clone())),
),
],
);
// The same doors admit an unheld key, so the refusals are the key's; and
// what a door admitted is held against every later door.
let fresh = key("aetheria.ship", "Gamma");
let receipt = submit_as(
&mut kernel,
CallerId::System(SystemCapability::Elaborator {
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
}),
Some(PatchAnswer::Boundary(answer)),
patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]),
)
.unwrap();
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let again = submit_as(
&mut kernel,
CallerId::System(SystemCapability::Play),
None,
patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]),
);
assert_eq!(rejected(again), vec![held("fresh-again")]);
}
#[test]
fn a_held_import_key_is_refused_in_a_later_patch() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let revision = kernel.state.revision;
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("again", Some(alpha()), place)]),
);
assert_eq!(rejected(error), vec![held("again")]);
assert_eq!(kernel.state.revision, revision);
assert_eq!(keys_in(&kernel), vec![alpha()]);
}
#[test]
fn a_retired_subjects_import_key_is_never_reissued() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let witness = kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.find(|subject| subject.import_key.is_some())
.unwrap()
.id;
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
WorldPatch {
declarations: Vec::new(),
operations: vec![ComponentOp::Retire {
subject: Ref::Existing(witness),
}],
evidence: Vec::new(),
},
)
.unwrap();
let retired = kernel
.snapshot()
.unwrap()
.subjects
.into_iter()
.find(|subject| subject.id == witness)
.unwrap();
assert!(retired.retired, "the witness is retired");
assert_eq!(
retired.import_key,
Some(alpha()),
"a retired subject keeps its key"
);
let error = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("reborn", Some(alpha()), place)]),
);
assert_eq!(rejected(error), vec![held("reborn")]);
}
#[test]
fn keys_that_differ_in_any_way_are_distinct_records() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
// Same length, case, prefix, the same concatenation split elsewhere, and
// the parts swapped: none is the held key.
let forgeries = [
key("aetheria.ship", "Alphb"),
key("aetheria.ship", "alpha"),
key("aetheria.ship", "Alph"),
key("aetheria.ship", "Alpha2"),
key("aetheria.shipA", "lpha"),
key("aetheria.sh", "ipAlpha"),
key("Aetheria.ship", "Alpha"),
key("Alpha", "aetheria.ship"),
key("aetheria.ship2", "Alpha"),
];
let declarations = forgeries
.iter()
.enumerate()
.map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place))
.collect();
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(declarations),
)
.expect("nine distinct keys beside the held one are admitted together");
let held_now: BTreeSet<ImportKey> = keys_in(&kernel).into_iter().collect();
assert_eq!(held_now.len(), forgeries.len() + 1);
// And each is then held exactly: repeating any one is refused.
for (index, forged) in forgeries.iter().enumerate() {
let again = submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("repeat", Some(forged.clone()), place)]),
);
assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}");
}
}
#[test]
fn a_malformed_import_key_is_refused_without_echoing_it() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let long = "x".repeat(201);
let wide = "é".repeat(101);
let cases = [
key("", "Delta"),
key("aetheria.ship", ""),
key("aetheria ship", "Delta"),
key("aetheria.ship", "Del\tta"),
key("aetheria.ship", "Del\u{a0}ta"),
key("aetheria.ship", " Delta"),
key("aetheria.ship", "Delta\n"),
key(&long, "Delta"),
key("aetheria.ship", &long),
key("aetheria.ship", &wide),
key(&wide, "Delta"),
];
for (index, bad) in cases.iter().enumerate() {
let set = rejected(submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror("bad", Some(bad.clone()), place)]),
));
assert_eq!(set, vec![malformed("bad")], "case {index}");
let spelled = format!("{set:?}");
assert!(
!spelled.contains("Delta") && !spelled.contains("aetheria"),
"case {index} echoed the key"
);
}
// The limit is 200 bytes per part, counted in bytes.
let edge = "é".repeat(100);
let at_limit = [
key(&"x".repeat(200), &edge),
key("aetheria.ship", &"y".repeat(200)),
];
let declarations = at_limit
.iter()
.enumerate()
.map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place))
.collect();
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(declarations),
)
.expect("keys of exactly 200 bytes per part are admitted");
}
#[test]
fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() {
let (_dir, kernel) = keyed_world();
let place = commons(&kernel);
let resolve = |handle: &str| {
crate::patch::resolve_patch(
&kernel.state,
CommandId::new(),
&patch_of(vec![mirror(
handle,
Some(key("aetheria.ship", "Omega")),
place,
)]),
None,
None,
)
.expect("the key is unheld at the revision both were resolved against")
};
let (first, second) = (resolve("first"), resolve("second"));
let mut state = kernel.state.clone();
let before = state.subjects.len();
let effect = |resolved| crate::WorldEffect::PatchAdmitted {
answers: None,
resolved,
};
crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&effect(first),
)
.unwrap();
assert_eq!(state.subjects.len(), before + 1);
let result = crate::apply_effect(
&mut state,
CommandId::new(),
&CallerId::Principal(owner()),
&effect(second),
);
assert!(
matches!(result, Err(KernelError::Invariant(_))),
"{result:?}"
);
assert_eq!(
state.subjects.len(),
before + 1,
"the second subject was installed"
);
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|window| window == needle)
}
#[test]
fn keyless_world_digests_unchanged() {
// A subject nobody imported serialises without the field at all, so every
// digest written before keys existed is the digest of the same bytes.
let (_dir, kernel) = create(creation(CommandId::new(), "Keyless"));
let kernel = kernel.unwrap();
let state = rmp_serde::to_vec_named(&kernel.state).unwrap();
assert!(
!contains(&state, b"import_key"),
"a keyless world grew a field"
);
// A keyed world carries it, round-trips, and digests differently.
let (_dir, keyed) = keyed_world();
let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap();
assert!(contains(&keyed_state, b"import_key"));
let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap();
assert_eq!(
crate::state_digest(&back).unwrap(),
crate::state_digest(&keyed.state).unwrap()
);
assert_ne!(
crate::state_digest(&keyed.state).unwrap(),
crate::state_digest(&kernel.state).unwrap()
);
// And a pre-key state deserialises with every key absent.
let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap();
assert!(stripped.subjects.values().all(|s| s.import_key.is_none()));
}
#[test]
fn a_keyed_world_reopens_with_its_keys_and_digests() {
let (dir, kernel) = keyed_world();
let snapshot = kernel.snapshot().unwrap();
let world_id = snapshot.world_id;
drop(kernel);
let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap();
let again = reopened.snapshot().unwrap();
assert_eq!(again.state_digest, snapshot.state_digest);
let keys = keys_in(&reopened);
assert_eq!(keys, vec![alpha()]);
// A reader of the snapshot gets both parts back, separately.
assert_eq!(keys[0].schema(), "aetheria.ship");
assert_eq!(keys[0].record(), "Alpha");
}
#[test]
fn a_record_key_is_one_canonical_spelling() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let nfc = "Caf\u{e9}";
let nfd = "Cafe\u{301}";
let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.ship", record)),
place,
)]),
)
};
submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted");
let refused = [
nfd,
"Alpha\u{200b}",
"Alpha\u{0}",
"\u{feff}Alpha",
"Alpha\u{202e}",
"Alpha\u{7}",
"Del ta",
"Del\tta",
"Del\u{a0}ta",
"Del\u{2003}ta",
"Del\nta",
// The violation sits deep in a long, real-shaped path, past any prefix.
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}",
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart",
];
for (index, record) in refused.iter().enumerate() {
let set = rejected(submit(&mut kernel, "bad", record));
assert_eq!(set, vec![malformed("bad")], "spelling {index}");
assert!(
!format!("{set:?}").contains("Alpha"),
"spelling {index} echoed the key"
);
}
assert_eq!(
keys_in(&kernel).len(),
2,
"only the genesis key and the NFC key are held"
);
}
#[test]
fn a_lore_page_path_with_spaces_is_a_record_key() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart";
let declare = |kernel: &mut WorldKernel, handle: &str| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.lore", record)),
place,
)]),
)
};
declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted");
assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]);
}
#[test]
fn a_loaded_state_with_a_bad_import_key_is_refused() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
submit_as(
&mut kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
"beta",
Some(key("aetheria.ship", "Beta")),
place,
)]),
)
.unwrap();
assert!(crate::journal::verify_state_shape(&kernel.state).is_ok());
let corrupt = |edit: &dyn Fn(&mut ImportKey)| {
let mut state = kernel.state.clone();
for subject in state.subjects.values_mut() {
if let Some(held) = subject.import_key.as_mut() {
edit(held);
}
}
crate::journal::verify_state_shape(&state)
};
let shared = corrupt(&|held| *held = alpha());
assert!(
matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")),
"two subjects sharing one key: {shared:?}"
);
assert!(
corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(),
"a key with edge spaces must not load"
);
assert!(
corrupt(&|held| *held = key("", "")).is_err(),
"an empty key must not load"
);
assert!(
corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(),
"a key with a format character must not load"
);
}
/// The admission backstop, reached with an effect resolution never produced: a
/// resolved patch whose two subjects carry one key. The control is the same
/// effect with distinct keys, admitted by the same function.
#[test]
fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() {
let (_dir, kernel) = keyed_world();
let place = commons(&kernel);
let resolve = || {
crate::patch::resolve_patch(
&kernel.state,
CommandId::new(),
&patch_of(vec![
mirror("first", Some(key("aetheria.ship", "Beta")), place),
mirror("second", Some(key("aetheria.ship", "Gamma")), place),
]),
None,
None,
)
.expect("two distinct keys resolve")
};
let resolves_to = crate::resolution_revision(&kernel.state).unwrap();
let mut control = kernel.state.clone();
crate::admit_resolved(&mut control, &resolve(), resolves_to)
.expect("distinct keys are admitted");
let mut twinned = resolve();
assert_eq!(twinned.subjects.len(), 2);
twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone();
let mut state = kernel.state.clone();
let error = crate::admit_resolved(&mut state, &twinned, resolves_to)
.expect_err("one key on two resolved subjects is refused");
assert!(
matches!(&error, KernelError::Invariant(why) if why.contains("import key")),
"{error:?}"
);
assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key");
}
/// A decomposable character at every position of a real-shaped key: the NFD
/// spelling is refused wherever it sits, the NFC spelling is admitted, so the
/// rule cannot be a prefix, suffix or first-character check.
#[test]
fn a_decomposable_character_is_refused_in_nfd_at_every_position() {
let (_dir, mut kernel) = keyed_world();
let place = commons(&kernel);
let template: Vec<char> = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"
.chars()
.collect();
let submit = |kernel: &mut WorldKernel, handle: &str, record: String| {
submit_as(
kernel,
CallerId::Principal(owner()),
None,
patch_of(vec![mirror(
handle,
Some(key("aetheria.lore", &record)),
place,
)]),
)
};
let spelled = |position: usize, inserted: &str| {
let mut record: String = template[..position].iter().collect();
record.push_str(inserted);
record.extend(&template[position..]);
record
};
for position in 0..=template.len() {
let nfd = spelled(position, "e\u{301}");
assert!(
!unicode_normalization::is_nfc(&nfd),
"position {position} is not NFD"
);
let set = rejected(submit(&mut kernel, "bad", nfd));
assert_eq!(set, vec![malformed("bad")], "NFD at position {position}");
}
for position in 0..=template.len() {
let nfc = spelled(position, "\u{e9}");
submit(&mut kernel, &format!("good{position}"), nfc)
.unwrap_or_else(|_| panic!("NFC at position {position} was refused"));
}
assert_eq!(
keys_in(&kernel).len(),
1 + template.len() + 1,
"the genesis key and one NFC key per position are held, no NFD key"
);
}

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()
@ -826,6 +831,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError>
"one subject holds two overlapping jurisdictions of one kind".into(),
));
}
let mut declared_keys = BTreeSet::new();
let no_held = BTreeSet::new();
for subject in state.subjects.values() {
if let Some(key) = &subject.import_key
&& super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some()
{
return Err(JournalError::Corrupt(
"subject import key is malformed or held by two subjects".into(),
));
}
}
let mut controller_ids = BTreeSet::new();
for (subject_id, subject) in &state.subjects {
if !super::patch::is_canonical_text(&subject.label) {
@ -1107,8 +1123,10 @@ mod tests {
},
affordances: kernel_speak_grant(),
position: None,
import_key: None,
}),
scale_intent: WorldScaleIntentRef::default(),
grammar: None,
};
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
let snapshot = kernel.snapshot().unwrap();
@ -1190,8 +1208,10 @@ mod tests {
},
affordances: kernel_speak_grant(),
position: None,
import_key: None,
}),
scale_intent: WorldScaleIntentRef::default(),
grammar: None,
};
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
let snapshot = kernel.snapshot().unwrap();
@ -1220,6 +1240,7 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak_entry(&kernel)]),
position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))),
import_key: None,
}),
],
operations: Vec::new(),
@ -1755,8 +1776,10 @@ mod tests {
controller: NewController::Human { principal: owner },
affordances: kernel_speak_grant(),
position: None,
import_key: None,
}),
scale_intent: WorldScaleIntentRef::default(),
grammar: None,
};
let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
let mut forged_head = kernel.state.clone();

View file

@ -56,11 +56,15 @@
//! ```
mod action;
mod aetheria;
mod clock;
mod consumer;
mod controllers;
mod cover;
mod elaboration;
mod grammar;
#[cfg(test)]
mod import_key_tests;
mod journal;
mod lens;
mod local_inference;
@ -70,6 +74,7 @@ mod sdk_inference;
mod table;
mod tool_schema;
mod vault;
mod verse;
pub(crate) use action::ActionMismatch;
pub(crate) use clock::Motion;
@ -80,23 +85,31 @@ pub use consumer::{
};
pub use controllers::{
CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels,
ControllerOpenError,
ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork,
ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError,
ControllerWorkWrite,
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput,
InferencePort,
InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle,
TracingInferencePort, open_controller_work, open_inference,
};
pub(crate) use cover::{CellId, Constituent, Resolution};
pub use aetheria::{
AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog,
};
pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover};
pub use elaboration::{
ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery,
EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row,
};
pub use grammar::{
GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb,
RenderPath,
};
pub use lens::{Lens, LensWeights};
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding};
#[cfg(feature = "test-support")]
pub use local_inference::open_local_port_with_timeout;
pub(crate) use mailbox::ElaborationPort;
@ -113,6 +126,7 @@ pub(crate) use patch::{
PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach,
SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef,
};
pub use patch::ImportKey;
#[cfg(test)]
use patch::{
AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp,
@ -130,6 +144,13 @@ pub use table::{
id_text, id_text_matches, table_view,
};
pub use vault::{VaultError, VaultEvidenceSource};
pub use verse::{
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};
use chrono::{DateTime, Utc};
@ -425,6 +446,10 @@ struct CreateWorld {
/// The world's flavor as genesis writes it. `SetLensWeights` may replace
/// it later; this command carries what the world began with.
lens_weights: LensWeights,
/// The grammar the world is bound to for its whole life, or none for an
/// open-catalog world. Authored here and nowhere else: genesis installs it.
#[serde(default, skip_serializing_if = "Option::is_none")]
grammar: Option<GrammarBinding>,
}
/// Unattributed creation intent. World ingress derives ownership, controller
@ -709,6 +734,10 @@ pub enum CommandBody {
struct SubjectState {
label: String,
kind: SubjectKind,
/// Written only when a subject declaration is resolved; absent from a
/// subject nobody imported, so its digests are unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
import_key: Option<ImportKey>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -828,6 +857,11 @@ struct WorldState {
/// Authored at creation and never mutated; the one prose the world says
/// about itself, projected as guidance to every lane.
brief: String,
/// Bound by genesis and never written again; absent from a world with no
/// grammar, so such a world's state and digests are what they were before
/// grammars existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
grammar: Option<GrammarBinding>,
draft_approvals: BTreeSet<PrincipalId>,
subjects: BTreeMap<SubjectId, SubjectState>,
entities: BTreeMap<EntityId, EntityRecord>,
@ -993,6 +1027,8 @@ pub struct SubjectSnapshot {
pub id: SubjectId,
pub label: String,
pub kind: SubjectKind,
/// The Aetheria record this subject was imported from, if it was.
pub import_key: Option<ImportKey>,
/// Absent for an externally controlled mirror, which has no controller and
/// no turn. Every reader fails closed on `None`; none gets a default.
pub(crate) controller_id: Option<ControllerId>,
@ -1135,6 +1171,9 @@ pub(crate) struct ForumSnapshot {
pub struct AffordanceSnapshot {
pub id: AffordanceId,
pub entry: Affordance,
/// Derived from the world's grammar by this entry's kind; none when the
/// world has no grammar.
pub render_path: Option<RenderPath>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -1197,6 +1236,8 @@ pub struct WorldSnapshot {
pub owner: PrincipalId,
pub title: String,
pub brief: String,
/// The bound grammar's identity; none for a world with an open catalog.
pub grammar: Option<GrammarIdentity>,
pub draft_approvals: BTreeSet<PrincipalId>,
pub required_approvers: BTreeSet<PrincipalId>,
pub subjects: Vec<SubjectSnapshot>,
@ -1238,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)]
@ -1425,7 +1481,12 @@ fn prepare_creation(
// derived ID, so it is minted before resolution rather than by it.
let world_id = WorldId::issue();
let resolved = patch::resolve_patch(
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
&WorldState::empty(
world_id,
input.owner.clone(),
title.clone(),
input.grammar.clone(),
),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -1523,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
@ -1896,7 +1993,12 @@ impl WorldState {
/// The world before any structure. Genesis resolves its own patch against
/// this value, so the genesis lane and `AdmitPatch` share one resolver
/// signature instead of hand-passing empty partitions.
fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self {
fn empty(
world_id: WorldId,
owner: PrincipalId,
title: String,
grammar: Option<GrammarBinding>,
) -> Self {
Self {
schema: STATE_SCHEMA.into(),
world_id,
@ -1905,6 +2007,7 @@ impl WorldState {
owner,
title,
brief: String::new(),
grammar,
draft_approvals: BTreeSet::new(),
subjects: BTreeMap::new(),
entities: BTreeMap::new(),
@ -1962,7 +2065,7 @@ impl WorldState {
// The same re-derive-and-compare that `apply_committed_command` runs for
// every other command. Deterministic allocation is what lets one
// equality replace a field-by-field binding zip.
let mut state = Self::empty(world_id, owner.clone(), title.clone());
let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone());
state.brief = brief.clone();
let expected = patch::resolve_patch(
&state,
@ -2071,6 +2174,18 @@ fn admit_resolved(
.filter_map(ControllerAssignment::id)
.collect();
for entry in &resolved.affordances {
// The catalog's only writer holds the same grammar check the resolver
// runs, so an effect resolved elsewhere cannot install an entry the
// bound grammar refuses.
if state.grammar.as_ref().is_some_and(|grammar| {
grammar
.refusal(&entry.affordance.kind, &entry.affordance.roles)
.is_some()
}) {
return Err(KernelError::Invariant(
"admitted affordance is outside the bound grammar".into(),
));
}
if state
.affordance_catalog
.insert(entry.affordance_id, entry.affordance.clone())
@ -2081,6 +2196,21 @@ fn admit_resolved(
));
}
}
// The subjects map's only writer holds the same injectivity the resolver
// checks, decided by the same function.
{
let held = patch::held_import_keys(state.subjects.values());
let mut declared = BTreeSet::new();
for subject in &resolved.subjects {
if let Some(key) = &subject.subject.import_key
&& patch::import_key_fault(key, &held, &mut declared).is_some()
{
return Err(KernelError::Invariant(
"admitted subject's import key is malformed, held or repeated".into(),
));
}
}
}
for subject in &resolved.subjects {
let scope = DecisionScope {
subject_id: subject.subject_id,
@ -3665,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()
@ -3674,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,
@ -3686,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()
@ -3964,6 +4128,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
id: *subject_id,
label: subject.label.clone(),
kind: subject.kind,
import_key: subject.import_key.clone(),
controller_id: controller.id(),
controller_mode: controller.mode(),
human_controller: controller.human_principal().cloned(),
@ -4079,6 +4244,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
owner: state.owner.clone(),
title: state.title.clone(),
brief: state.brief.clone(),
grammar: state.grammar.as_ref().map(GrammarBinding::identity),
draft_approvals: state.draft_approvals.clone(),
required_approvers: required_approvers(state),
subjects,
@ -4088,6 +4254,11 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
.map(|(affordance_id, entry)| AffordanceSnapshot {
id: *affordance_id,
entry: entry.clone(),
render_path: state
.grammar
.as_ref()
.and_then(|grammar| grammar.verb(&entry.kind.0))
.map(|verb| verb.render_path),
})
.collect(),
places,
@ -5438,6 +5609,7 @@ mod tests {
// The fixture world is one room. Co-located speech needs a place to
// fill, so every fixture subject stands in the commons.
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
import_key: None,
})
}
@ -5540,11 +5712,13 @@ mod tests {
.chain(std::iter::once(Ref::Draft(DraftHandle::new("convene"))))
.collect(),
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
import_key: None,
}),
],
},
scale_intent: WorldScaleIntentRef::default(),
lens_weights: stock_weights(),
grammar: None,
}
}
@ -5689,6 +5863,7 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Draft(DraftHandle::new("yard"))),
import_key: None,
}),
],
// A route that should start closed is declared and closed in one
@ -5794,6 +5969,7 @@ mod tests {
}))
.collect(),
position: Some(Ref::Existing(place)),
import_key: None,
})
}
@ -6314,6 +6490,7 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: civic_grants(handles, &speak),
position: Some(place),
import_key: None,
})
};
let declarations = civic_affordances()
@ -6370,6 +6547,7 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("hall"))),
import_key: None,
}),
Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new("reeve"),
@ -6381,6 +6559,7 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("chamber"))),
import_key: None,
}),
person(
"farmer",
@ -6404,6 +6583,7 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: civic_grants(&["levy", "petition"], &speak),
position: None,
import_key: None,
}),
])
.collect();
@ -6567,6 +6747,7 @@ mod tests {
Ref::Draft(DraftHandle::new("recant")),
]),
position: place.map(|place| Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
};
submit_owner(
@ -7835,6 +8016,7 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.road)),
import_key: None,
}),
],
operations: Vec::new(),
@ -8853,6 +9035,7 @@ mod custody_tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.yard)),
import_key: None,
}),
],
operations: vec![ComponentOp::Admit {
@ -9512,6 +9695,7 @@ mod custody_tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.yard)),
import_key: None,
}),
],
operations: vec![admit(u64::MAX), admit(1)],
@ -11438,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 {
@ -11447,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.
@ -11504,6 +11691,7 @@ mod witness_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]),
position: Some(Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
}
@ -12617,6 +12805,7 @@ mod witness_tests {
// Every non-mirror subject must hold one.
affordances: BTreeSet::from([Ref::Existing(beacon)]),
position: position.map(Ref::Existing),
import_key: None,
}),
],
operations: vec![witness(
@ -12981,6 +13170,7 @@ mod clock_tests {
Ref::Draft(DraftHandle::new("threaten")),
]),
position: Some(Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
};
let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle));
@ -15162,7 +15352,7 @@ mod clock_tests {
}));
let title = normalize_title(&input.title).unwrap();
let resolved = patch::resolve_patch(
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -15191,7 +15381,7 @@ mod clock_tests {
});
let title = normalize_title(&input.title).unwrap();
let resolved = patch::resolve_patch(
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -17034,6 +17224,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17100,6 +17291,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17177,6 +17369,7 @@ mod clock_tests {
},
affordances: BTreeSet::new(),
position: Some(Ref::Existing(commons)),
import_key: None,
})],
operations: Vec::new(),
evidence: Vec::new(),
@ -17270,6 +17463,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: vec![ComponentOp::Mint {
@ -17512,6 +17706,7 @@ mod clock_tests {
// the boundary its own answer claims: an occupied
// place is no longer unelaborated.
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: Vec::new(),
evidence: Vec::new(),
@ -17690,6 +17885,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::Retire {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -17799,6 +17995,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(commons)),
import_key: None,
}),
],
operations: vec![ComponentOp::GrantAffordance {
@ -18013,6 +18210,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::GrantAffordance {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -18431,6 +18629,7 @@ mod clock_tests {
Ref::Existing(clockwork.threaten),
]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::RevokeAffordance {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -18879,6 +19078,7 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak.clone()]),
position: None,
import_key: None,
}),
Declaration::Channel(ChannelDeclaration {
handle: DraftHandle::new("wire"),

View file

@ -1,5 +1,8 @@
//! A local OpenAI-compatible inference port, for a loopback model server this
//! process does not authenticate to and does not need a credential for.
//! An OpenAI-compatible inference port with two bindings. The local binding
//! reaches a loopback model server this process does not authenticate to and
//! needs no credential for. The hosted binding reaches an https endpoint
//! (a cheap hosted model for the Aetheria verse) with a bearer key read once
//! from a file.
//!
//! This module owns one authority: lowering a prepared request to one
//! `POST /v1/chat/completions` call and lifting the reply back into the same
@ -10,8 +13,8 @@
use super::controllers::{
ControllerOpenError, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceOutput, InferencePort,
InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, call_id_is_valid,
tool_name_is_valid, unix_ms,
InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, TokenUsage,
call_id_is_valid, tool_name_is_valid, unix_ms,
};
use async_trait::async_trait;
use codex_connector::CodexInputItem;
@ -19,7 +22,9 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use zeroize::Zeroizing;
/// The model-name prefix that names the local transport when nothing
/// configures one.
@ -33,6 +38,41 @@ pub struct LocalBinding {
pub caller_runtime_id: String,
}
/// The model-name prefix that names the hosted transport when nothing
/// configures one.
pub const DEFAULT_HOSTED_MODEL_PREFIX: &str = "hosted/";
/// Everything the hosted binding needs to open. `base_url` is the provider's
/// origin (`https://api.deepseek.com`); the port appends
/// `/v1/chat/completions`. The key is read from `key_path` once, at open.
pub struct HostedBinding {
pub base_url: String,
pub key_path: PathBuf,
pub model_prefix: String,
pub caller_runtime_id: String,
}
/// Where the port sends its one request. The credential lives only in the
/// `Hosted` variant, so the loopback binding cannot carry one.
enum Endpoint {
Loopback(SocketAddr),
Hosted {
base_url: String,
key: Zeroizing<String>,
},
}
impl Endpoint {
fn url(&self) -> String {
match self {
Self::Loopback(address) => format!("http://{address}/v1/chat/completions"),
Self::Hosted { base_url, .. } => {
format!("{}/v1/chat/completions", base_url.trim_end_matches('/'))
}
}
}
}
const LOCAL_RECEIPT_SCHEMA: &str = "ghostlight.local_inference_receipt.v1";
/// The identity half is computed here from the invocation, exactly as the SDK
@ -113,22 +153,47 @@ struct LocalChatFunctionCall {
arguments: String,
}
#[derive(Debug, Deserialize)]
struct LocalChatUsage {
#[serde(default)]
prompt_tokens: u64,
#[serde(default)]
completion_tokens: u64,
/// A count may be absent from the wire (no usage reported, never a zero), but
/// a count that is present must be a number: an explicit null is an integrity
/// violation, as it is on the local lane before the hosted lane existed.
fn present_count<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
u64::deserialize(deserializer).map(Some)
}
/// The local OpenAI-compatible backend behind Ghostlight's inference seam.
/// Every count is optional on the wire: a reply missing the prompt or
/// completion count reports no usage rather than a zero.
#[derive(Debug, Deserialize)]
struct LocalChatUsage {
#[serde(default, deserialize_with = "present_count")]
prompt_tokens: Option<u64>,
#[serde(default, deserialize_with = "present_count")]
completion_tokens: Option<u64>,
/// DeepSeek's cache-hit count, part of `prompt_tokens`.
#[serde(default)]
prompt_cache_hit_tokens: Option<u64>,
}
impl LocalChatUsage {
fn reported(&self) -> Option<TokenUsage> {
Some(TokenUsage {
prompt: self.prompt_tokens?,
completion: self.completion_tokens?,
cached_prompt: self.prompt_cache_hit_tokens,
})
}
}
/// The OpenAI-compatible backend behind Ghostlight's inference seam.
/// Unlike the SDK port, it runs no query loop: one prepared request lowers to
/// one HTTP call, and any tool call in the reply comes back inert for Rust's
/// own evaluator to drive the next round, exactly as the connector lane
/// already does.
pub(super) struct LocalInferencePort {
client: reqwest::Client,
endpoint: SocketAddr,
endpoint: Endpoint,
prefix: String,
caller_runtime_id: String,
/// Read only by the tests that pin which timeout a constructor resolves
@ -138,16 +203,17 @@ pub(super) struct LocalInferencePort {
}
impl LocalInferencePort {
/// The loopback check happens here, in the constructor, so a non-loopback
/// endpoint cannot reach this port through any path that builds one —
/// not only the one `open_inference` exercises today. The client is
/// built with `.no_proxy()` so an environment proxy variable can never
/// carry this request, or a `proxy-authorization` header derived from a
/// proxy URL's userinfo, off this loopback endpoint; and with redirects
/// disabled, so a 3xx reply cannot re-POST the request's contents to a
/// second endpoint this port never opened.
/// The endpoint checks happen here, in the constructor, so neither a
/// non-loopback local endpoint nor a non-https hosted one can reach this
/// port through any path that builds one — not only the one
/// `open_inference` exercises today. The client is built with
/// `.no_proxy()` so an environment proxy variable can never carry this
/// request, or a `proxy-authorization` header derived from a proxy URL's
/// userinfo, off the endpoint; and with redirects disabled, so a 3xx
/// reply cannot re-POST the request's contents (or the hosted bearer) to
/// a second endpoint this port never opened.
fn new(
endpoint: SocketAddr,
endpoint: Endpoint,
prefix: impl Into<String>,
caller_runtime_id: impl Into<String>,
) -> Result<Self, ControllerOpenError> {
@ -159,13 +225,34 @@ impl LocalInferencePort {
/// owner; the other callers are tests that reach the timeout path without
/// waiting `RESPONSE_TIMEOUT` out.
fn with_timeout(
endpoint: SocketAddr,
endpoint: Endpoint,
prefix: impl Into<String>,
caller_runtime_id: impl Into<String>,
timeout: std::time::Duration,
) -> Result<Self, ControllerOpenError> {
if !endpoint.ip().is_loopback() {
return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint });
Self::build(endpoint, prefix, caller_runtime_id, timeout, false)
}
/// The one builder. `allow_plain_http` is false on every production path;
/// the tests' hosted seam passes true so a hosted endpoint can be a
/// loopback responder with no TLS.
fn build(
endpoint: Endpoint,
prefix: impl Into<String>,
caller_runtime_id: impl Into<String>,
timeout: std::time::Duration,
allow_plain_http: bool,
) -> Result<Self, ControllerOpenError> {
match &endpoint {
Endpoint::Loopback(address) if !address.ip().is_loopback() => {
return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint: *address });
}
Endpoint::Hosted { base_url, .. }
if !allow_plain_http && !base_url.starts_with("https://") =>
{
return Err(ControllerOpenError::HostedEndpointNotHttps);
}
_ => {}
}
Ok(Self {
client: reqwest::Client::builder()
@ -183,13 +270,11 @@ impl LocalInferencePort {
}
async fn send(&self, body: Value) -> Result<LocalChatResponse, InferenceFault> {
let response = self
.client
.post(format!("http://{}/v1/chat/completions", self.endpoint))
.json(&body)
.send()
.await
.map_err(send_fault)?;
let mut request = self.client.post(self.endpoint.url()).json(&body);
if let Endpoint::Hosted { key, .. } = &self.endpoint {
request = request.bearer_auth(key.as_str());
}
let response = request.send().await.map_err(send_fault)?;
let status = response.status();
if !status.is_success() {
let detail = "the local inference endpoint returned a non-success status";
@ -240,8 +325,9 @@ fn bad_reply() -> InferenceFault {
/// Lowers one prepared request to the OpenAI chat-completions body: the
/// system message is the request's instructions, every input item maps to
/// one message in order, and every offered tool is declared `strict`. No
/// header but content type is ever set on the request this builds.
/// one message in order, and every offered tool is declared `strict`. The
/// body carries no credential; the hosted binding's bearer is the one header
/// `send` adds.
fn lower_request(prepared: &PreparedInference, prefix: &str) -> Result<Value, InferenceFault> {
let request = &prepared.invocation.request;
let model = request.model.strip_prefix(prefix).unwrap_or(&request.model);
@ -345,6 +431,7 @@ fn assemble_output(
arguments: call.function.arguments,
});
}
let usage = response.usage.as_ref().and_then(LocalChatUsage::reported);
let receipt = LocalInferenceReceipt {
schema_id: LOCAL_RECEIPT_SCHEMA.to_owned(),
request_id: request.request_id.clone(),
@ -358,20 +445,21 @@ fn assemble_output(
prompt_tokens: response
.usage
.as_ref()
.map(|usage| usage.prompt_tokens)
.and_then(|usage| usage.prompt_tokens)
.unwrap_or_default(),
completion_tokens: response
.usage
.as_ref()
.map(|usage| usage.completion_tokens)
.and_then(|usage| usage.completion_tokens)
.unwrap_or_default(),
};
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
.map_err(|_| InferenceFault::new("the local inference receipt could not be encoded"))?;
Ok(InferenceOutput::new(
events,
format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
))
let output = InferenceOutput::new(events, format!("sha256:{:x}", Sha256::digest(&receipt_bytes)));
Ok(match usage {
Some(usage) => output.with_usage(usage),
None => output,
})
}
#[async_trait]
@ -421,7 +509,7 @@ pub fn open_local_port_with_timeout(
timeout: std::time::Duration,
) -> Result<Arc<dyn InferencePort>, ControllerOpenError> {
Ok(Arc::new(LocalInferencePort::with_timeout(
binding.endpoint,
Endpoint::Loopback(binding.endpoint),
binding.model_prefix,
binding.caller_runtime_id,
timeout,
@ -429,7 +517,47 @@ pub fn open_local_port_with_timeout(
}
fn local_port(binding: LocalBinding) -> Result<LocalInferencePort, ControllerOpenError> {
LocalInferencePort::new(binding.endpoint, binding.model_prefix, binding.caller_runtime_id)
LocalInferencePort::new(
Endpoint::Loopback(binding.endpoint),
binding.model_prefix,
binding.caller_runtime_id,
)
}
/// Builds the hosted port from its binding: reads the key file once, then the
/// constructor checks the URL is https. Every failure names the field's
/// error and never the path, the URL or the key.
pub(super) fn open_hosted_port(
binding: HostedBinding,
) -> Result<Arc<dyn InferencePort>, ControllerOpenError> {
Ok(Arc::new(hosted_port(binding)?))
}
fn hosted_port(binding: HostedBinding) -> Result<LocalInferencePort, ControllerOpenError> {
let key = read_key_file(&binding.key_path)?;
LocalInferencePort::new(
Endpoint::Hosted {
base_url: binding.base_url,
key,
},
binding.model_prefix,
binding.caller_runtime_id,
)
}
/// The key file's whole content, minus one trailing newline run. Empty, not
/// UTF-8, or padded with whitespace is unreadable.
fn read_key_file(path: &std::path::Path) -> Result<Zeroizing<String>, ControllerOpenError> {
let bytes = Zeroizing::new(std::fs::read(path).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?);
let raw = std::str::from_utf8(bytes.as_slice()).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?;
let key = raw.trim_end_matches(['\r', '\n']);
// A bearer token is one run of visible ASCII: anything else (padding, an
// interior newline, a control character) cannot ride an Authorization
// header, so it is refused here and not at the first send.
if key.is_empty() || !key.bytes().all(|byte| byte.is_ascii_graphic()) {
return Err(ControllerOpenError::HostedKeyUnreadable);
}
Ok(Zeroizing::new(key.to_owned()))
}
#[cfg(test)]
@ -663,7 +791,7 @@ mod tests {
let _guard = client_build_lock()
.lock()
.expect("the client-build lock is never poisoned");
LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
.expect("a loopback endpoint opens")
}
@ -824,6 +952,7 @@ mod tests {
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
caller_runtime_id: TEST_RUNTIME.into(),
}),
None,
&[TEST_MODEL],
)
.err()
@ -845,14 +974,14 @@ mod tests {
.lock()
.expect("the client-build lock is never poisoned");
LocalInferencePort::new(
"[::1]:1".parse().unwrap(),
Endpoint::Loopback("[::1]:1".parse().unwrap()),
DEFAULT_LOCAL_MODEL_PREFIX,
TEST_RUNTIME,
)
.expect("the IPv6 loopback address opens");
let error = LocalInferencePort::new(
"[2001:db8::1]:8080".parse().unwrap(),
Endpoint::Loopback("[2001:db8::1]:8080".parse().unwrap()),
DEFAULT_LOCAL_MODEL_PREFIX,
TEST_RUNTIME,
)
@ -903,7 +1032,7 @@ mod tests {
Some(Arc::clone(&connector_port)),
Some(Arc::clone(&sdk_port)),
"claude",
Some(("claude-local/".to_owned(), Arc::clone(&local_port))),
vec![("claude-local/".to_owned(), Arc::clone(&local_port))],
);
assert!(Arc::ptr_eq(
routed.route("claude-local/llama-8b").unwrap(),
@ -930,6 +1059,7 @@ mod tests {
model_prefix: "claude".into(),
caller_runtime_id: TEST_RUNTIME.into(),
}),
None,
&["claude-opus-5"],
)
.err()
@ -1050,7 +1180,7 @@ mod tests {
std::env::set_var("HTTP_PROXY", &proxy_url);
std::env::set_var("http_proxy", &proxy_url);
}
let built = LocalInferencePort::new(target.endpoint(), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME);
let built = LocalInferencePort::new(Endpoint::Loopback(target.endpoint()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME);
unsafe {
std::env::remove_var("HTTP_PROXY");
std::env::remove_var("http_proxy");
@ -1212,6 +1342,7 @@ mod tests {
model_prefix: String::new(),
caller_runtime_id: TEST_RUNTIME.into(),
}),
None,
&[TEST_MODEL],
)
.err()
@ -1237,6 +1368,7 @@ mod tests {
model_prefix: String::new(),
}),
None,
None,
&[TEST_MODEL],
)
.err()
@ -1566,7 +1698,7 @@ mod tests {
let port = {
let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned");
LocalInferencePort::with_timeout(
addr,
Endpoint::Loopback(addr),
DEFAULT_LOCAL_MODEL_PREFIX,
TEST_RUNTIME,
std::time::Duration::from_millis(150),
@ -1613,7 +1745,7 @@ mod tests {
let _guard = client_build_lock()
.lock()
.expect("the client-build lock is never poisoned");
LocalInferencePort::with_timeout(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout)
LocalInferencePort::with_timeout(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout)
.expect("a loopback endpoint opens")
}
@ -1706,7 +1838,7 @@ mod tests {
let endpoint: SocketAddr = "127.0.0.1:9".parse().unwrap();
let constructed = {
let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned");
LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME)
.expect("a loopback endpoint opens")
};
assert_eq!(constructed.response_timeout, RESPONSE_TIMEOUT);
@ -1751,4 +1883,383 @@ mod tests {
}
}
}
// ---- The hosted binding and token usage (cut hosted-lane) ----
/// A synthetic bearer, generated for the test and written to a temp key
/// file, so the production reader is the path under test.
const SYNTHETIC_KEY: &str = "synthetic-bearer-0123456789abcdef";
/// One file per call (named by its content's length and first byte), so
/// several cases in one directory never overwrite each other.
fn write_key_file(directory: &tempfile::TempDir, content: &[u8]) -> PathBuf {
let tag = format!("{}-{}", content.len(), content.first().copied().unwrap_or(0));
let path = directory.path().join(format!("provider-{tag}.key"));
std::fs::write(&path, content).unwrap();
path
}
fn hosted_binding(base_url: &str, key_path: PathBuf) -> HostedBinding {
HostedBinding {
base_url: base_url.into(),
key_path,
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
caller_runtime_id: TEST_RUNTIME.into(),
}
}
/// The test-only seam: the production key reader and the production
/// builder, with the https requirement lifted so the endpoint can be a
/// loopback responder with no TLS.
fn hosted_port_over_plain_http(base_url: String, key_path: &std::path::Path) -> LocalInferencePort {
let _guard = client_build_lock()
.lock()
.expect("the client-build lock is never poisoned");
LocalInferencePort::build(
Endpoint::Hosted {
base_url,
key: read_key_file(key_path).expect("the synthetic key file reads"),
},
DEFAULT_HOSTED_MODEL_PREFIX,
TEST_RUNTIME,
RESPONSE_TIMEOUT,
true,
)
.expect("the hosted seam opens")
}
fn usage_reply(usage: Value) -> String {
json!({
"id": "resp-usage",
"choices": [{
"message": {"role": "assistant", "content": "ok", "tool_calls": []},
"finish_reason": "stop",
}],
"usage": usage,
})
.to_string()
}
#[test]
fn hosted_binding_refuses_plain_http() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
for url in ["http://api.example.test", "ftp://api.example.test", "api.example.test", ""] {
let error = open_hosted_port(hosted_binding(url, key_path.clone()))
.err()
.expect("a non-https hosted endpoint opened");
assert!(matches!(error, ControllerOpenError::HostedEndpointNotHttps), "{url}");
assert!(!error.to_string().contains("example"), "the refusal echoed the URL");
}
let through_open_inference = open_inference(
None,
None,
None,
Some(hosted_binding("http://api.example.test", key_path.clone())),
&["hosted/deepseek-chat"],
)
.err()
.expect("open_inference opened a plain-http hosted endpoint");
assert!(matches!(through_open_inference, ControllerOpenError::HostedEndpointNotHttps));
assert!(open_hosted_port(hosted_binding("https://api.example.test", key_path)).is_ok());
}
#[test]
fn hosted_binding_refuses_an_empty_key_file() {
let directory = tempfile::tempdir().unwrap();
let absent = directory.path().join("absent.key");
let cases: Vec<(&str, PathBuf)> = vec![
("empty", write_key_file(&directory, b"")),
("only a newline", write_key_file(&directory, b"\n")),
("padded", write_key_file(&directory, b" key-with-padding \n")),
("not utf-8", write_key_file(&directory, &[0xff, 0xfe, 0xfd])),
("absent", absent),
];
for (label, path) in cases {
let canary = path.display().to_string();
let error = open_hosted_port(hosted_binding("https://api.example.test", path))
.err()
.unwrap_or_else(|| panic!("{label}: a bad key file opened"));
assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}");
let rendered = format!("{error} | {error:?}");
assert!(!rendered.contains(&canary), "{label}: the path leaked");
assert!(!rendered.contains("provider-"), "{label}: the file name leaked");
}
// A key file that ends in the usual newline is the key without it.
let ok = write_key_file(&directory, b"fine-key\r\n");
assert_eq!(read_key_file(&ok).unwrap().as_str(), "fine-key");
}
/// A bearer token is one run of visible ASCII. Each shape that cannot ride
/// an Authorization header is refused at open with the typed fault, and the
/// refusal carries neither the key text nor the path.
#[test]
fn hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header() {
let directory = tempfile::tempdir().unwrap();
let cases: [(&str, &[u8]); 9] = [
("interior newline", b"keycanary\nsecondline"),
("interior carriage return", b"keycanary\rsecondline"),
("interior tab", b"keycanary\tsecondline"),
("interior NUL", b"keycanary\0secondline"),
("leading space", b" keycanary"),
("trailing space before the newline", b"keycanary \n"),
("non-ascii", "keycanary-\u{e9}".as_bytes()),
("leading newline", b"\nkeycanary"),
("interior DEL", b"keycanary\x7fsecondline"),
];
for (label, content) in cases {
let path = write_key_file(&directory, content);
let canary = path.display().to_string();
let error = open_hosted_port(hosted_binding("https://api.example.test", path))
.err()
.unwrap_or_else(|| panic!("{label}: a key that cannot ride a header opened"));
assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}");
let rendered = format!("{error} | {error:?}");
assert!(!rendered.contains("keycanary"), "{label}: the key leaked");
assert!(!rendered.contains("secondline"), "{label}: the key leaked");
assert!(!rendered.contains(&canary), "{label}: the path leaked");
}
}
#[tokio::test]
async fn hosted_request_carries_the_bearer_and_loopback_carries_none() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, format!("{SYNTHETIC_KEY}\n").as_bytes());
let hosted_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
let hosted = hosted_port_over_plain_http(format!("http://{}", hosted_responder.endpoint()), &key_path);
let output = infer_once(&hosted).await.expect("the hosted port infers");
assert_eq!(output.events, vec![InferenceEvent::Text("ok".into())]);
let header_block = hosted_responder.headers()[0].to_ascii_lowercase();
assert!(
header_block.contains(&format!("authorization: bearer {}", SYNTHETIC_KEY.to_ascii_lowercase())),
"{header_block}"
);
let local_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
infer_once(&port(local_responder.endpoint())).await.expect("the local port infers");
assert!(!local_responder.headers()[0].to_ascii_lowercase().contains("authorization"));
}
#[tokio::test]
async fn the_hosted_request_strips_its_prefix_and_names_the_v1_path() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
// A trailing slash on the origin must not double the path separator.
let hosted = hosted_port_over_plain_http(format!("http://{}/", responder.endpoint()), &key_path);
let request = tool_request(
CommandId::new(),
0,
InferencePurpose::Persona,
"hosted/deepseek-chat",
"Respond only in natural prose.",
vec![CodexInputItem::UserText { text: "Say something true.".into() }],
Vec::<CodexToolDefinition>::new(),
RequestShape { max_output_tokens: 1_200, parallel_tool_calls: false },
)
.expect("the request builds");
let prepared = hosted.prepare(request).unwrap();
hosted.infer(prepared).await.expect("the hosted port infers");
assert!(responder.headers()[0].starts_with("POST /v1/chat/completions HTTP/1.1"));
let body: Value = serde_json::from_str(&responder.bodies()[0]).unwrap();
assert_eq!(body["model"], "deepseek-chat");
}
/// The bearer must not follow a redirect, and no fault, log line or
/// receipt may carry the key or the endpoint.
#[tokio::test]
async fn the_hosted_bearer_is_never_sent_to_a_redirect_target_or_echoed() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
let target = ScriptedResponder::start(vec![(200, text_reply("reached"))]).await;
let redirecting = start_redirecting_to(target.endpoint()).await;
let hosted = hosted_port_over_plain_http(format!("http://{redirecting}"), &key_path);
let fault = infer_once(&hosted).await.expect_err("a redirect produced an output");
assert!(target.headers().is_empty(), "the redirect target was reached");
let refusing = ScriptedResponder::start(vec![(401, "{}".to_owned())]).await;
let hosted = hosted_port_over_plain_http(format!("http://{}", refusing.endpoint()), &key_path);
let unauthorised = infer_once(&hosted).await.expect_err("a 401 produced an output");
let ok_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await;
let hosted = hosted_port_over_plain_http(format!("http://{}", ok_responder.endpoint()), &key_path);
let output = infer_once(&hosted).await.expect("the hosted port infers");
let rendered = format!(
"{fault} | {fault:?} | {unauthorised} | {unauthorised:?} | {output:?}"
);
assert!(!rendered.contains(SYNTHETIC_KEY), "the key leaked: {rendered}");
for addr in [redirecting, refusing.endpoint(), ok_responder.endpoint()] {
assert!(!rendered.contains(&addr.port().to_string()), "an endpoint leaked: {rendered}");
}
}
#[tokio::test]
async fn usage_reaches_inference_output() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
let responder = ScriptedResponder::start(vec![
(
200,
usage_reply(json!({
"prompt_tokens": 120,
"completion_tokens": 7,
"prompt_cache_hit_tokens": 100,
"prompt_cache_miss_tokens": 20,
})),
),
(200, usage_reply(json!({"prompt_tokens": 31, "completion_tokens": 4}))),
])
.await;
for (hosted, expected) in [
(
true,
TokenUsage { prompt: 120, completion: 7, cached_prompt: Some(100) },
),
(
false,
TokenUsage { prompt: 31, completion: 4, cached_prompt: None },
),
] {
let output = if hosted {
let port =
hosted_port_over_plain_http(format!("http://{}", responder.endpoint()), &key_path);
infer_once(&port).await.unwrap()
} else {
infer_once(&port(responder.endpoint())).await.unwrap()
};
assert_eq!(output.usage(), Some(expected));
}
}
/// A reply without usage is a typed absence. The receipt keeps the zero
/// counts it always carried (the local lane's receipt is unchanged), so
/// the digest equals the digest of a reply that reported zeroes, while the
/// output tells the two apart.
#[tokio::test]
async fn a_reply_without_usage_is_none_and_the_local_receipt_is_unchanged() {
let absent = json!({
"id": "resp-usage",
"choices": [{
"message": {"role": "assistant", "content": "ok", "tool_calls": []},
"finish_reason": "stop",
}],
})
.to_string();
let responder = ScriptedResponder::start(vec![
(200, absent),
(200, usage_reply(json!({"prompt_tokens": 0, "completion_tokens": 0}))),
(200, usage_reply(json!({"prompt_tokens": 9}))),
(200, usage_reply(json!({"completion_tokens": 9}))),
])
.await;
let port = port(responder.endpoint());
// One prepared request, so the receipts differ in nothing but the reply.
let prepared = port.prepare(plain_request()).expect("the port prepares");
let none = port.infer(prepared.clone()).await.unwrap();
let zeroes = port.infer(prepared.clone()).await.unwrap();
let half = port.infer(prepared.clone()).await.unwrap();
let other_half = port.infer(prepared).await.unwrap();
assert_eq!(none.usage(), None);
assert_eq!(zeroes.usage(), Some(TokenUsage { prompt: 0, completion: 0, cached_prompt: None }));
assert_eq!(half.usage(), None, "a reply missing the completion count reported a zero");
assert_eq!(other_half.usage(), None, "a reply missing the prompt count reported a zero");
assert_eq!(none.receipt_digest(), zeroes.receipt_digest());
}
/// A usage count that is present but null is a malformed reply: an
/// integrity violation on the local lane (as before the hosted lane
/// existed) and on the hosted lane that shares its driver. Only a count
/// that is absent is a typed absence.
#[tokio::test]
async fn a_null_usage_count_is_an_integrity_violation_on_both_lanes() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
let bodies = [
json!({"prompt_tokens": null, "completion_tokens": 3}),
json!({"prompt_tokens": 3, "completion_tokens": null}),
];
for hosted in [false, true] {
for usage in &bodies {
let responder = ScriptedResponder::start(vec![(200, usage_reply(usage.clone()))]).await;
let fault = if hosted {
let port = hosted_port_over_plain_http(
format!("http://{}", responder.endpoint()),
&key_path,
);
infer_once(&port).await
} else {
infer_once(&port(responder.endpoint())).await
}
.expect_err("a null usage count produced an output");
assert_eq!(fault.class(), InferenceFaultClass::BadReply, "hosted={hosted} {fault:?}");
assert_eq!(
fault.disposition(),
InferenceFaultDisposition::IntegrityViolation,
"hosted={hosted} {fault:?}"
);
}
}
}
#[test]
fn a_hosted_prefix_routes_to_the_hosted_port_and_shares_no_prefix() {
let directory = tempfile::tempdir().unwrap();
let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes());
let local_binding = || LocalBinding {
endpoint: "127.0.0.1:1".parse().unwrap(),
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
caller_runtime_id: TEST_RUNTIME.into(),
};
let hosted = || hosted_binding("https://api.example.test", key_path.clone());
let models = ["local/llama-8b", "hosted/deepseek-chat"];
assert!(open_inference(None, None, Some(local_binding()), Some(hosted()), &models).is_ok());
// Without the hosted binding its model has no owner: loud, not a fallback.
assert!(matches!(
open_inference(None, None, Some(local_binding()), None, &models),
Err(ControllerOpenError::UnroutableModel { .. })
));
let mut clashing = hosted();
clashing.model_prefix = DEFAULT_LOCAL_MODEL_PREFIX.into();
assert!(matches!(
open_inference(None, None, Some(local_binding()), Some(clashing), &["local/x"]),
Err(ControllerOpenError::SharedModelPrefix { .. })
));
let mut empty = hosted();
empty.model_prefix = String::new();
assert!(matches!(
open_inference(None, None, None, Some(empty), &["x"]),
Err(ControllerOpenError::EmptyModelPrefix { transport: "hosted" })
));
// Routing itself: each prefix reaches its own port.
let local_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let hosted_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let routed = RoutedInferencePort::new(
None,
None,
"claude",
vec![
("local/".to_owned(), Arc::clone(&local_port)),
("hosted/".to_owned(), Arc::clone(&hosted_port)),
],
);
assert!(Arc::ptr_eq(routed.route("local/a").unwrap(), &local_port));
assert!(Arc::ptr_eq(routed.route("hosted/a").unwrap(), &hosted_port));
assert!(routed.route("other").is_none());
// Overlapping hosted claims: the longest prefix wins.
let narrow_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc<dyn InferencePort>;
let routed = RoutedInferencePort::new(
None,
None,
"claude",
vec![
("hosted/".to_owned(), Arc::clone(&hosted_port)),
("hosted/deep/".to_owned(), Arc::clone(&narrow_port)),
],
);
assert!(Arc::ptr_eq(routed.route("hosted/deep/x").unwrap(), &narrow_port));
assert!(Arc::ptr_eq(routed.route("hosted/x").unwrap(), &hosted_port));
}
}

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,8 @@ impl WorldMailbox {
kind,
controller,
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
position: Some(commons_ref()),
import_key: None,
})
};
// Roots stand beside the commons, not inside it: a root contained by
@ -129,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,
@ -189,6 +211,7 @@ impl WorldMailbox {
},
scale_intent,
lens_weights: input.lens_weights,
grammar: None,
},
AuthenticatedCaller::verified_principal(principal_id),
)
@ -217,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
@ -459,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
@ -928,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,
@ -988,6 +1082,7 @@ mod tests {
fn creation(id: CommandId, title: &str) -> CreateWorld {
CreateWorld {
lens_weights: crate::tests::stock_weights(),
grammar: None,
id,
owner: PrincipalId::new("owner"),
title: title.into(),
@ -1009,6 +1104,7 @@ mod tests {
},
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
import_key: None,
}),
],
operations: Vec::new(),
@ -1396,6 +1492,7 @@ mod tests {
controller,
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))),
import_key: None,
})
};
let room = |handle: &str| {
@ -1425,6 +1522,7 @@ mod tests {
evidence: Vec::new(),
},
scale_intent: WorldScaleIntentRef::default(),
grammar: None,
},
&authenticated,
)

View file

@ -13,6 +13,7 @@
use super::clock::{FictionalMinutes, TickMinutes};
use super::tool_schema;
use super::grammar::GrammarRefusal;
use super::{
AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights,
NewController, SubjectId, SubjectKind, SubjectState, WorldId,
@ -22,6 +23,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
use uuid::Uuid;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@ -944,6 +946,102 @@ impl EvidenceRef {
}
}
/// The Aetheria record a subject was imported from: the CultCache schema id and
/// the record key, compared exactly as two separate texts so no pair of
/// distinct records can spell one key. Admission keeps the mapping injective;
/// a key is never matched by prefix, case or normalisation.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
#[serde(deny_unknown_fields)]
pub struct ImportKey {
schema: String,
record: String,
}
/// The longest either part of an import key may be, in bytes.
const MAX_IMPORT_KEY_PART_BYTES: usize = 200;
impl ImportKey {
pub fn new(schema: impl Into<String>, record: impl Into<String>) -> Self {
Self {
schema: schema.into(),
record: record.into(),
}
}
pub fn schema(&self) -> &str {
&self.schema
}
pub fn record(&self) -> &str {
&self.record
}
/// The one predicate for an import key, called by the resolver and by the
/// load-time state gate alike. The record part is canonical free text: not
/// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC
/// (refused, never normalised, so the kernel derives no second spelling),
/// no control or format character, and no whitespace but U+0020. The schema
/// part is a schema id: the same text with no space at all.
pub(super) fn is_canonical(&self) -> bool {
is_canonical_key_text(&self.record)
&& is_canonical_key_text(&self.schema)
&& !self.schema.contains(' ')
}
}
fn is_canonical_key_text(part: &str) -> bool {
!part.is_empty()
&& part.len() <= MAX_IMPORT_KEY_PART_BYTES
&& !part.starts_with(' ')
&& !part.ends_with(' ')
&& !part.contains(" ")
&& unicode_normalization::is_nfc(part)
&& part.chars().all(|c| {
!c.is_control()
&& c.general_category() != GeneralCategory::Format
&& (c == ' ' || !c.is_whitespace())
})
}
/// Why an import key cannot join a world: the resolver names these by draft
/// handle, the state gates by invariant text.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ImportKeyFault {
Malformed,
Held,
Duplicate,
}
/// The keys the subjects hold, retired ones included, so a key is never
/// reissued. Derived on demand; no stored index.
pub(super) fn held_import_keys<'a>(
subjects: impl IntoIterator<Item = &'a super::SubjectState>,
) -> BTreeSet<&'a ImportKey> {
subjects
.into_iter()
.filter_map(|subject| subject.import_key.as_ref())
.collect()
}
/// The one decision on a key: malformed, held by a subject already in the
/// world, or already claimed in `declared` (which it joins). Every door that
/// admits or loads subjects calls this, so none can decide otherwise.
pub(super) fn import_key_fault<'a>(
key: &'a ImportKey,
held: &BTreeSet<&ImportKey>,
declared: &mut BTreeSet<&'a ImportKey>,
) -> Option<ImportKeyFault> {
if !key.is_canonical() {
Some(ImportKeyFault::Malformed)
} else if held.contains(key) {
Some(ImportKeyFault::Held)
} else if !declared.insert(key) {
Some(ImportKeyFault::Duplicate)
} else {
None
}
}
/// `position` is the subject's presence: one place it stands in. A subject
/// declared without one is unplaced until a later pass gives placement an owner.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -958,6 +1056,11 @@ pub(crate) struct SubjectDeclaration {
/// atomically.
pub(crate) affordances: BTreeSet<Ref<AffordanceId>>,
pub(crate) position: Option<Ref<EntityId>>,
/// The record this subject was imported from, if any. Absent from a
/// subject nobody imported, so such a world's state and digests are what
/// they were before import keys existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) import_key: Option<ImportKey>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -1350,6 +1453,33 @@ pub enum Mismatch {
EmptyLabel {
handle: DraftHandle,
},
/// The world is bound to a grammar and this affordance's kind is not one of
/// its verbs.
AffordanceKindNotInGrammar {
handle: DraftHandle,
},
/// The kind is a verb of the bound grammar, but the declared roles are not
/// its signature (name, referent kind or order).
AffordanceRolesDisagreeWithGrammar {
handle: DraftHandle,
},
/// The grammar binds the kernel's speak verb as anything but a Conversation.
SpeakRenderPathDisagreesWithGrammar {
handle: DraftHandle,
},
/// An import key is empty, has whitespace, or is longer than 200 bytes in
/// either part.
MalformedImportKey {
handle: DraftHandle,
},
/// Two subjects in one patch declare the same import key.
DuplicateImportKey {
handle: DraftHandle,
},
/// A subject already in the world, retired or not, holds this import key.
ImportKeyHeld {
handle: DraftHandle,
},
UnresolvedDraft {
site: Site,
referent: DraftHandle,
@ -3061,6 +3191,28 @@ fn candidate_set(
}
}
/// A bound grammar is the whole catalog: a kind it lacks, or a role signature
/// it does not carry, is refused. A world with no grammar is not checked.
fn check_against_grammar(
state: &super::WorldState,
handle: &DraftHandle,
kind: &AffordanceKindName,
roles: &[RoleSpec],
mismatches: &mut Vec<Mismatch>,
) {
let Some(grammar) = &state.grammar else {
return;
};
let handle = handle.clone();
mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal {
GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle },
GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle },
GrammarRefusal::SpeakNotConversation => {
Mismatch::SpeakRenderPathDisagreesWithGrammar { handle }
}
}));
}
/// The one resolution owner for every admission lane: declarations, references,
/// topology admission, and operation preconditions. Every check runs against the
/// complete candidate graph — what the world already holds plus what this patch
@ -3084,6 +3236,11 @@ pub(super) fn resolve_patch(
if admits_human {
index.insert(speak_handle.clone(), RefKind::Affordance);
}
if admits_human {
// The kernel-built entry is held to the grammar like any declaration.
let speak = kernel_speak_entry();
check_against_grammar(state, &speak_handle, &speak.kind, &speak.roles, &mut mismatches);
}
let mut kind_names: BTreeSet<AffordanceKindName> = state
.affordance_catalog
.values()
@ -3093,6 +3250,8 @@ pub(super) fn resolve_patch(
kind_names.insert(kernel_speak_entry().kind);
}
let held_keys = held_import_keys(state.subjects.values());
let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new();
for (position, declaration) in patch.declarations.iter().enumerate() {
let (handle, label, kind) = match declaration {
Declaration::Subject(subject) => (
@ -3132,6 +3291,21 @@ pub(super) fn resolve_patch(
}),
}
}
if let Declaration::Subject(subject) = declaration
&& let Some(key) = &subject.import_key
{
let handle = subject.handle.clone();
match import_key_fault(key, &held_keys, &mut declared_keys) {
Some(ImportKeyFault::Malformed) => {
mismatches.push(Mismatch::MalformedImportKey { handle });
}
Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }),
Some(ImportKeyFault::Duplicate) => {
mismatches.push(Mismatch::DuplicateImportKey { handle });
}
None => {}
}
}
if let Declaration::Affordance(affordance) = declaration {
validate_affordance(
&affordance.handle,
@ -3143,6 +3317,13 @@ pub(super) fn resolve_patch(
affordance.carries_speech,
&mut mismatches,
);
check_against_grammar(
state,
&affordance.handle,
&affordance.kind,
&affordance.roles,
&mut mismatches,
);
if !kind_names.insert(affordance.kind.clone()) {
mismatches.push(Mismatch::DuplicateAffordanceKind {
handle: affordance.handle.clone(),
@ -5229,6 +5410,7 @@ pub(super) fn resolve_patch(
subject: SubjectState {
label: input.label.clone(),
kind: input.kind,
import_key: input.import_key.clone(),
},
controller,
affordances: granted,
@ -7734,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.
@ -7853,6 +8036,7 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak_entry(kernel)]),
position,
import_key: None,
})
}
@ -8120,6 +8304,7 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]),
position: None,
import_key: None,
})
};
@ -8420,6 +8605,7 @@ mod tests {
},
affordances: BTreeSet::from([speak_entry(&kernel)]),
position: None,
import_key: None,
}),
]);
let error = kernel
@ -8472,6 +8658,7 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak_entry(&kernel)]),
position: None,
import_key: None,
}),
]);
let receipt = submit_owner(&mut kernel, &before, admit(repaired));
@ -9027,6 +9214,7 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak_entry(&kernel)]),
position: None,
import_key: None,
})]);
submit_owner(&mut kernel, &before, admit(patch.clone()));
let admitted = *kernel
@ -9303,6 +9491,7 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: None,
import_key: None,
})])),
);
let active = activate(&mut kernel);

View file

@ -7,7 +7,7 @@
//! its own, computes no tool result, and holds no credential.
use super::controllers::{
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, TokenUsage,
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid,
tool_name_is_valid, unix_ms,
};
@ -121,6 +121,22 @@ pub(super) struct SdkModelUsage {
pub(super) cache_creation_input_tokens: u64,
}
/// The reply's token usage summed over the models the run used. The whole
/// prompt is the fresh input plus both cache figures (Anthropic reports them
/// apart); the cached part is what was read from cache. A run that reported
/// no usage at all is `None`, never a zero.
fn sdk_usage(usage: &[SdkModelUsage]) -> Option<TokenUsage> {
if usage.is_empty() {
return None;
}
let sum = |field: fn(&SdkModelUsage) -> u64| usage.iter().map(field).sum::<u64>();
Some(TokenUsage {
prompt: sum(|u| u.input_tokens) + sum(|u| u.cache_read_input_tokens) + sum(|u| u.cache_creation_input_tokens),
completion: sum(|u| u.output_tokens),
cached_prompt: Some(sum(|u| u.cache_read_input_tokens)),
})
}
/// The failures the sidecar is allowed to name. It reports a reason; this
/// module assigns the disposition, so TypeScript can never quarantine the
/// world's cognition by naming an integrity violation directly.
@ -654,9 +670,10 @@ fn assemble_output(
};
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
.map_err(|_| InferenceFault::new("the SDK receipt could not be encoded"))?;
Ok(InferenceOutput {
events: lowered,
receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
let output = InferenceOutput::new(lowered, format!("sha256:{:x}", Sha256::digest(&receipt_bytes)));
Ok(match sdk_usage(&receipt.usage) {
Some(usage) => output.with_usage(usage),
None => output,
})
}
@ -713,10 +730,10 @@ pub(super) struct RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: String,
/// The local port's own claimed prefix, carried beside it rather than in a
/// third bare `String` field, so `route` can compare its length against
/// the SDK's claim without a third parallel field to keep in sync.
local: Option<(String, Arc<dyn InferencePort>)>,
/// The OpenAI-compatible ports (the loopback one and the hosted one), each
/// with its own claimed prefix carried beside it, so `route` can compare
/// lengths without a parallel field per port to keep in sync.
claims: Vec<(String, Arc<dyn InferencePort>)>,
}
impl RoutedInferencePort {
@ -724,13 +741,13 @@ impl RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: impl Into<String>,
local: Option<(String, Arc<dyn InferencePort>)>,
claims: Vec<(String, Arc<dyn InferencePort>)>,
) -> Self {
Self {
connector,
sdk,
sdk_model_prefix: sdk_model_prefix.into(),
local,
claims,
}
}
@ -747,9 +764,10 @@ impl RoutedInferencePort {
// the connector, which is what keeps a typo'd model prefix loud
// instead of silently reaching the wrong backend.
let local_claim = self
.local
.as_ref()
.claims
.iter()
.filter(|(prefix, _)| model.starts_with(prefix.as_str()))
.max_by_key(|(prefix, _)| prefix.len())
.map(|(prefix, port)| (prefix.len(), port));
let sdk_claim = model
.starts_with(&self.sdk_model_prefix)
@ -1202,6 +1220,41 @@ mod tests {
assert_eq!(output.events.len(), 2);
}
/// The SDK lane reports the run's tokens summed over its models: the whole
/// prompt is fresh input plus both cache figures, the cached part is the
/// cache read, and a run that reported nothing is `None`, not a zero.
#[tokio::test]
async fn the_sdk_receipts_token_counts_reach_inference_output() {
let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME);
let prepared = prose_prepared(&port, "Say something true.");
let events = vec![SidecarEvent::Text { text: "ok".into() }];
let mut two_models = material();
two_models.usage = vec![
SdkModelUsage {
model: TEST_MODEL.into(),
input_tokens: 11,
output_tokens: 5,
cache_read_input_tokens: 3,
cache_creation_input_tokens: 2,
},
SdkModelUsage {
model: "claude-haiku-5".into(),
input_tokens: 100,
output_tokens: 40,
cache_read_input_tokens: 7,
cache_creation_input_tokens: 0,
},
];
let output = assemble_output(&prepared, events.clone(), two_models, &[]).unwrap();
assert_eq!(
output.usage(),
Some(TokenUsage { prompt: 123, completion: 45, cached_prompt: Some(10) })
);
let mut none = material();
none.usage = Vec::new();
assert_eq!(assemble_output(&prepared, events, none, &[]).unwrap().usage(), None);
}
/// Spec test 11. The digest is a pure function of the receipt, so it is
/// compared directly rather than through two whole queries.
#[tokio::test]
@ -1274,6 +1327,7 @@ mod tests {
let granted = vec![AffordanceSnapshot {
id: AffordanceId::issue(),
entry: kernel_speak_entry(),
render_path: None,
}];
let speak = granted[0].entry.kind.0.clone();
let link = ScriptedLink::new(vec![
@ -1335,7 +1389,7 @@ mod tests {
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
DEFAULT_SDK_MODEL_PREFIX,
None,
Vec::new(),
);
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk));
assert!(Arc::ptr_eq(
@ -1346,7 +1400,7 @@ mod tests {
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
"gpt-",
None,
Vec::new(),
);
assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk));
assert!(Arc::ptr_eq(
@ -1385,11 +1439,11 @@ mod tests {
};
assert!(matches!(
open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()),
open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
open_inference(Some(connector()), None, None, &models("claude-opus-5").each()),
open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
@ -1401,12 +1455,13 @@ mod tests {
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
None,
None,
&models(TEST_MODEL).each(),
),
Err(ControllerOpenError::SdkSidecarMissing { .. })
));
assert!(
open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(),
open_inference(Some(connector()), Some(sdk()), None, None, &models(TEST_MODEL).each()).is_ok(),
"a configuration where every model routes did not open"
);
}
@ -1759,6 +1814,7 @@ mod tests {
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
None,
None,
&models(TEST_MODEL).each(),
)
.expect("the SDK model routes");
@ -1881,7 +1937,7 @@ mod tests {
)) as Arc<dyn InferencePort>),
None,
DEFAULT_SDK_MODEL_PREFIX,
None,
Vec::new(),
);
assert!(connector_only.route(TEST_MODEL).is_none());
let prepared =
@ -1913,6 +1969,7 @@ mod tests {
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
None,
None,
&models(TEST_MODEL).each(),
)
.is_ok()
@ -1990,6 +2047,7 @@ mod tests {
let granted = vec![AffordanceSnapshot {
id: AffordanceId::issue(),
entry: kernel_speak_entry(),
render_path: None,
}];
let mut emitted: Vec<(String, String)> = patch_tools()
.into_iter()

View file

@ -14,7 +14,7 @@
//! Projector may call it.
use super::controllers::{
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest,
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage,
RequestShape, tool_request,
};
use super::patch::{
@ -1070,6 +1070,12 @@ impl InferenceOutput {
pub fn receipt_digest(&self) -> &str {
&self.receipt_digest
}
/// The tokens the provider reported for this reply; `None` when it
/// reported none.
pub fn usage(&self) -> Option<TokenUsage> {
self.usage
}
}
/// The canonical id as the patch vocabulary spells it: the bare UUID text,
@ -1600,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] = &[
@ -2489,6 +2496,7 @@ mod tests {
outcome_bands: Vec::new(),
carries_speech: false,
},
render_path: None,
}
}

View file

@ -0,0 +1,759 @@
//! Provider call records, the run's spend, and replay.
//!
//! `RecordingInferencePort` is the one owner of a run's spend. Before it
//! forwards a call it decides, from the request alone, whether the call could
//! pass the run's token cap; after the reply it charges what the call really
//! cost. Every reply is stored as a `ProviderCallRecord` before the output
//! returns to the lane, so `ReplayInferencePort` can serve the same run again
//! from the store with no provider behind it.
//!
//! Lanes and runners never count tokens. A request is held against the cap by
//! its bound: the serialized provider request's bytes (a token is at least one
//! byte, so bytes bound the prompt) plus the request's own output allowance.
//!
//! The run's spend is one persisted ledger row per run, owned by the store and
//! written where it is spent. A call reserves its bound in that row before the
//! provider is called; a reply settles the reservation to what it reported
//! (prompt plus completion, cached prompt tokens being part of the prompt; none
//! reported is charged the bound); a fault leaves the reservation charged. A
//! restart reads the row, so no restart returns budget. A reply that reports
//! more than the call's bound is charged in full and faults the call, so the
//! run's spend never passes the cap unnoticed. One port at a time owns a run.
use crate::controllers::{
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose,
InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms,
};
use async_trait::async_trait;
use chrono::Utc;
use codex_connector::CodexProviderRequest;
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
path::Path,
sync::{Arc, Mutex},
};
use thiserror::Error;
const CALL_RECORD_ROW: &str = "verse_provider_call.v1";
const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1";
/// The run ledger document's schema.
const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1";
/// The call record document's schema. A store holding any other row type or
/// schema is refused at open: this store is not migrated from, or shared with,
/// another document.
pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1";
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
pub enum CallRecordError {
#[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")]
InvalidRunId,
#[error("the call record store could not be read or written")]
Store,
#[error("the call record store holds a row that is not a canonical call record")]
Corrupt,
#[error("a call record's sequence is not the next in its run")]
OutOfSequence,
#[error("replay needs one run's records in dense sequence from 1")]
NotReplayable,
#[error("another recording port already owns this run")]
RunInUse,
#[error("the call could pass the run's token cap")]
OverCap,
#[error("the run was recorded with a different {0}")]
RunMismatch(RunField),
}
/// What a run is bound to when it begins, named by the field a mismatch refuses.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunField {
/// The token cap the run was opened with.
Cap,
/// The identity of the world the run started from.
WorldId,
/// The state digest of the world the run started from.
StateDigest,
}
impl std::fmt::Display for RunField {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Cap => "token cap",
Self::WorldId => "world id",
Self::StateDigest => "world state digest",
})
}
}
/// The world a run began from: its identity and state digest. Replay is of a
/// restored copy of this world, so it takes the same two values.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunStart {
world_id: String,
state_digest: String,
}
impl RunStart {
pub fn of(world: &crate::WorldSnapshot) -> Self {
Self {
world_id: world.world_id.text(),
state_digest: world.state_digest.clone(),
}
}
}
/// A run's identity as a key segment.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub struct RunId(String);
impl RunId {
pub fn new(id: &str) -> Result<Self, CallRecordError> {
let valid = !id.is_empty()
&& id.len() <= 64
&& id
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
if valid {
Ok(Self(id.to_owned()))
} else {
Err(CallRecordError::InvalidRunId)
}
}
pub fn as_str(&self) -> &str {
&self.0
}
}
/// One provider call of one run: the request's content digest (never its text, so no
/// prompt or credential is stored), the reply exactly as the lane received it,
/// and what the call was charged. Keyed (run id, sequence).
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProviderCallRecord {
run_id: String,
sequence: u64,
purpose: InferencePurpose,
model: String,
content_sha256: String,
events: Vec<InferenceEvent>,
receipt_digest: String,
usage: Option<TokenUsage>,
charged: u64,
}
impl ProviderCallRecord {
pub fn run_id(&self) -> &str {
&self.run_id
}
pub fn sequence(&self) -> u64 {
self.sequence
}
pub fn purpose(&self) -> InferencePurpose {
self.purpose
}
pub fn model(&self) -> &str {
&self.model
}
/// Lowercase hex of the request's content digest (see `content_digest`).
pub fn content_sha256(&self) -> &str {
&self.content_sha256
}
pub fn usage(&self) -> Option<TokenUsage> {
self.usage
}
/// Tokens this call was charged against the run's cap.
pub fn charged(&self) -> u64 {
self.charged
}
/// The reply as the lane received it.
pub fn output(&self) -> InferenceOutput {
let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone());
match self.usage {
Some(usage) => output.with_usage(usage),
None => output,
}
}
}
fn row_key(run: &str, sequence: u64) -> String {
format!("{run}/{sequence:020}")
}
fn hex(digest: &[u8]) -> String {
digest.iter().map(|byte| format!("{byte:02x}")).collect()
}
/// What a request asks of the provider, without the ids that name it. The
/// request and conversation ids are derived from the command id, and a command
/// id from the world it ran in, so keying on them would tie a recorded run to
/// one world's random identity. Everything else the provider is sent
/// (model, instructions, input, tools, shape) is in the digest.
fn content_digest(request: &CodexProviderRequest) -> Result<String, InferenceFault> {
// Exhaustive on purpose: a field added upstream must be placed in the key
// or left out here by name before this compiles.
let CodexProviderRequest {
schema_id,
request_id: _request_id, // names the call, not what it asks
conversation_id: _conversation_id, // names the call, not what it asks
model,
instructions,
input,
reasoning_effort,
reasoning_summary,
service_tier,
output_format_name,
previous_response_id,
tools,
tool_choice,
parallel_tool_calls,
output_schema_json,
max_output_tokens,
prompt_cache_key,
} = request;
let bytes = rmp_serde::to_vec(&(
schema_id,
model,
instructions,
input,
reasoning_effort,
reasoning_summary,
service_tier,
output_format_name,
previous_response_id,
tools,
tool_choice,
parallel_tool_calls,
output_schema_json,
max_output_tokens,
prompt_cache_key,
))
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
Ok(hex(&Sha256::digest(bytes)))
}
/// A run's persisted ledger, the one owner of what the run began as: the cap
/// it was opened under, the world it started from, and the tokens reserved or
/// charged so far.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct RunLedger {
run_id: String,
cap: u64,
start: RunStart,
spent: u64,
}
enum DecodedRow {
Call(ProviderCallRecord),
Ledger(RunLedger),
}
pub(super) fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
row: &CultCacheEnvelope,
) -> Result<T, CallRecordError> {
let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?;
let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?;
if again == row.payload {
Ok(value)
} else {
Err(CallRecordError::Corrupt)
}
}
fn decode_row(row: &CultCacheEnvelope) -> Result<DecodedRow, CallRecordError> {
match (row.r#type.as_str(), row.schema_id.as_deref()) {
(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => {
let record: ProviderCallRecord = canonical(row)?;
// A run id that is no whole key segment would key into another
// run's records, and sequences count from 1.
if RunId::new(&record.run_id).is_err()
|| record.sequence == 0
|| row.key != row_key(&record.run_id, record.sequence)
{
return Err(CallRecordError::Corrupt);
}
Ok(DecodedRow::Call(record))
}
(RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => {
let ledger: RunLedger = canonical(row)?;
if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id {
return Err(CallRecordError::Corrupt);
}
Ok(DecodedRow::Ledger(ledger))
}
_ => Err(CallRecordError::Corrupt),
}
}
#[derive(Default)]
struct RunTally {
last_sequence: u64,
/// The persisted ledger, read at open and written where it is spent.
ledger: Option<RunLedger>,
}
struct StoreState {
store: OwnedRedbMessagePackBackingStore,
runs: BTreeMap<String, RunTally>,
/// Runs with a recording port alive; one ledger has one owner.
owned: BTreeSet<String>,
}
/// The call records and spend ledgers of every run in one CultCache file.
/// Records are appended and never changed; a run's sequence is dense from 1.
pub struct CallRecordStore {
state: Mutex<StoreState>,
}
impl CallRecordStore {
/// Opens the file, creating it empty when absent. Every row must be a
/// canonical call record or run ledger of this schema with its key, a
/// dense sequence, and a ledger that covers its records' charges; anything
/// else, an earlier or later schema version included, is refused and
/// nothing is migrated.
pub fn open(path: impl AsRef<Path>) -> Result<Self, CallRecordError> {
let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())
.map_err(|_| CallRecordError::Store)?;
store
.validate_path_identity()
.map_err(|_| CallRecordError::Store)?;
let rows = store.pull_all().map_err(|_| CallRecordError::Store)?;
let mut runs: BTreeMap<String, RunTally> = BTreeMap::new();
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
let mut charged: BTreeMap<String, u64> = BTreeMap::new();
for row in &rows {
match decode_row(row)? {
DecodedRow::Call(record) => {
*counts.entry(record.run_id.clone()).or_default() += 1;
let total = charged.entry(record.run_id.clone()).or_default();
*total = total.saturating_add(record.charged);
let tally = runs.entry(record.run_id).or_default();
tally.last_sequence = tally.last_sequence.max(record.sequence);
}
DecodedRow::Ledger(ledger) => {
let run = ledger.run_id.clone();
runs.entry(run).or_default().ledger = Some(ledger);
}
}
}
// Sequences start at 1 and keys are unique, so a run is dense exactly
// when its rows number its highest sequence. Every record was reserved
// in a ledger before it was made, so a run with records has a ledger
// that covers what they charged.
for (run, tally) in &runs {
let rows = counts.get(run).copied().unwrap_or(0);
let recorded = charged.get(run).copied().unwrap_or(0);
let covered = tally
.ledger
.as_ref()
.is_some_and(|ledger| ledger.spent >= recorded);
if rows != tally.last_sequence || !covered {
return Err(CallRecordError::Corrupt);
}
}
Ok(Self {
state: Mutex::new(StoreState {
store,
runs,
owned: BTreeSet::new(),
}),
})
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, CallRecordError> {
self.state.lock().map_err(|_| CallRecordError::Store)
}
/// The sequence the next record of this run must carry.
pub fn next_sequence(&self, run: &RunId) -> Result<u64, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.map_or(0, |tally| tally.last_sequence)
+ 1)
}
/// Tokens this run has reserved or been charged: its persisted ledger.
pub fn spent(&self, run: &RunId) -> Result<u64, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map_or(0, |ledger| ledger.spent))
}
/// Takes the run's ledger for one port, or refuses when another holds it.
/// The first claim of a run writes its ledger: the cap it is opened under
/// and the world it starts from. A later claim must name the same cap; the
/// start is not compared, because a resumed world has moved on.
fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
if !state.owned.insert(run.as_str().to_owned()) {
return Err(CallRecordError::RunInUse);
}
let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) {
Some(ledger) if ledger.cap == cap => Ok(()),
Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)),
None => Self::write_ledger(
&mut state,
RunLedger {
run_id: run.as_str().to_owned(),
cap,
start: start.clone(),
spent: 0,
},
),
};
if begun.is_err() {
state.owned.remove(run.as_str());
}
begun
}
/// The world a run began from, when it has begun.
fn start_of(&self, run: &RunId) -> Result<Option<RunStart>, CallRecordError> {
Ok(self
.lock()?
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map(|ledger| ledger.start.clone()))
}
fn disown(&self, run: &RunId) {
if let Ok(mut state) = self.state.lock() {
state.owned.remove(run.as_str());
}
}
/// Persists the ledger, then adopts it in memory. A write that fails
/// leaves the previous ledger.
fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> {
let row = CultCacheEnvelope {
key: ledger.run_id.clone(),
r#type: RUN_LEDGER_ROW.into(),
payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?,
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(RUN_LEDGER_SCHEMA.into()),
};
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
let run = ledger.run_id.clone();
state.runs.entry(run).or_default().ledger = Some(ledger);
Ok(())
}
/// Persists `spent` as the run's spend; the run must have been claimed.
fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> {
let mut ledger = state
.runs
.get(run)
.and_then(|tally| tally.ledger.clone())
.ok_or(CallRecordError::Store)?;
ledger.spent = spent;
Self::write_ledger(state, ledger)
}
fn held(state: &StoreState, run: &RunId) -> u64 {
state
.runs
.get(run.as_str())
.and_then(|tally| tally.ledger.as_ref())
.map_or(0, |ledger| ledger.spent)
}
/// Charges `bound` to the run's persisted ledger, or refuses with
/// `OverCap` when the ledger plus the bound would pass `cap`. The check and
/// the write hold one lock, so calls on the wire hold their bounds.
fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
let after = Self::held(&state, run).saturating_add(bound);
if after > cap {
return Err(CallRecordError::OverCap);
}
Self::write_spent(&mut state, run.as_str(), after)
}
/// Replaces a reservation of `bound` with what the call was charged. With
/// a record, the record is stored first, so a crash between the two writes
/// leaves the larger reservation charged.
fn settle(
&self,
run: &RunId,
bound: u64,
charged: u64,
record: Option<&ProviderCallRecord>,
) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
if let Some(record) = record {
Self::push_record(&mut state, record)?;
}
let settled = Self::held(&state, run)
.saturating_sub(bound)
.saturating_add(charged);
Self::write_spent(&mut state, run.as_str(), settled)
}
fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> {
let mut state = self.lock()?;
Self::push_record(&mut state, record)
}
fn push_record(
state: &mut StoreState,
record: &ProviderCallRecord,
) -> Result<(), CallRecordError> {
let last = state
.runs
.get(&record.run_id)
.map_or(0, |tally| tally.last_sequence);
if record.sequence != last + 1 {
return Err(CallRecordError::OutOfSequence);
}
let row = CultCacheEnvelope {
key: row_key(&record.run_id, record.sequence),
r#type: CALL_RECORD_ROW.into(),
payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?,
stored_at: Utc::now().to_rfc3339(),
schema_id: Some(CALL_RECORD_SCHEMA.into()),
};
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
state
.runs
.entry(record.run_id.clone())
.or_default()
.last_sequence = record.sequence;
Ok(())
}
/// The run's records in sequence.
pub fn records(&self, run: &RunId) -> Result<Vec<ProviderCallRecord>, CallRecordError> {
let prefix = format!("{}/", run.as_str());
let rows = self
.lock()?
.store
.pull_all()
.map_err(|_| CallRecordError::Store)?;
let mut records = Vec::new();
for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) {
if let DecodedRow::Call(record) = decode_row(row)? {
records.push(record);
}
}
records.sort_by_key(|record| record.sequence);
Ok(records)
}
}
/// Decorates a port for one run: reserves each call's bound in the run's
/// ledger before the provider is called, settles it to the real cost, and
/// stores every reply before returning it.
pub struct RecordingInferencePort {
inner: Arc<dyn InferencePort>,
store: Arc<CallRecordStore>,
run: RunId,
cap: u64,
}
impl RecordingInferencePort {
/// Takes ownership of the run's ledger, which resumes from the store. The
/// first open of a run records its cap and the world it starts from; a
/// reopen with another cap is refused with `RunMismatch(Cap)`. A second
/// port on a run whose first is alive is refused with `RunInUse`.
pub fn open(
inner: Arc<dyn InferencePort>,
store: Arc<CallRecordStore>,
run: RunId,
cap_tokens: u64,
start: &RunStart,
) -> Result<Self, CallRecordError> {
store.claim(&run, cap_tokens, start)?;
Ok(Self {
inner,
store,
run,
cap: cap_tokens,
})
}
/// The run this port records.
pub fn run_id(&self) -> &RunId {
&self.run
}
/// The token cap the run was opened under.
pub fn cap(&self) -> u64 {
self.cap
}
/// Tokens reserved or charged to the run so far.
pub fn spent(&self) -> u64 {
self.store.spent(&self.run).unwrap_or(u64::MAX)
}
/// The most this request can cost: its serialized bytes plus its output
/// allowance. A request with no output allowance has no bound, so it
/// cannot be held under a cap.
fn bound(request: &PreparedInference) -> Result<u64, InferenceFault> {
let allowance = request
.invocation
.request
.max_output_tokens
.ok_or_else(InferenceFault::budget_exhausted)?;
let payload = serde_json::to_vec(&request.invocation.request)
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
Ok((payload.len() as u64).saturating_add(u64::from(allowance)))
}
fn settle(
&self,
bound: u64,
identity: (InferencePurpose, String, String),
result: Result<InferenceOutput, InferenceFault>,
) -> Result<InferenceOutput, InferenceFault> {
// A fault leaves its reservation charged: the request may have run.
let output = result?;
let charged = match output.usage {
Some(usage) => usage.prompt.saturating_add(usage.completion),
None => bound,
};
if charged > bound {
self.store
.settle(&self.run, bound, charged, None)
.map_err(unrecorded)?;
return Err(InferenceFault::budget_exhausted());
}
let (purpose, model, content_sha256) = identity;
let record = ProviderCallRecord {
run_id: self.run.as_str().to_owned(),
sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?,
purpose,
model,
content_sha256,
events: output.events.clone(),
receipt_digest: output.receipt_digest.clone(),
usage: output.usage,
charged,
};
self.store
.settle(&self.run, bound, charged, Some(&record))
.map_err(unrecorded)?;
Ok(output)
}
}
impl Drop for RecordingInferencePort {
fn drop(&mut self) {
self.store.disown(&self.run);
}
}
fn unrecorded(_: CallRecordError) -> InferenceFault {
InferenceFault::integrity_violation("a provider call could not be recorded")
}
#[async_trait]
impl InferencePort for RecordingInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
self.inner.prepare(request)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
let bound = Self::bound(&request)?;
let identity = (
request.purpose,
request.invocation.request.model.clone(),
content_digest(&request.invocation.request)?,
);
self.store
.reserve(&self.run, bound, self.cap)
.map_err(|error| match error {
CallRecordError::OverCap => InferenceFault::budget_exhausted(),
_ => unrecorded(error),
})?;
let result = self.inner.infer(request).await;
self.settle(bound, identity, result)
}
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
self.inner.lend_tool_results(prepared, oracle);
}
}
/// Serves a run's stored replies, in sequence, to the same requests, matched
/// by what they ask of the provider and not by the ids that name them. It holds
/// no provider and makes no call: a request that is not the next recorded one
/// is a fault.
pub struct ReplayInferencePort {
caller_runtime_id: String,
pending: Mutex<VecDeque<ProviderCallRecord>>,
}
impl ReplayInferencePort {
/// Replays `run` from the store against `world`, a restored copy of the
/// world the run started from. Any other world is refused here, before a
/// call, with `RunMismatch` naming the field; a run the store never began
/// is `NotReplayable`.
pub fn new(
caller_runtime_id: impl Into<String>,
store: &CallRecordStore,
run: &RunId,
world: &RunStart,
) -> Result<Self, CallRecordError> {
let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?;
if began.world_id != world.world_id {
return Err(CallRecordError::RunMismatch(RunField::WorldId));
}
if began.state_digest != world.state_digest {
return Err(CallRecordError::RunMismatch(RunField::StateDigest));
}
Ok(Self {
caller_runtime_id: caller_runtime_id.into(),
pending: Mutex::new(store.records(run)?.into()),
})
}
}
#[async_trait]
impl InferencePort for ReplayInferencePort {
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
PreparedInference::prepare(
&self.caller_runtime_id,
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
request,
)
}
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
let mut pending = self
.pending
.lock()
.map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?;
let digest = content_digest(&request.invocation.request)?;
let Some(next) = pending.front() else {
return Err(InferenceFault::integrity_violation(
"replay has no recorded call left",
));
};
if next.content_sha256 != digest {
return Err(InferenceFault::integrity_violation(
"the request is not the next recorded call",
));
}
let output = next.output();
pending.pop_front();
Ok(output)
}
}
#[cfg(test)]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,143 @@
//! The canonical Aetheria verse world's genesis.
//!
//! One intent lowers to one `CreateWorld`: a commons, every grammar verb
//! declared as an affordance, and each Aetheria faction as an institution
//! imported by its record key. The world is bound to the catalog's grammar, so
//! the kernel refuses any affordance the grammar does not carry.
//!
//! Phase A verbs record the act and change no component: an outcome band with
//! no effects, and the act carried as the utterance that makes an entry
//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling
//! `verb-effects-ghostlight`), and those arrive with phase B.
use crate::aetheria::AetheriaCatalog;
use crate::mailbox::{MailboxError, commons_declaration, commons_ref};
use crate::patch::{
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef,
FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration,
kernel_speak_entry, kernel_speak_grant,
};
use crate::{
CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind,
WorldPatch, WorldScaleIntentRef,
};
use std::collections::BTreeMap;
const COMMIT_LEN: usize = 40;
/// What a caller states to create the canonical verse world. Everything else a
/// world needs (the commons, the subjects, the affordances, their grants and
/// the provenance of what is known) is derived from the catalog.
#[derive(Clone, Debug)]
pub struct CreateVerseWorldIntent {
pub id: CommandId,
pub title: String,
pub brief: String,
pub catalog: AetheriaCatalog,
/// The Aetheria commit the catalog was read at: 40 lower-case hex digits.
/// It is the provenance of every fact the world holds about a faction.
pub aetheria_commit: String,
pub lens_weights: LensWeights,
pub targets: BTreeMap<SubjectKind, u32>,
}
#[derive(Debug, thiserror::Error)]
pub enum VerseGenesisError {
#[error("aetheria_commit is not 40 lower-case hex digits")]
NotACommit,
#[error(transparent)]
Mailbox(#[from] MailboxError),
}
impl CreateVerseWorldIntent {
/// The one lowering. The kernel supplies the `speak` entry at genesis and
/// holds it to the grammar, so it is granted here and never declared.
pub(crate) fn lower(self, owner: PrincipalId) -> Result<CreateWorld, VerseGenesisError> {
let commit = &self.aetheria_commit;
if commit.len() != COMMIT_LEN
|| !commit
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
{
return Err(VerseGenesisError::NotACommit);
}
let speak = kernel_speak_entry().kind;
let mut grants = kernel_speak_grant();
let mut declarations = vec![commons_declaration()];
for (kind, verb) in self.catalog.grammar.verbs() {
if kind == speak.0 {
continue;
}
let handle = DraftHandle::new(format!("verb-{kind}"));
grants.insert(Ref::Draft(handle.clone()));
declarations.push(Declaration::Affordance(AffordanceDeclaration {
handle,
kind: AffordanceKindName(kind.to_owned()),
roles: verb.role_specs(),
preconditions: vec![Precondition::CanBroadcast {
via: AudienceSpec::Colocated,
}],
effect_slots: Vec::new(),
outcome_bands: vec![OutcomeBand {
weight: 1,
effects: Vec::new(),
}],
// The kernel refuses an entry that can neither change a
// component nor speak, and a speaking entry needs an audience,
// so a phase A verb is its own utterance to the room: the act
// is recorded as speech and nothing else changes.
carries_speech: true,
}));
}
let mut evidence = Vec::new();
for (position, faction) in self.catalog.factions.iter().enumerate() {
declarations.push(Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new(format!("faction-{position}")),
label: faction.name.trim().to_owned(),
kind: SubjectKind::Institution,
controller: NewController::OperationalAgent,
affordances: grants.clone(),
position: Some(commons_ref()),
import_key: Some(faction.key.clone()),
}));
// A faction with no description has nothing to state.
if let Some(statement) = Statement::new(faction.description.trim()) {
let receipt = EvidenceRef::new(format!(
"aetheria:{commit}:{}/{}",
faction.key.schema(),
faction.key.record()
));
evidence.push(receipt.clone());
declarations.push(Declaration::Fact(FactDeclaration {
handle: DraftHandle::new(format!("fact-{position}")),
label: faction.name.trim().to_owned(),
statement,
standing: FactStandingRef::Canonical { evidence: receipt },
}));
}
}
Ok(CreateWorld {
id: self.id,
owner,
title: self.title,
brief: self.brief,
patch: WorldPatch {
declarations,
operations: Vec::new(),
evidence,
},
scale_intent: WorldScaleIntentRef {
targets: self.targets,
jurisdictions: BTreeMap::new(),
},
lens_weights: self.lens_weights,
grammar: Some(self.catalog.grammar),
})
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,476 @@
use super::*;
use crate::aetheria::read_aetheria_catalog;
use crate::patch::{FactStanding, Mismatch};
use crate::tests::{auth_principal, owner, stock_weights};
use crate::{
CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb,
KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel,
WorldMailbox, WorldPhase,
};
use chrono::{Duration, Utc};
use std::collections::BTreeSet;
/// Aetheria master when the fixture catalog was written.
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
const FIXTURE: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/aetheria-verse-catalog.cc"
);
fn catalog() -> AetheriaCatalog {
read_aetheria_catalog(FIXTURE).unwrap()
}
fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent {
CreateVerseWorldIntent {
id: CommandId::new(),
title: "The Verse".into(),
brief: "Aetheria.".into(),
catalog,
aetheria_commit: commit.into(),
lens_weights: stock_weights(),
targets: BTreeMap::new(),
}
}
/// A world created through the kernel from the lowering, for what the
/// snapshot hides: the facts' evidence.
fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
let dir = tempfile::tempdir().unwrap();
let world = intent.lower(owner()).unwrap();
let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner()))
.map(|(kernel, _)| kernel);
(dir, result)
}
struct Verse {
_dir: tempfile::TempDir,
_task: tokio::task::JoinHandle<()>,
mailbox: WorldMailbox,
principal: VerifiedPrincipalEvidence,
receipt: crate::CreationReceipt,
}
async fn verse(intent: CreateVerseWorldIntent) -> Verse {
let dir = tempfile::tempdir().unwrap();
let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
let receipt = mailbox.create_verse(intent, &principal).await.unwrap();
Verse {
_dir: dir,
_task: task,
mailbox,
principal,
receipt,
}
}
#[tokio::test]
async fn verse_genesis_from_the_aetheria_fixture() {
let catalog = catalog();
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
// One institution per faction, keyed by its record, named by its Name.
let mut factions: Vec<_> = snapshot
.subjects
.iter()
.map(|subject| {
(
subject.import_key.clone().unwrap(),
subject.label.clone(),
subject.kind,
subject.controller_mode,
)
})
.collect();
factions.sort_by(|a, b| a.0.cmp(&b.0));
let expected: Vec<_> = catalog
.factions
.iter()
.map(|faction| {
(
faction.key.clone(),
faction.name.clone(),
SubjectKind::Institution,
Some(ControllerMode::OperationalAgent),
)
})
.collect();
assert_eq!(factions.len(), 2);
assert_eq!(factions, expected);
// One fact per faction, carrying its description.
let statements: BTreeSet<_> = snapshot
.facts
.iter()
.map(|fact| fact.statement.as_str().to_owned())
.collect();
assert_eq!(
statements,
catalog
.factions
.iter()
.map(|faction| faction.description.clone())
.collect::<BTreeSet<_>>()
);
// The world is bound to the catalog's grammar.
assert_eq!(snapshot.grammar, Some(catalog.grammar.identity()));
assert_eq!(verse.receipt.world_id, snapshot.world_id);
// Every faction stands in the one commons.
let commons: Vec<_> = snapshot
.places
.iter()
.filter(|place| place.label == "The Commons")
.collect();
assert_eq!(commons.len(), 1);
assert!(
snapshot
.subjects
.iter()
.all(|subject| subject.position == Some(commons[0].id))
);
}
#[test]
fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() {
for commit in [COMMIT, OTHER_COMMIT] {
let (_dir, kernel) = kernel_of(intent_of(catalog(), commit));
let kernel = kernel.unwrap();
let evidence: BTreeSet<String> = kernel
.state
.facts
.values()
.map(|fact| match &fact.standing {
FactStanding::Canonical { evidence } => evidence.text().to_owned(),
other => panic!("a verse fact is canonical, not {other:?}"),
})
.collect();
assert_eq!(
evidence,
catalog()
.factions
.iter()
.map(|faction| format!(
"aetheria:{commit}:aetheria.faction/{}",
faction.key.record()
))
.collect::<BTreeSet<_>>()
);
}
}
#[test]
fn a_commit_is_forty_lower_case_hex_digits() {
for bad in [
"",
&COMMIT[..39],
&format!("{COMMIT}0"),
&COMMIT.to_uppercase(),
&format!("{}g", &COMMIT[..39]),
] {
assert!(
matches!(
intent_of(catalog(), bad).lower(owner()),
Err(VerseGenesisError::NotACommit)
),
"{bad:?}"
);
}
assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok());
assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT));
}
#[test]
fn verse_genesis_refuses_a_duplicate_faction_record() {
let mut twice = catalog();
let first = twice.factions[0].clone();
twice.factions.push(first);
// The lowering does not decide: it declares all three.
let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap();
let subjects = world
.patch
.declarations
.iter()
.filter(|declaration| matches!(declaration, Declaration::Subject(_)))
.count();
assert_eq!(subjects, 3);
let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT));
let Err(KernelError::PatchRejected(set)) = kernel else {
panic!("a second faction under one record key was admitted");
};
assert!(
set.iter()
.any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })),
"{set:?}"
);
}
#[tokio::test]
async fn every_granted_affordance_is_a_grammar_verb() {
let catalog = catalog();
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
let mut kinds: Vec<_> = snapshot
.affordances
.iter()
.map(|affordance| affordance.entry.kind.0.clone())
.collect();
kinds.sort();
// Each verb once: the kernel's speak is the grammar's speak.
assert_eq!(
kinds,
catalog
.grammar
.verbs()
.map(|(kind, _)| kind.to_owned())
.collect::<Vec<_>>()
);
assert!(kinds.contains(&"speak".to_owned()));
// Each has its render path from the grammar, and phase A changes nothing.
for affordance in &snapshot.affordances {
let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap();
assert_eq!(affordance.render_path, Some(verb.render_path));
assert!(affordance.entry.effect_slots.is_empty());
assert_eq!(affordance.entry.outcome_bands.len(), 1);
assert!(affordance.entry.outcome_bands[0].effects.is_empty());
}
// Every faction is granted every verb.
let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect();
for subject in &snapshot.subjects {
assert_eq!(subject.affordances, all);
}
}
#[tokio::test]
async fn factions_get_opportunities() {
let verse = verse(intent_of(catalog(), COMMIT)).await;
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let revision = verse.mailbox.snapshot().await.unwrap().revision;
verse
.mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: verse.receipt.world_id,
expected_revision: revision,
body,
},
&verse.principal,
)
.await
.unwrap();
}
let snapshot = verse.mailbox.snapshot().await.unwrap();
assert_eq!(snapshot.phase, WorldPhase::Active);
assert_eq!(snapshot.opportunities.len(), catalog().factions.len());
let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect();
assert_eq!(scopes.len(), snapshot.opportunities.len());
for opportunity in &snapshot.opportunities {
assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent);
assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len());
}
}
#[test]
fn a_faction_without_a_description_holds_no_fact() {
let mut bare = catalog();
bare.factions[0].description = " ".into();
bare.factions[1].description = " Second. ".into();
let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT));
let facts: Vec<_> = kernel
.unwrap()
.state
.facts
.values()
.map(|fact| fact.statement.as_str().to_owned())
.collect();
assert_eq!(facts, vec!["Second.".to_owned()]);
}
#[test]
fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() {
let mut mute = catalog();
mute.grammar = GrammarBinding::new(
"aetheria.verse_grammar",
1,
[(
"haul".to_owned(),
GrammarVerb {
render_path: RenderPath::ShipAction,
roles: vec![GrammarRole {
role: "cargo".into(),
referent: GrammarReferentKind::Cargo,
}],
},
)],
)
.unwrap();
let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT));
let Err(KernelError::PatchRejected(set)) = kernel else {
panic!("a world whose grammar has no speak was admitted");
};
assert!(
set.iter()
.any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })),
"{set:?}"
);
}
#[tokio::test]
async fn the_dungeon_ingress_still_creates_an_unbound_world() {
let dir = tempfile::tempdir().unwrap();
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1));
mailbox
.create(
crate::CreateWorldIntent {
id: CommandId::new(),
title: "Dungeon".into(),
brief: String::new(),
human_subject_label: "Player".into(),
narrative_persona_label: None,
operational_agent_label: None,
targets: BTreeMap::new(),
jurisdictions: Vec::new(),
lens_weights: stock_weights(),
},
&principal,
)
.await
.unwrap();
let snapshot = mailbox.snapshot().await.unwrap();
assert_eq!(snapshot.grammar, None);
assert!(
snapshot
.places
.iter()
.any(|place| place.label == "The Commons")
);
}
#[tokio::test]
async fn a_faction_name_is_trimmed_into_its_institution_label() {
let mut padded = catalog();
let expected = padded.factions[0].name.trim().to_owned();
padded.factions[0].name = format!(" {expected}\t ");
let key = padded.factions[0].key.clone();
let verse = verse(intent_of(padded, COMMIT)).await;
let snapshot = verse.mailbox.snapshot().await.unwrap();
let subject = snapshot
.subjects
.iter()
.find(|subject| subject.import_key.as_ref() == Some(&key))
.expect("the padded faction was created");
assert_eq!(subject.label, expected);
}
#[tokio::test]
async fn genesis_twice_with_one_intent_is_one_creation() {
let dir = tempfile::tempdir().unwrap();
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
let principal =
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
let intent = intent_of(catalog(), COMMIT);
let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap();
let before = mailbox.snapshot().await.unwrap();
let again = mailbox.create_verse(intent, &principal).await.unwrap();
let after = mailbox.snapshot().await.unwrap();
assert_eq!(again, first);
assert_eq!(after.revision, before.revision);
assert_eq!(after.state_digest, before.state_digest);
assert_eq!(after.subjects.len(), before.subjects.len());
}
#[tokio::test]
async fn genesis_under_another_command_id_finds_the_world_occupied() {
let verse = verse(intent_of(catalog(), COMMIT)).await;
let before = verse.mailbox.snapshot().await.unwrap();
let refused = verse
.mailbox
.create_verse(intent_of(catalog(), COMMIT), &verse.principal)
.await;
assert!(
matches!(
refused,
Err(VerseGenesisError::Mailbox(MailboxError::Kernel(
KernelError::CreationTargetOccupied
)))
),
"{refused:?}"
);
let after = verse.mailbox.snapshot().await.unwrap();
assert_eq!(after.revision, before.revision);
assert_eq!(after.state_digest, before.state_digest);
}
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
/// the lowering admits a ship verb only as an utterance,
/// and committing it asserts a claim fact the room hears.
#[test]
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
use crate::tests::{command, opportunity_for, spoken, submit_owner};
use crate::{
AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt,
Target,
};
let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT));
let mut kernel = kernel.unwrap();
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let snapshot = kernel.snapshot().unwrap();
submit_owner(&mut kernel, &snapshot, body);
}
let active = kernel.snapshot().unwrap();
let attack = active
.affordances
.iter()
.find(|a| a.entry.kind.0 == "attack")
.unwrap()
.id;
let [first, second] = [&active.subjects[0], &active.subjects[1]];
let opportunity = opportunity_for(&active, first.id);
let caller = CallerId::Controller(opportunity.controller_id);
let invocation = |speech: Option<&str>| DecisionInvocation {
affordance: attack,
bindings: vec![RoleBinding {
role: Role("target".into()),
target: Target::Subject(second.id),
}],
proposed: Vec::new(),
speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()),
display: None,
};
let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| {
kernel.submit(
command(
&active,
CommandId::new(),
caller.clone(),
CommandBody::ExerciseDecision {
opportunity: opportunity.clone(),
invocation: invocation(speech),
},
),
&AuthenticatedCaller::fixture(caller.clone()),
)
};
// Without an utterance the act is refused: the verb has no other way in.
assert!(try_it(&mut kernel, None).is_err());
// With one, the act commits and its words are a claim fact.
let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits");
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
let event = kernel.state.events.last().unwrap();
assert_eq!(spoken(&kernel.state, event), Some("We strike."));
}

View file

@ -0,0 +1,19 @@
//! The Aetheria verse run's own organs. The verse binary sits above this
//! module; here sit the parts of a run that belong to the library: the record
//! of every provider call and the spend it implies, the genesis of the
//! canonical verse world, and the manual run that advances it with its record.
mod calls;
mod genesis;
mod run;
pub use genesis::{CreateVerseWorldIntent, VerseGenesisError};
pub use calls::{
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord,
RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,
};
pub use run::{
RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus,
TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
call_run_id, run_manual,
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,16 @@
aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight.
Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test
Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs),
which authors VerseGrammar, three VerseVerb and two Faction records through
Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH.
Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc
(contains aetheria-release cut verse-grammar, merged at 35af2930).
Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc
dotnet test tests/Aetheria.Shared.Tests
-p:CultLibRoot=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
--filter "FullyQualifiedName~Fixture_writer_is_deterministic"
run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10.
Content: six records; two writes of the writer agree on content, though not bytes
(CultCache stamps storedAt from the wall clock). Regenerate and diff on content,
never hand-edit.

Binary file not shown.

View file

@ -0,0 +1,3 @@
3
sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d
sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979

View file

@ -0,0 +1 @@
"a534b32a-4d00-429c-bf6c-ce1d55385380"

View file

@ -1,189 +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",
"--nocapture",
]
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.v17"
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

@ -307,7 +307,7 @@ provider request is a distinct round (`interpreter_round`) with its own
content-addressed request id, so it cannot collide with the first lowering's
request. State schema is `ghostlight.world_state.consumer.v6`, commit schema
`ghostlight.world_commit.consumer.v6`, controller work
`ghostlight.controller_work.v17`, Persona turn receipt
`ghostlight.controller_work.v18`, Persona turn receipt
`ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are
refused. Ghostlight owns a conserved narrative ledger;
Delvehold owns the economy (`delvehold-forced-ontology-integration.md`).

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),
),
},
},
});