Compare commits

..
Author SHA1 Message Date
Meta Vens
f6ffe16fd2 Verse map: the identity table names the unsent awaiting row state
Closes follow-up map-row-unsent-awaiting (ruling unsent-awaiting-row-is-awaiting).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 20:55:52 +02:00
Meta Vens
35d0fd2797 Verse map: run identity re-map after Soul s1 (tick named by world clock ordinal, frozen tick document, controller lookup by command id)
Body facts R1-R8, the identity/lifecycle/authority table for tick, cell,
command id, clock advance, run, run sequence and call ledger, and the fix
order for specs verse-run r3, verse-run-lookup, verse-run-refusals and
verse-run-claim. Research note: how event-sourced workflow engines and
lockstep sims persist turn identity for resume and replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:03:22 +02:00
Meta Vens
5f5f23f909 Verse sim: target r3 and phase A cut order
Imagination pass (session self-2026-10-10-ag) folds the seven 2026-10-10
verse-sim rulings into target revision 3, fills the model page cells they
settle, and records phase A's body facts and cut order. The r3 draft is
folded into the target.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 06:14:00 +02:00
Meta Vens
d8179506dd Verse sim: port inventory, model page and target r3 draft
Imagination 0b pass (session self-2026-10-10-ag) under rulings
verse-sim-pipeline, gestalt-machinery-port and teardown-was-authority-sprawl.
Old gestalt machinery inventoried at f9f019b; seven operator questions
admitted. Target r3 is a draft until the questions are ruled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 05:37:20 +02:00
Meta Vens
3a61eab3da Map: the Bonsai host under ruling bonsai-idle-unload-host
Body facts B1-B11 from the PrismML fork source and the Eyes report
(Raven unreachable at mapping time: every Raven-side claim is unprobed),
the Bonsai lifecycle row on the model page, and the rationale for
sleeping inside llama-server instead of a reaper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 19:58:48 +02:00
545 changed files with 105898 additions and 14500 deletions

6
.gitignore vendored
View file

@ -1,5 +1,11 @@
__pycache__/
*.pyc
.venv/
node_modules/
web/dist/
sidecar/claude-sdk/dist/
state/scratch-compaction-*.md
experiments/images/
target/
# Disposable local scaffolding. These accumulated to ~87MB of untracked debris

3
.gitmodules vendored Normal file
View file

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

305
AGENTS.md
View file

@ -2,106 +2,233 @@
## Project Purpose
Ghostlight is a Rust world kernel and the play daemon built on it. Models
propose; the kernel admits or refuses. The same kernel is meant to run the
simulation behind Aetheria's public story, a persistent cast and storylets, as
well as Ghostlight Dungeon, the single-player DM-led client (parked at
tag `parked/ghostlight-eve-surface`). `README.md` says
what exists today and what does not.
Ghostlight is a research and implementation home for socially persistent
generative agents. The core problem is not "make an LLM sound like a person."
The core problem is building an architecture where personality, culture,
memory, masks, perception, and incentives can actually survive contact with
time, relationships, routine, tonal variation, and pressure.
## Where State Lives
This repo exists so those organs can live somewhere cleaner than a side chamber
inside another project.
- Campaign state (targets, rulings, cut specs, reports, verdicts, findings,
follow-ups) lives in the Eureka mind, not in this repo. The current campaign
is `ghostlight-verse`; query it through the `eureka-state` tools.
- `notes/ghostlight-verse-target.md` and `notes/ghostlight-verse-map.md` are the
campaign's prose target and map. Read the map for probed body facts.
- This repo has no state directory, no state CLI, and no evidence ledger. Do not
create one. History belongs in git; durable decisions belong in the mind.
- `notes/fresh-workspace-handoff.md` is a pointer: what runs, how it deploys,
where open work is queried.
- Dated cut documents and postmortems (`docs/architecture/*-cut.md`,
`*-postmortem.md`) are history. Do not edit them, including their old paths.
## Canonical State
## Invariants
- Treat `state/ghostlight-state.cultcache.jsonl` as the CultCache-backed
persistence spine for machine-managed state records that have migrated.
- Treat `state/map.yaml` as the canonical human-readable project map.
- Treat `state/scratch.md` as disposable working memory for one bounded
subgoal.
- Treat `state/evidence.jsonl` and `state/branches.json` as readable export
surfaces during the persistence migration, not as the long-term direct-write
API. A direct write to either is silently reverted: every `add-evidence`
regenerates `evidence.jsonl` from the CultCache evidence document, so edits
and deletions that do not go through `tools/ghostlight_state_store.py` look
applied until the next append restores what you removed. Compaction means
replacing the store's evidence document through
`ghostlight_state.py compact-evidence --replacement <distilled.jsonl>`,
which archives every current record verbatim to
`state/evidence.archive.jsonl` first; the export follows.
- Treat `state/evidence.jsonl` as the distilled durable ledger of what was
learned, verified, rejected, or accepted.
- Treat `notes/ghostlight-implementation-plan.md` as the current implementation
plan for the larger organs.
- Update `state/map.yaml` when project understanding changes.
- Add evidence after meaningful research, implementation, verification, or
rejected paths, but keep it distilled. Routine "I just did this" proof
belongs in git history, commits, smoke artifacts, or targeted logs unless it
changes what the next session should believe.
- Do not turn notes, map, handoff, and evidence into four versions of the same
brain. Distinct jobs or bust.
- **The kernel decides.** The kernel owns world state through its closed
operation set. Models, tools, controllers, and consumers produce proposals
only. No code outside the kernel commits world state, and no compatibility
writer exists.
- **The library is sealed.** `crates/ghostlight` exposes create/open,
immutable snapshots, command submission, typed receipts, and the runner and
port entry points. Do not publish mutable world types, an ID issuer, a reducer
entry, or a journal handle for the convenience of tests or adapters. The seal
is proven by the `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
and by `crates/ghostlight/tests/external_admission.rs`; keep both passing and
extend them when the public surface changes.
- **Every commit is a command.** Everything enters through the mailbox
(`crates/ghostlight/src/mailbox.rs`) as a command. Do not append a commit when
reduction produces no canonical mutation.
- **A Persona never sees structured state.** Persona turns are prose; an
Interpreter lowers them to typed proposals, and anything it cannot lower
becomes a recorded gap, not invented state.
- **Typed state.** Runtime documents are CultCache `.cc`. JSON is for schema
publication and boundaries with other systems.
- **Credentials.** Ghostlight never reads, copies, forwards, or logs a model
credential. A consumer never restates a foreign owner's document shape as its
own strict struct; read the owner's published contract and test against the
real counterpart.
- **Canon flows one way.** Simulation output never writes back into AetheriaLore
or Aetheria's catalog without operator review.
## Important Paths
## Where Things Are
- Project root: `F:\Projects\Ghostlight`
- Canonical map: `F:\Projects\Ghostlight\state\map.yaml`
- Scratch surface: `F:\Projects\Ghostlight\state\scratch.md`
- Distilled evidence ledger: `F:\Projects\Ghostlight\state\evidence.jsonl`
- Branch ledger: `F:\Projects\Ghostlight\state\branches.json`
- Handoff summary: `F:\Projects\Ghostlight\notes\fresh-workspace-handoff.md`
- System map: `F:\Projects\Ghostlight\notes\ghostlight-current-system-map.md`
- Implementation plan: `F:\Projects\Ghostlight\notes\ghostlight-implementation-plan.md`
- Architecture rationale: `F:\Projects\Ghostlight\notes\architecture-rationale.md`
- State CLI: `F:\Projects\Ghostlight\tools\ghostlight_state.py`
- CultCache state seam: `F:\Projects\Ghostlight\tools\ghostlight_state_store.py`
- CultCache-Py dependency: pinned `cultcache-py==0.2.0` from PyPI, declared in
`F:\Projects\Ghostlight\tools\requirements.txt`
- CultCache state store: `F:\Projects\Ghostlight\state\ghostlight-state.cultcache.jsonl`
- Pre-compaction helper: `F:\Projects\Ghostlight\tools\ghostlight_prepare_compaction.py`
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
`docs/architecture/ghostlight-world-consumer-api.md`.
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
tag `parked/ghostlight-eve-surface`; not on main.
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
- Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
rebuild, not the current plan.
## Useful Commands
## Build, Verify, Deploy
Use the bundled Python runtime if `python` is not on PATH. `npm run state:status`
and `npm run state:prepare-compaction` install the pinned `cultcache-py`
dependency automatically (a `pre*` npm hook runs `state:bootstrap` first, an
idempotent `pip install -r tools/requirements.txt`). A direct
`ghostlight_state.py` invocation does not go through npm, so run
`state:bootstrap` once per session before the first direct call:
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`.
```powershell
npm run state:bootstrap
npm run state:status
npm run state:prepare-compaction
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-evidence --type research --status ok --note '...'
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' add-branch --id branch-id --hypothesis '...'
& 'C:\Users\Meta\.cache\codex-runtimes\codex-primary-runtime\dependencies\python\python.exe' '.\tools\ghostlight_state.py' compact-evidence --replacement '.\distilled.jsonl'
```
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
`F:\Projects\gamecult-ops` before acting.
## Build And Deploy Target
Ghostlight Dungeon ships to Linux on Yggdrasil. Yggdrasil is its build, test,
and production body: Idunn freezes an exact commit, runs the locked native
Linux tests, builds the daemon and web projection in pinned Linux containers,
and seals the release. Owning runbook:
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
You are almost certainly reading this on a Windows workstation. That is where
the shell is, not where the artifact runs. A Windows compile of a commit is not
evidence about the Linux release — toolchain, linker, filesystem semantics and
path handling all differ — and it is not the build the deploy path will use.
Build for the target, or let the deploy path's own builder do it, and say which
one you used.
## Session Bootstrap And Re-entry Protocol
On fresh session load, do this before wandering off into implementation:
1. read:
- `state/map.yaml`
- `notes/fresh-workspace-handoff.md`
- `notes/ghostlight-current-system-map.md`
- `notes/ghostlight-implementation-plan.md`
2. run:
- `npm run state:bootstrap` (installs the pinned `cultcache-py` dependency;
idempotent, safe to rerun)
- `npm run state:status`
3. restate the current next action from the persisted state before starting
edits
4. if the user only asked to rehydrate or reorient, stop after orientation and
wait for an explicit continue instruction instead of treating the persisted
next action as permission to start coding
After compaction, resume, or suspicious continuity loss:
1. rerun `npm run state:status`
2. reread `state/map.yaml` and `notes/fresh-workspace-handoff.md`
3. treat the persisted next action as authoritative unless fresh repo evidence
contradicts it
When context pressure is clearly rising:
1. stop broad exploration
2. narrow the active move to one bounded organ
3. persist map or handoff updates, plus distilled evidence only when the lesson
changes future belief, before the blackout lands
When the user says to prepare for imminent compaction:
1. immediately write hot live context from memory into a new
`state/scratch-compaction-<guid>.md` file before reading files, running
status checks, or doing tidy persistence work
2. run `tools/ghostlight_prepare_compaction.py` before editing persistence
surfaces
3. use its warnings as the checklist for map, handoff, scratch, evidence, and
git hygiene
4. update only the state that actually needs to change
5. after hot context has been folded into canonical persisted state, delete the
`state/scratch-compaction-<guid>.md` file so stale emergency memory does not
linger
6. run `tools/ghostlight_prepare_compaction.py` again after edits and scratch
cleanup
7. fix errors, address warnings, and commit the completed persistence pass
unless the work is deliberately mid-surgery
## Operating Discipline
- Restate the current mechanism and intended change before substantial edits.
Prefer one clear hypothesis per iteration.
- Verify with checks that reflect the real goal, not proxy success. Revert
changes that do not clearly improve the target.
- If the diff grows while understanding shrinks, stop and diagnose.
- Delete obsolete authority before adding a new path. Do not keep an old writer
alive behind a shim.
- Commit completed work and push it. Merge, confirm the tip by SHA, then delete
the branch; push only after reading the test result line.
- In documentation, describe the live system and the current rule directly. No
claim of capability without a file that implements it. Keep rejected history
short and decision-relevant, or leave it in git.
- Steward persistent state as part of the work. If a lesson changes future
behavior, update the smallest correct memory surface before moving on; if a
memory surface is stale, duplicated, or making re-entry slower, cut or
consolidate it.
- Before substantial edits, restate the current mechanism and intended change.
- Prefer one clear hypothesis per iteration.
- Verify with checks that reflect the real goal, not just proxy success.
- Revert or discard changes that do not clearly improve the target.
- If the diff grows while understanding shrinks, stop implementation and switch
to diagnosis.
- Keep maps and prose together; do not replace useful maps with prose-only
explanations.
- Commit completed work before it rots in the worktree unless the task is
deliberately mid-surgery or the user asked to leave changes uncommitted.
- Push after major completed passes when a remote exists, unless the user asked
not to or there is a concrete reason to keep the commit local for a moment.
- Before handoff, compaction, or phase boundaries, sync `state/map.yaml`, add
distilled evidence when the lesson changes future belief, refresh
`notes/fresh-workspace-handoff.md`, and make the next action explicit.
- Treat `state/map.yaml` as a working memory map, not an archive. Preserve
current mission, live seams, boundaries, and next action; let git history,
evidence, and artifact files carry chronology.
- In documentation, describe the live system and current rule directly. Do not
write victory laps, scar tours, clever autopsies, or contrastive little
boasts about what the project no longer does. If a sentence congratulates the
design for not being a previous wrong design, cut it or rewrite it as a plain
current rule. If rejected history matters, keep it short, explicit, and
decision-relevant; otherwise cut it. The docs are not a trophy case for
yesterday's wrong machine.
## Aetheria Grounding
## Aetheria Grounding Doctrine
- Before writing or generating Aetheria material, check AetheriaLore for the
factions, institutions, species or body types, location, and time period
involved. Ground detail in the source's material facts: habitat, law, money,
route access, surveillance, communication channels, and local failure costs.
- If the source is too vague for a concrete detail, do not bluff. Mark the gap
and propose a narrow elaboration for AetheriaLore, subject to operator review.
- Choose a tonal mode on purpose. Aetheria supports wit with stakes, warmth,
absurdity, domestic texture, ritual quiet, wonder, horror, noir, and dry
systems prose; do not default to constant crisis. Comic precision in the
Adams/Pratchett manner is a good default, where jokes reveal stakes instead of
dissolving them.
- Establish ordinary life before interruption: routines, work, relationships,
small desires, and local accommodations are worldbuilding, not filler.
- Before writing or generating Aetheria scenes, check AetheriaLore for the
factions, movements, institutions, species or body types, location, and time
period involved.
- Before drafting story prose, choose an intentional tonal mode or blend for the
scene. Aetheria should support wit-with-stakes, warmth, absurdity, domestic
texture, ritual quiet, wonder, horror, noir, dour poetry, and dry systems
prose as needed. Do not default to constant crisis or procedural severity.
- Use Adams/Pratchett-style comic precision and humane levity as a useful
default touchstone when no stronger scene-specific style is called for:
jokes and absurdity should reveal stakes, not dissolve them.
- Establish ordinary life before interruption when the fixture calls for it:
routines, work texture, relationships, private jokes, rituals, small desires,
and local accommodations are part of worldbuilding, not filler.
- For illustrated Ink fixtures, segment prose into click-through visual
sections. Distinct places, focal areas, object closeups, arrivals, alarms,
pivotal one-sentence beats, and aftermath states need stable visual scene ids
and imagegen-ready prompts.
- Treat visual artifacts as illustrated replay and website collateral, not core
social-model training signal. Visual review judges the playable Ink
experience plus the visual plan, not clean-run prose receipts.
- Write visual prompts for a general image model, not a lore expert. Explain
fictional objects and body affordances in visible terms: geometry, materials,
lighting, stance, interfaces, tools, supports, cables, fluids, screens, and
crowd positions.
- Do the same for environments. A location label like "manifold line," "cavity
yard," "supervisor glass," or "anchor rail" is not a drawable scene until the
prompt describes its visible geometry, materials, interfaces, lights, and
scale.
- If a fixture has a target visual style, store that style cue once in the
sidecar and include it in every assembled image prompt. Do not rely on memory
or unstated website context to carry the look.
- Do not treat a character name as a visual description. Illustrated fixtures
need stable character visual refs for every recurring named character, and
scene prompts should either include those refs during prompt assembly or carry
enough appearance detail to stand alone.
- Include visual character refs only for characters actually visible in the
rendered frame. Do not feed imagegen refs for absent, off-frame, merely
mentioned, or object-represented characters.
- Treat visual continuity notes as tool/reviewer constraints, not prompt text.
If imagegen needs the fact, rewrite it as concrete visible description in the
base prompt, character ref, or branch/state modifier.
- Treat generated environment images as concept and material reference, not
spatial truth. Multi-angle or branch-consistent illustrated IF needs a durable
scene-set source such as a hand-built blockout, procedural scene file, layout
asset, or camera map. Imagegen can paint over camera-specific visual input;
it should not be asked to preserve room topology from a prior generated
image or text memory.
- Ground scene writing in the source's material details: habitat form,
infrastructure, law, class pressure, money, route access, surveillance,
communication channels, body affordances, and local failure costs.
- If the source is too vague for a concrete scene detail, do not bluff. Mark
the lore gap, patch the AetheriaLore source with a narrow elaboration, then
regenerate or revise the Ghostlight artifact from that patched source.
- Treat this as part of data generation. Ghostlight is expected to stress-test
Aetheria at room scale, find missing granularity, and push useful
elaborations back into the lore vault.

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,10 +13,19 @@ 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"] }
@ -24,7 +33,9 @@ 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"

276
README.md
View file

@ -1,121 +1,197 @@
# Ghostlight
**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).
Ghostlight builds persistent generative people and worlds. Its active product is
[Ghostlight Dungeon](https://ghostlight.gamecult.org): a Vault-grounded narrative
simulation in which places, knowledge, relationships, institutions, clocks, and
consequences survive beyond the model's current context.
**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.
The project began as a research and fixture pipeline for socially persistent
Aetheria agents. That material remains useful regression evidence. The live
machine is now a Rust runtime with typed campaign state, one canonical world
authority, bounded Persona projection, fiction-first action resolution, and a
multiresolution agency graph for keeping large settings active at finite cost.
**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.
## What Ghostlight Owns
- `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`).
- `WorldKernel` is the sole owner of committed campaign state and revisions.
- Every player command, NPC action, strategic tick, wait, import, and reload
enters the same validated campaign mailbox and atomic CultCache commit path.
- Foreground actions, NPC reactions, strategic Gestalt activity, waits, travel,
and population fission lower into one closed semantic world-mutation algebra.
Means and intended effects remain proposals; only an admitted mutation batch
can change canonical components. There is no model-authored JSON Patch path.
- Vault evidence constrains world compilation without pretending every playable
route, procedure, or local institution is already written down. The compiler
synthesizes the smallest compatible branch elaboration, exposes it for
approval, and keeps it distinct from canon. Details that must not vary belong
in the Vault.
- Hosted lore Vaults are planned as Git-synchronized, Obsidian-compatible
Markdown hierarchies. The Vault service owns their indexes; campaigns retain
only manifest bindings and exact evidence receipts.
- Projector → Persona → Interpreter turns produce proposals. Models never
commit world state.
- Knowledge, perception, capability, location, custody, and authority are
validated against the exact actor proposing an action.
- Fiction-first d20 resolution treats player text as an attempt rather than an
accomplished fact.
- Multiresolution Gestalts keep people, institutions, and factions active under
a bounded Persona-cell budget without merging rivals into one mind or erasing
named individuals.
- Away-time simulation advances clocks and remote agency without puppeting or
directly harming an absent player character.
- CultMesh publishes typed service and Eve/CultUI state; the browser lowers that
surface and does not become a second state authority.
- Odin receives Ghostlight's advertised boundary schemas and typed projections
over ACK-bounded RUDP publication. Internal runtime schemas are not treated as
discovery contracts merely because Ghostlight owns them.
Start with those two documents, then `cargo doc -p ghostlight --open`.
## Eve-native interface
**Populate the algebra first.** The kernel owns the component operations; a world owns its
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
Speak, which records an utterance and changes nothing. Until your world declares affordances
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
will be thin no matter how good the model is. The Aetheria verse genesis
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
only record the act; their effects are the next phase.
Ghostlight publishes one stable logical surface, `ghostlight.play`, for
anonymous entry, Session Zero, Contract Review, and campaign play. The browser
contains Eve's canonical provider host and lowering package plus Heimdall's
access-plugin adapter. It does not contain Ghostlight-specific screen renderers
or domain API routing.
## Elaborators
Editable Eve bindings are the input model. Text, number, and choice controls
bind either provider-owned typed state or renderer-local draft state; an
operation captures named bindings and submits one canonical Eve command.
Rejected or stale commands preserve local drafts, while an accepted receipt may
clear named bindings before the host refetches the authoritative surface. A
browser may lower this interaction to an HTML form for accessibility, but forms
are not part of Eve's state or command ontology.
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.
Heimdall authentication is composed as the required
`gamecult.heimdall.access` Eve plugin. Heimdall owns OAuth attempts, provider
callbacks, claims, entitlements, and single-use completion redemption.
Ghostlight owns only its hashed HttpOnly app session and derives campaign
authority from canonical membership. The browser sees an opaque attempt handle,
never a claim, refresh credential, account hash, member ID, or actor authority.
See
[`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md).
## Direction (planned, not built)
## Current Status
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`.
Ghostlight Dungeon is implemented and deployed as a native Yggdrasil,
Heimdall-gated playtest harness. Idunn owns daemon continuity, Odin owns Verse
discovery, and the application remains the sole owner of campaign truth.
Authentication commands resolve Heimdall's redacted private boundary through
Odin; no direct route is browser- or unit-owned, and valid local sessions do
not phone home on routine commands. The
current acceptance surface covers:
## Ghostlight Dungeon (parked)
- persistent DM-led Session Zero with shared/private channels, typed contracts,
private boundaries, character bargains, digest-bound unanimous approval, and
atomic publication;
- source-constrained world compilation from an approved brief: canon evidence
pins what must remain true, compatible game-scale connective tissue becomes
disclosed branch-local state, and genuine premise conflicts return to
negotiation instead of borrowing a nearby story;
- selectable Aetheria and Kalsa Vaults, with Kalsa's player-safe `Public` and
GM-only `Spoilers` evidence lanes preserved through exact receipts;
- persistent campaigns, forks, resets, exports, and Heimdall-account-isolated sessions;
- parallel affected-character Projector/Persona/Interpreter waves;
- impossible-action refusal, assessed stakes, server-side rolls, and receipted
commits;
- strategic clocks, away-time catch-up, institution activity, migration, and
information-channel-aware news;
- bounded shared-scene co-op for up to eight Heimdall members, with exact
member→actor authority, actor-filtered surfaces, unanimous time/budget
governance, and no player puppeting or PvP mutation;
- connected cohesive and arena simulation covers at budgets from 1 to 128;
- Gestalt materialisation, folding, member deltas, migration, and later
rematerialisation of the same person;
- atomic rejection of malformed, stale, or semantically invalid model waves;
- one kernel-owned mutation reducer for foreground, reaction, strategic, time,
travel, population-fission, and bounded region-expansion consequences, with
exact component versions and mutation proof receipts;
- provider, token, cache, latency, validation, state-version, and build receipts;
- provider-neutral inference through either direct DeepSeek/OpenRouter
boundaries or the independent CodexConnector daemon's encrypted loopback
CultNet boundary, while every prompt, schema, retry, interpretation, and world
commit remains Ghostlight's;
- exact-build deployment, state migration, public cutover, and restart
verification on Yggdrasil.
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.
Initial compiler seed publication is a bounded empty-store creation
transaction. Named-person materialisation and folding are resolution
transactions that preserve individual deltas without rewriting their Gestalt.
The remaining forge gates are expansion and review of the agency corpus beyond
its current candidate seed, removal of legacy model effect schemas,
multi-account human pressure testing of Session Zero privacy/publication and
bounded co-op, and continued multiresolution Gestalt agency pressure. The
public site is provisional; checkout is not live.
## Repo map
## Bounded Co-op
| 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. |
Campaign creation now supports one to eight authenticated players. One
`SessionZeroKernel` owns negotiation; after unanimous approval, one
`WorldKernel` owns the shared campaign. Membership binds each account to one
exact actor, and every player receives an actor-filtered Eve projection.
`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.
This milestone is intentionally one shared scene with sequential public
actions. PvP, split parties, private in-play actions, delegation, late joining,
and simultaneous declarations remain closed until their consent and governance
contracts exist. See
[`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md)
and the longer-term
[`multiplayer intention`](docs/architecture/ghostlight-dungeon-multiplayer-intention.md).
## Build and test
## Lore Vault Product Intention
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
workstation:
The hosted product will accept ordinary Obsidian-compatible lore Vaults synced
through Git. Contributor and Private plans provision one active custom Vault;
their Plus variants provision three, subject to combined indexed-source and
import allowances. The entitlement, accounting rules, authority boundary, and
security gates are recorded in
[`docs/product/lore-vault-entitlements.md`](docs/product/lore-vault-entitlements.md).
Custom tenant import is not yet part of the deployed tester harness.
```sh
cargo build --locked --workspace
cargo test --locked -p ghostlight-persona-projection
cargo test --locked -p ghostlight
cargo test --locked -p ghostlight-verse
## Architecture
The shortest reliable re-entry path is:
- [`docs/architecture/ghostlight-dungeon-mvp.md`](docs/architecture/ghostlight-dungeon-mvp.md): runtime authority, compiler, action loop, persistence, hosting, and security;
- [`docs/architecture/ghostlight-dungeon-session-zero.md`](docs/architecture/ghostlight-dungeon-session-zero.md): campaign negotiation, privacy, publication, membership, and bounded co-op;
- [`docs/architecture/ghostlight-multiresolution-agency.md`](docs/architecture/ghostlight-multiresolution-agency.md): dynamic Gestalt partitioning, cohesive and arena cells, fairness, and atomic strategic waves;
- [`docs/architecture/ghostlight-eve-native-interface.md`](docs/architecture/ghostlight-eve-native-interface.md): Eve bindings, the stable provider surface, private Heimdall command plane, app-session custody, and browser cut line;
- [`docs/architecture/ghostlight-transition-algebra.md`](docs/architecture/ghostlight-transition-algebra.md): canonical subjects and components, semantic mutations, admission envelopes, atomic reduction, and the remaining writer migration;
- [`notes/ghostlight-current-system-map.md`](notes/ghostlight-current-system-map.md): current implemented pipeline;
- [`notes/ghostlight-implementation-plan.md`](notes/ghostlight-implementation-plan.md): live sequence and next pressure tests;
- [`state/map.yaml`](state/map.yaml): canonical human-readable project state.
## Repository Shape
- `crates/ghostlight/`: the library crate — world kernel, ontology and reducer,
journal, mailbox, controllers, elaboration, cover, clock, consumer admission,
inference ports, and evidence sources;
- `crates/ghostlight-dungeon/`: Rust daemon consuming that library — HTTP
routes, Eve projection/command ingress, Heimdall and app sessions, CultMesh
publication, Idunn health, and acceptance harnesses;
- `crates/ghostlight-persona-projection/`: generalized projection membrane owned
by Ghostlight and consumed by Epiphany;
- `docs/architecture/`: durable contracts and authority maps;
- `docs/articles/`: accessible public explanations;
- `notes/`: implementation planning, handoff, and current-system maps;
- `schemas/`: published JSON Schemas for typed boundary documents;
- `examples/`: earlier fixtures and regression material;
- `state/`: human-readable project memory plus older research-pipeline state;
- `tools/`: state, validation, and fixture helpers.
- `web/`: thin Eve browser host, Ghostlight transport, and Heimdall plugin
adapter; product state and product-specific rendering remain server-owned.
Runtime documents and campaign exports use MessagePack-backed CultCache `.cc`.
JSON exists at schema publication, browser, MCP, model-provider, and diagnostic
boundaries; it is not the canonical hosted state store.
## Useful Commands
```powershell
cargo test --workspace
npm run state:status
npm run state:prepare-compaction
npm run schema:validate
```
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.
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.
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
publication, model-provider and diagnostic boundaries.
For Codex-driven work, read `AGENTS.md` before changing the repository. It owns
the persistence, grounding, verification, and handoff discipline.

View file

@ -0,0 +1,8 @@
Beyond the Confluence, approach and admit a politically inhabited elven forest realm whose watershed law, living-landscape institutions, rival offices, settlements, and public routes respond independently to the deep crisis.
Beyond the Confluence, approach and admit a politically inhabited human rail-capital realm with a government, opposition, university, mage interests, labor constituencies, dependent infrastructure, surrounding settlements, and public routes.
Beyond the Confluence, approach and admit a politically inhabited orcish oathland realm with several councils and communities, guardian-species obligations, contested enforcement, trade interests, dissent, and public routes.
Beyond the Confluence, approach and admit a politically inhabited halfling grain-and-river realm with farms, market towns, tenants, cooperatives, water authorities, insurers, faiths, political movements, and public routes.
Beyond the Confluence, approach and admit a politically inhabited goblin deep-route realm with distinct settlements, route guilds, kin networks, custodians, brokers, radicals, discounted expertise, and public routes.
Beyond the Confluence, approach and admit a second politically inhabited human polity: a tidal mercantile republic with elected harbor offices, hereditary creditors, dock unions, displaced fisher wards, rival courts, naval contracts, and public routes independent of the rail capital.
Beyond the Confluence, approach and admit a mixed border federation whose member towns, mobile communities, species, faiths, and trade leagues retain local authority while sharing delegates, mutual-aid stores, contested asylum law, watershed obligations, and public routes.
Beyond the Confluence, approach and admit an autonomous deep-ecology polity of dungeon organs, custodial communities, symbiotic species, and negotiated human-scale envoys, with legible law, dissent, material means, territorial claims, redress, and public routes; do not make it a single mystical will.

View file

@ -0,0 +1,112 @@
# DELVE/HOLD Outside World — Full-World Acceptance Seed v1
The Greathold is a sovereign dwarven civilization along the mountain spine. It
possesses the world's deepest inherited routes, the principal international
adventuring threshold, mature guild and civic institutions, and a rapidly
industrializing system of standardized runes, core farming, pumps, rail,
weapons, and mana distribution. It is controlled outside this simulation. The
world beyond may observe it, depend on it, bargain with it, fear it, and act
against it, but may not invent its choices, quantities, prices, contracts, or
internal political outcomes.
Elven cultures understand landscapes as nested societies of persons: forests,
groves, trees, rivers, animal communities, and seasons retain identities within
larger living wholes. Elven realms are politically plural. Courts, communes,
temples, scientific delegations, trade interests, militant defenders, and rival
legal traditions disagree over whether core farming is husbandry, mutilation,
or surgery upon a sleeping god. Their names, exact borders, officeholders,
selection systems, revenues, redress, internal factions, and rival claims are
intentionally unspecified and must emerge as grounded institutions.
Human societies include kingdoms, republics, city-states, merchant leagues,
principalities, and empires. Human cities buy dwarven pumps, lights, rails,
weapons, and power systems while local mage guilds lose work and governments
acquire dangerous foreign dependencies. Universities arbitrate disputed
evidence amid company restrictions, elven models, municipal interests, and
political funding. No single human authority or position exists; invent several
distinct polities with credible institutions, constituencies, opposition, and
cross-border interests.
Orcish societies vary, but many organize ancestry, territory, oath, rhythm, and
reciprocal obligation. Some maintain old agreements with local dungeon systems
that outsiders dismiss as monster worship. Industrial disturbance displaces
protected species, breaks inherited obligations, and forces clans, councils,
war leaders, ritual authorities, traders, and younger dissidents to decide who
owes repair and what enforcement is legitimate. Their politics are not a
single tribal will.
Halfling regions are agricultural, riverine, commercial, and locally varied.
Mana contamination brings crop mutation, magical pests, livestock deformity,
untimely storms, and localized ecological collapse. Farms and towns depend on
dwarven pumps and rail even as those systems transmit the crisis. Invent
landholders, tenants, cooperatives, market towns, water authorities, faiths,
insurers, and political movements with conflicting exposure to that dependence.
Goblin communities possess practical knowledge of living tunnels and dungeon
moods accumulated through work, travel, salvage, habitation, and negotiated
coexistence. Their expertise is routinely used while their jurisdiction and
testimony are discounted. Invent distinct settlements, route guilds, kin
networks, councils, brokers, radicals, and custodians; do not reduce them to a
shared instinct or a service population.
The deep magical ecology crosses every border. Young dungeon cores behave like
organs; old cores remember; ancient systems may become persons, polities, or
gods. Dwarven extraction produces scattered mine failures, shifting veins,
strange migration, coordinated dungeon architecture, mana storms, altered
rivers, crop failures, and foreign political pressure. Mining companies call
this deep mana instability, engineers call it adaptive geomantic feedback,
militaries call it subterranean hostile activity, elven observers call it
injury and immune response, and goblin guides say the tunnels know what the
machines are for. None of these interpretations is established as complete.
The world begins as a coarse international skeleton. Invent at least one
politically inhabited seed locality for several peoples rather than treating a
species as a state. Establish concrete routes, dependencies, public channels,
institutions, offices, revenues, succession or selection, redress, opposed
interests, and named figures. Preserve ordinary life alongside crisis. Later
elaboration and strategic cells may deepen these facts but may not replace this
ontology or turn the Greathold into an autonomous character.
## Frozen run profile
- World name: `The Deep World Beyond the Greathold`
- Boundary observer: `The player-controlled Greathold, present only as an
externally sovereign boundary observer with no Persona, vote, office, or
autonomous strategic turn.`
- Place scope: `the politically inhabited world beyond the Greathold: elven,
human, orcish, halfling, and goblin realms joined by trade, rivers, deep
routes, migration, scholarship, and the living dungeon ecology`
- Time scope: `the first international season in which coordinated deep
ecological injury can no longer be dismissed as isolated industrial failure`
- Public channel: `the world post and treaty-wire carried through named local
presses, couriers, universities, councils, guild halls, and border exchanges`
- Strategic pressure: `The Greathold has opened the Crownshaft Array and
reversed three deep mana currents to supply rune works through winter. River
pumps fail across foreign grain country; a migrating dungeon organ surfaces
beneath a human rail capital; an orcish oath-route closes after its guardian
species arrives blind and burned; goblin route custodians produce work songs
that predict collapses before dwarven instruments; and five elven delegations
publish mutually incompatible claims of jurisdiction over the same wounded
watershed. Dwarven power exports continue under signed contracts while
insurers suspend cover, armies secure infrastructure, displaced families
cross borders, and an unknown party circulates proof that several foreign
governments secretly financed the extraction they now condemn.`
- Ordered outer-realm requests:
`acceptance/full-world-delvehold-regions-v2.txt` (eight requests, one per
line; their order is part of the acceptance input)
- World locality elaborations: `8` (one per ordered outer-realm request)
- World elaboration parallelism: `8`
- Active cell budget: `240`
- Target active-cover basis points: `2000` (exactly `1200` qualified subjects)
- World complexity parallelism: `8`
- Maximum world complexity rounds: `128`
- Strategic waves: `1`
- Maximum rejected pulses per wave: `4`
- Provider routing: Luna fast, Terra balanced, Sol capable through the existing
Codex connector
Acceptance requires a single persistent campaign with multiple politically
inhabited realms, named figures and institutions acting across realm boundaries,
causal consequences that survive later waves, no autonomous Greathold action,
complete per-wave and final publication artifacts, exact source/model receipts,
immutable archive hashes, and unchanged live-service process identities.

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

View file

@ -0,0 +1,15 @@
# Boundary Rail Exchange woodcut
Generation mode: built-in ImageGen, fresh generation from text.
```text
Use case: illustration-story
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
Primary request: a stark Boundary Rail Exchange breach: two heavy parallel iron rails and timber sleepers cross a cramped masonry exchange; thick living roots bend toward exposed mana conduits; in the immediate foreground lie several unmistakable severed gloved elven hands, each palm marked by a different simple burned court seal; a rough warning cordon and marked withdrawal path remain secondary
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black contours, irregular crosshatching, white negative space between marks, uneven hand-pressed ink, archaic perspective
Composition/framing: horizontal rectangular block, centered subject, roots and severed hands clearly readable at 3-inch newspaper width, balanced white margins
Color palette: black marks on a bright white background only
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern comic style, or vector cleanliness; macabre but not wet or photorealistically gory
```
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View file

@ -0,0 +1,15 @@
# Sinkroot Gauge Gallery woodcut
Generation mode: built-in ImageGen, fresh generation from text.
```text
Use case: illustration-story
Asset type: printable wordless woodcut for an early hand-press fantasy broadsheet
Primary request: Sinkroot Gauge Gallery during a deep-current dispute: one enormous round calibrated pressure gauge with a violently deflected needle dominates the center; branching iron pipes, rootlike conduits, worn shutoff wheels, workers' measuring rods, and a bound petition sit below it; carved wave and vibration marks inside the pipes imply turbulent current
Style/medium: authentic late-15th-century relief woodcut printed in BLACK INK ON WHITE PAPER, bold carved black outlines, rhythmic crosshatching, ample white negative space, uneven hand-pressed ink, archaic technical illustration without labels
Composition/framing: horizontal rectangular block, central dial unmistakable and fully visible, petition and tools secondary, readable at 3-inch newspaper width, balanced white margins
Color palette: black marks on a bright white background only
Constraints: the page must be predominantly white, never a black or dark background; wordless; no caption, letters, numerals, frame, logo, watermark, gradients, gray wash, glowing white lines, photorealism, modern vector style, or steampunk clutter
```
Press preparation: converted to grayscale, thresholded at 72%, and trimmed without semantic edits.

View file

@ -0,0 +1,46 @@
[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]
tempfile = "3"
tower = { version = "0.5", features = ["util"] }

View file

@ -0,0 +1,65 @@
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

@ -0,0 +1,869 @@
//! 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>>,
}
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()
|| session.access_revision != expected_access_revision
|| input.access_revision() <= expected_access_revision
|| session.heimdall_session_id != input.heimdall_session_id()
|| session.account_subject_hash
!= secret_hash(&format!("heimdall-account:{}", input.account_id()))
{
bail!("local session changed during refresh");
}
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)
}
}
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 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());
}
}

View file

@ -0,0 +1,980 @@
//! Eve/CultUI projection for the one live world owner.
use crate::mesh::{COMMAND_BOUNDARY, COMMAND_RECEIPT_SCHEMA, COMMAND_RESULT_SCHEMA, OWNER_REPO, PROVIDER_ID, SURFACE_ID};
use crate::play::{PlayTurnState, PlayTurnView};
use ghostlight::{JurisdictionKey, WorldPhase, WorldSnapshot};
use anyhow::{Context, bail};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use uuid::Uuid;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub(crate) struct EveCommandInvocation {
pub(crate) schema: String,
#[serde(rename = "providerId")]
pub(crate) provider_id: String,
#[serde(rename = "surfaceId")]
pub(crate) surface_id: String,
pub(crate) operation: EveOperation,
pub(crate) payload: Value,
#[serde(rename = "issuedAt")]
pub(crate) issued_at: String,
#[serde(rename = "clientId")]
pub(crate) client_id: String,
#[serde(rename = "commandBoundary")]
pub(crate) command_boundary: String,
#[serde(rename = "receiptSchema")]
pub(crate) receipt_schema: String,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct EveOperation {
pub(crate) operation_id: String,
pub(crate) schema_id: Option<String>,
pub(crate) idempotency_key: Option<String>,
#[serde(default)]
pub(crate) route_hint: EveRouteHint,
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(crate) struct EveRouteHint {
pub(crate) source_version: Option<u64>,
pub(crate) transport: Option<String>,
}
/// The `world_create.v4` payload, in one place, so the button that captures it
/// and the descriptor that advertises it cannot drift.
const CREATE_BINDINGS: [&str; 8] = [
"title",
"brief",
"subject_label",
"narrative_persona_label",
"operational_agent_label",
"targets",
"jurisdictions",
"lens_weights",
];
const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"];
pub(crate) fn surface_version(snapshot: Option<&WorldSnapshot>) -> u64 {
snapshot
.map(|world| world.revision.saturating_add(1))
.unwrap_or(0)
}
/// The wake signal `publish_projection` sends over the `revisions` broadcast
/// channel and `/api/eve/events` relays — never a value a client feeds back
/// as `routeHint.sourceVersion`, and never the surface document's own
/// `version` field (PA.f148/PA.f160), which names `surface_version` alone so
/// the kernel's `expected_revision = source_version - 1` derivation in
/// `runtime.rs` stays sound. It folds `surface_version` with the play row's
/// own `revision`, when a play view is available, so a play-row-only change
/// (a fresh refusal, a newly open question, a closed turn's narration, none
/// of which necessarily commit anything to the world) still wakes a watching
/// client even though `surface_version` alone would not move. Additive, not
/// paired: both counters only ever grow, so the sum only ever grows. No play
/// view (no turn has opened yet, or the table is unavailable) contributes
/// nothing, matching `surface_version`'s own reach when no play card renders
/// at all.
pub(crate) fn authenticated_wake_version(
snapshot: Option<&WorldSnapshot>,
play: Option<&PlayTurnView>,
) -> u64 {
surface_version(snapshot).saturating_add(play.map_or(0, |view| view.revision))
}
pub(crate) fn world_state(snapshot: Option<&WorldSnapshot>) -> &'static str {
match snapshot.map(|world| world.phase) {
None => "empty",
Some(WorldPhase::Draft) => "draft",
Some(WorldPhase::Active) => "active",
}
}
pub(crate) fn anonymous_surface() -> Value {
anonymous_surface_at(0)
}
pub(crate) fn mesh_surface(snapshot: Option<&WorldSnapshot>) -> Value {
anonymous_surface_at(surface_version(snapshot))
}
fn anonymous_surface_at(version: u64) -> Value {
surface_document(
version,
"Ghostlight Dungeon",
vec![
json!({
"id":"ghostlight.access",
"kind":"heimdall.access_gate",
"props":{
"state":"anonymous",
"title":"Enter Ghostlight",
"detail":"Sign in with Discord to reach the world owner."
},
"children":[]
}),
command_button(
"ghostlight.auth.begin",
"Continue with Discord",
"heimdall.auth.begin",
json!({}),
&[],
),
],
vec![command_descriptor(
"heimdall.auth.begin",
"heimdall.auth_begin_command.v1",
&[],
"Heimdall",
)],
)
}
/// What the seed lane has done and what it will answer next. Everything here is
/// derived from `scale_deficit` and `revision`: in Draft every commit is a patch
/// admission, so `revision - 1` is exactly the seed patches committed since
/// genesis, and "next shortfall" calls the runner's own `select_row` so the card
/// and the runner cannot disagree about what happens next.
///
/// It shows no in-flight session and no last outcome. Both would need a second
/// reader of the controller-work store from here and a status cache in
/// `AppState`; the last outcome is already in the invocation's own
/// `command_result` receipt, which is where a command's result belongs.
fn seed_card(world: &WorldSnapshot) -> Value {
let next = ghostlight::select_row(world).map_or_else(
|| "Nothing short.".to_owned(),
|row| {
format!(
"{} · {:?} · short {}",
jurisdiction_label(world, row.jurisdiction),
row.kind,
row.deficit
)
},
);
let rows = world
.scale_deficit
.iter()
.enumerate()
.map(|(index, row)| {
json!({
"id":format!("world.seed.row.{index}"),
"kind":"text",
"props":{"value":format!(
"{} · {:?} · target {} · alive {} · short {}",
jurisdiction_label(world, row.jurisdiction),
row.kind,
row.target,
row.qualified,
row.deficit
)},
"children":[]
})
})
.collect::<Vec<_>>();
json!({
"id":"world.seed.card",
"kind":"card",
"props":{
"title":"Seed",
"detail":format!("Draft patches committed: {}", world.revision.saturating_sub(1)),
"nextShortfall":next
},
"children":rows
})
}
fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> String {
match jurisdiction {
JurisdictionKey::PlaceSubtree(root) => world
.places
.iter()
.find(|entry| entry.id == root)
.map_or_else(
|| "an unnamed place subtree".to_owned(),
|entry| entry.label.clone(),
),
JurisdictionKey::Uncovered => "everything no declared root covers".to_owned(),
}
}
/// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot`
/// carries no play-turn state of its own. The play card below reads `play`
/// alone, never the operator's own unscoped log of every committed event:
/// invariant 8's projection, question, and refusal, nothing else.
pub(crate) fn authenticated_surface(
account: &str,
snapshot: Option<&WorldSnapshot>,
play: Option<&PlayTurnView>,
) -> anyhow::Result<Value> {
// The document's own `version` names the world's `surface_version`
// alone — the one meaning `routeHint.sourceVersion` and the kernel's
// compare-and-swap require of it (PA.f148/PA.f160). The play row's own
// revision moves independently of it and is never folded in; it has no
// wire reader of its own (PA.f165) and stays internal to
// `eve::authenticated_wake_version`'s SSE wake computation.
let version = surface_version(snapshot);
let mut children = vec![json!({
"id":"ghostlight.identity",
"kind":"heimdall.identity",
"props":{
"state":"authenticated",
"title":"Authenticated",
"detail":"Heimdall identity is bound server-side.",
"displayName":short_principal(account)
},
"children":[]
})];
let mut commands = Vec::new();
match snapshot {
None => {
// Dungeon policy, editable before submission; the library holds no
// default weights.
let default_lens_weights =
serde_json::to_string(&crate::runtime::uniform_lens_weights())
.context("the default lens weights encode")?;
children.extend([
json!({
"id":"world.create.title",
"kind":"control.input.text",
"props":{"label":"World title","placeholder":"A name for this world"},
"stateBindings":[local_draft("title", "string")],
"children":[]
}),
// Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised
// `brief` — `world_create.v4`'s `CreatePayload.brief` is
// required, not `#[serde(default)]` — but no control ever
// captured it. Driving `world.create` with the real
// vendored lowering surfaced this: nothing the lowering
// could ever submit through this form would satisfy
// `world_create.v4`, hand-built test payloads notwithstanding.
json!({
"id":"world.create.brief",
"kind":"control.input.textarea",
// PA.f164: `brief` is documented "required, may be empty"
// (`CreatePayload::brief`), the same deliberate-empty
// shape as `targets`/`jurisdictions` below — an authored
// `value` so a form left untouched here still submits.
"props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"},
"stateBindings":[local_draft("brief", "string")],
"children":[]
}),
json!({
"id":"world.create.subject",
"kind":"control.input.text",
"props":{"label":"Your name","placeholder":"The first person in the world"},
"stateBindings":[local_draft("subject_label", "string")],
"children":[]
}),
json!({
"id":"world.create.narrative_persona",
"kind":"control.input.text",
"props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"},
"stateBindings":[local_draft("narrative_persona_label", "string")],
"children":[]
}),
json!({
"id":"world.create.operational_agent",
"kind":"control.input.text",
"props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"},
"stateBindings":[local_draft("operational_agent_label", "string")],
"children":[]
}),
json!({
"id":"world.create.targets",
"kind":"control.input.textarea",
// PA.f164: an authored `value`, not only a `placeholder` —
// `findAuthoredBindingValue` (the vendored lowering) only
// ever captures a control's `value`, never its
// `placeholder`, so a form submitted without editing this
// field would otherwise be refused as missing. `{}` is a
// legitimate `targets` (a world with no scale target is a
// deliberate choice, `CreatePayload`'s own doc comment).
"props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"},
"stateBindings":[local_draft("targets", "string")],
"children":[]
}),
json!({
"id":"world.create.jurisdictions",
"kind":"control.input.textarea",
// PA.f164: same gap, `[]` is a legitimate empty root list.
"props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"},
"stateBindings":[local_draft("jurisdictions", "string")],
"children":[]
}),
json!({
"id":"world.create.lens_weights",
"kind":"control.input.textarea",
"props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights},
"stateBindings":[local_draft("lens_weights", "string")],
"children":[]
}),
command_button(
"world.create",
"Create world",
"world.create",
json!({}),
&CREATE_BINDINGS,
),
]);
commands.push(command_descriptor(
"world.create",
"ghostlight.world_create.v4",
&CREATE_BINDINGS,
"WorldMailbox",
));
}
Some(world) => {
// Cut 10 (PA.f159): phase and clock stay; the subject census is
// gone — nothing about who else is in the world is the player's
// own projection, invariant 8's own "nothing else."
children.push(json!({
"id":"world.summary",
"kind":"card",
"props":{"title":world.title,"subtitle":format!("{:?} · revision {} · minute {}", world.phase, world.revision, world.now.0)},
"children":[]
}));
match world.phase {
WorldPhase::Draft => {
if world
.required_approvers
.contains(&ghostlight::PrincipalId::new(account))
&& !world
.draft_approvals
.contains(&ghostlight::PrincipalId::new(account))
{
children.push(command_button(
"world.approve",
"Approve draft",
"world.approve",
json!({}),
&[],
));
commands.push(command_descriptor(
"world.approve",
"ghostlight.world_approve.v0",
&[],
"WorldMailbox",
));
}
if world.owner == ghostlight::PrincipalId::new(account) {
children.extend([
json!({
"id":"world.seed.vault_scope",
"kind":"control.input.text",
"props":{"label":"Vault scope (optional)","placeholder":"A subdirectory of the configured vault"},
"stateBindings":[local_draft("vault_scope", "string")],
"children":[]
}),
json!({
"id":"world.seed.brief",
"kind":"control.input.textarea",
"props":{"label":"Brief (optional)","rows":2,"placeholder":"One sentence of what this world is for"},
"stateBindings":[local_draft("brief", "string")],
"children":[]
}),
command_button(
"world.seed",
"Seed the world",
"world.seed",
json!({}),
&SEED_BINDINGS,
),
seed_card(world),
]);
commands.push(command_descriptor(
"world.seed",
"ghostlight.world_seed.v1",
&SEED_BINDINGS,
"SeedPort",
));
}
if world.owner == ghostlight::PrincipalId::new(account)
&& world.draft_approvals == world.required_approvers
{
children.push(command_button(
"world.activate",
"Activate world",
"world.activate",
json!({}),
&[],
));
commands.push(command_descriptor(
"world.activate",
"ghostlight.world_activate.v0",
&[],
"WorldMailbox",
));
}
}
WorldPhase::Active => {
if world.owner == ghostlight::PrincipalId::new(account) {
children.extend([
json!({
"id":"world.advance_time.minutes",
"kind":"control.input.number",
"props":{"label":"Advance minutes","min":1,"max":525_600},
"stateBindings":[local_draft("minutes", "number")],
"children":[]
}),
command_button(
"world.advance_time",
"Advance time",
"world.advance_time",
json!({}),
&["minutes"],
),
]);
commands.push(command_descriptor(
"world.advance_time",
"ghostlight.world_advance_time.v0",
&["minutes"],
"WorldMailbox",
));
}
// Cut 10 (PA.f152): owner-gated, like `world.advance_time`
// and `world.seed` above — the play card, its controls,
// and the `world.play` descriptor are the owner's own
// play authority over their own world, not a public
// affordance.
if world.owner == ghostlight::PrincipalId::new(account) {
// Cut 9: the play card. Invariant 8 — the player sees
// a projection, the question, and the refusal of
// their own act, nothing else — so this reads `play`
// alone, never the operator's own unscoped event log:
// no id, no other subject's state, no speech the
// player did not perceive. Always rendered here, with
// empty rows before any turn has ever opened.
let mut play_rows = Vec::new();
if let Some(narration) = play.and_then(|view| view.narration.as_deref()) {
play_rows.push(json!({
"id":"world.play.narration",
"kind":"text",
"props":{"value":narration},
"children":[]
}));
}
if let Some(question) = play.and_then(|view| view.question.as_ref()) {
play_rows.push(json!({
"id":"world.play.question",
"kind":"text",
"props":{"value":question.text.as_str()},
"children":[]
}));
}
if let Some(refusal) = play.and_then(|view| view.refusal.as_deref()) {
play_rows.push(json!({
"id":"world.play.refusal",
"kind":"text",
"props":{"value":refusal},
"children":[]
}));
}
// PA.f193-D ("the interrupted turn"; renumbered from
// the PA.f188 duplicate this file's own comment used
// to carry — PA.f188 names only `f295a3a`'s
// construction-site coverage): a turn a process died
// mid-round leaves `Running` forever — nothing
// resumes it on its own, and the only way forward is
// submitting an empty message (`play.rs`'s own
// `Some(existing) if existing.state ==
// PlayTurnState::Running` admit arm: empty text
// continues it, non-empty text is refused as
// `TurnStillRunning`). Before this the card showed
// nothing at all in that state — no narration, no
// question, no refusal, since none of those are set
// on a turn that never got to close, ask, or record a
// refused act since it last opened — so a player had
// no way to learn the empty-submit continue exists at
// all. This is a control hint about the player's own
// turn, not world truth, so it stays within
// invariant 8 the same way the narration/question/
// refusal rows above already do.
//
// `&& !view.run_in_progress`: `state == Running`
// alone is not "nothing is happening" — it is also
// true for the seconds a healthy turn is genuinely
// running its own round loop. In that window `admit`
// fails `run_lock`'s own `try_lock_owned` before
// inspecting anything and refuses as `TurnBusy`,
// contradicting a hint that told the player an empty
// continue would be accepted. `run_in_progress` is
// `current_turn_view`'s own non-blocking peek at that
// same lock, so the hint now renders only for a
// `Running` turn nothing is actively working on.
if play.is_some_and(|view| view.state == PlayTurnState::Running && !view.run_in_progress) {
play_rows.push(json!({
"id":"world.play.running",
"kind":"text",
"props":{"value":"Your last turn did not finish. If nothing arrives, \
submit an empty message to continue it."},
"children":[]
}));
}
// PA.f194-D: with the hint above correctly suppressed
// while a round is genuinely in flight
// (`run_in_progress`), the card had nothing left to
// render for the whole span between submitting a turn
// and its own narration/question/refusal landing —
// narration/question/refusal are all still whatever
// the *previous* turn left behind (or unset, on a
// fresh table), and the round in progress does not
// touch any of them until it closes or opens a
// question. A player who submits and sees an
// unchanged or empty card has no sign anything is
// happening. This row renders only while this
// player's own turn is actively being worked on —
// `run_in_progress`, not world truth, and stays
// within invariant 8 the same way the hint above
// does.
if play.is_some_and(|view| view.run_in_progress) {
play_rows.push(json!({
"id":"world.play.resolving",
"kind":"text",
"props":{"value":"Resolving your turn…"},
"children":[]
}));
}
children.push(json!({
"id":"world.play.card",
"kind":"card",
"props":{"title":"Play"},
"children":play_rows
}));
// Cut 10 (PA.f151): no `answers`/question-id control
// any more. The lowering has no authored, non-editable
// binding value (`hidden` is not a prop any renderer
// reads — Soul's own probe found it rendered as a
// plain, editable, visible text box holding the raw
// id), so the server resolves the open question
// itself instead of trusting a client-supplied id at
// all (`runtime.rs::execute_world`'s `world.play`
// arm). PA.f170's guarantee now holds by an opaque
// per-question token rather than PA.f161's version
// comparison (see `play::PlayTurn::question_token`'s
// own doc comment for why a version alone cannot tell
// two questions in the same turn apart): the button's
// own `props.action` carries it DOM-invisibly, the
// way `props.action.command` already does — a real
// client spreads a button's own `action` fields
// straight into the payload beside `bindings`
// (`commandPayload`,
// `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`),
// never through a renderer that could show or edit
// it. `None` while no question is open: the button's
// own action then carries no token at all, matching a
// plain continue/opening, which needs none.
// PA.f179: `question.token` is `Option<String>` — a
// `None` here (a question open whose own token was,
// for whatever reason, never minted) serializes as a
// JSON `null`, which `runtime.rs`'s own
// `answer_token_admits` gate refuses unconditionally,
// the same as any other mismatch. It is never
// rendered as an empty string a forged answer could
// match.
let play_action = play
.and_then(|view| view.question.as_ref())
.map(|question| json!({"answerToken": question.token}))
.unwrap_or_else(|| json!({}));
children.extend([
json!({
"id":"world.play.text",
"kind":"control.input.textarea",
"props":{"label":"What do you do?","rows":3,"placeholder":"Write freely"},
"stateBindings":[local_draft("text", "string")],
"children":[]
}),
command_button(
"world.play",
"Play",
"world.play",
play_action,
&["text"],
),
]);
commands.push(command_descriptor(
"world.play",
"ghostlight.world_play.v0",
&["text"],
"PlayTable",
));
}
}
}
}
}
children.push(command_button(
"app.auth.logout",
"Sign out",
"app.auth.logout",
json!({}),
&[],
));
commands.push(command_descriptor(
"app.auth.logout",
"ghostlight.app_logout.v2",
&[],
"AppSessionOwner",
));
Ok(surface_document(
version,
snapshot
.map(|world| world.title.as_str())
.unwrap_or("Ghostlight Dungeon"),
children,
commands,
))
}
pub(crate) fn validate_invocation(
invocation: &EveCommandInvocation,
expected_transport: &str,
) -> anyhow::Result<()> {
if invocation.schema != "gamecult.eve.command_invocation.v1"
|| invocation.provider_id != PROVIDER_ID
|| invocation.surface_id != SURFACE_ID
|| invocation.command_boundary != COMMAND_BOUNDARY
|| invocation.receipt_schema != COMMAND_RESULT_SCHEMA
{
bail!("invocation does not match the Ghostlight Eve boundary");
}
if invocation.operation.operation_id.trim().is_empty()
|| invocation
.operation
.idempotency_key
.as_deref()
.is_none_or(str::is_empty)
|| invocation
.operation
.schema_id
.as_deref()
.is_none_or(str::is_empty)
|| invocation.client_id.trim().is_empty()
|| invocation.issued_at.parse::<DateTime<Utc>>().is_err()
|| invocation.operation.route_hint.transport.as_deref() != Some(expected_transport)
{
bail!("invocation metadata is incomplete");
}
let expected = operation_schema(&invocation.operation.operation_id)
.context("Eve operation is not advertised by Ghostlight")?;
if invocation.operation.schema_id.as_deref() != Some(expected) {
bail!("operation payload schema does not match its command descriptor");
}
if invocation.payload.get("caller").is_some()
|| invocation.payload.get("caller_id").is_some()
|| invocation.payload.get("callerId").is_some()
{
bail!("payload may not supply caller authority");
}
Ok(())
}
pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> {
Some(match operation {
"heimdall.auth.begin" => "heimdall.auth_begin_command.v1",
"heimdall.auth.complete" => "heimdall.auth_complete_command.v1",
"app.auth.logout" => "ghostlight.app_logout.v2",
"world.create" => "ghostlight.world_create.v4",
"world.approve" => "ghostlight.world_approve.v0",
"world.activate" => "ghostlight.world_activate.v0",
"world.advance_time" => "ghostlight.world_advance_time.v0",
"world.seed" => "ghostlight.world_seed.v1",
"world.play" => "ghostlight.world_play.v0",
_ => return None,
})
}
/// One command result in the shape `gamecult.eve.command_result.v1` actually
/// admits: `additionalProperties: false` over exactly `schema`, `receipt`,
/// `transientProjection`, `pluginPayload` and `draftDirective`, with the
/// per-command detail (state, message, source version, ids) inside the
/// receipt, which is `gamecult.eve.command_receipt.v1` and does allow its own
/// extra fields.
///
/// The browser validates this envelope before it looks at anything in it, so
/// a flat result is not a lenient dialect of the contract — it is rejected
/// whole, with the client reporting "invalid Eve command result: data must
/// not have additional properties" and no command of any kind completing.
pub(crate) fn command_result(
invocation: &EveCommandInvocation,
state: &str,
message: impl Into<String>,
source_version: Option<u64>,
plugin_payload: Option<Value>,
receipt_extra: Option<Value>,
) -> Value {
let message = message.into();
// A refusal is otherwise visible only to the browser that received it.
// The operator log gets the operation and the refusal's own message,
// never the payload.
if !matches!(state, "accepted" | "completed" | "pending") {
tracing::warn!(
operation = %invocation.operation.operation_id,
state,
message = %message,
"Eve command not accepted"
);
}
let mut receipt = json!({
"schema":COMMAND_RECEIPT_SCHEMA,
"receiptId":Uuid::new_v4().to_string(),
"commandId":invocation.operation.idempotency_key.clone().unwrap_or_default(),
"command":invocation.operation.operation_id,
"state":state,
"ownerRepo":OWNER_REPO,
"authority":PROVIDER_ID,
"providerId":PROVIDER_ID,
"surfaceId":SURFACE_ID,
"message":message,
// The contract requires a source version and refuses a null one. A
// command that reports no version reports the surface it was decided
// against, which is 0 before any world exists.
"sourceVersion":source_version.unwrap_or(0),
"issuedAtUtc":Utc::now().to_rfc3339()
});
if let (Some(map), Some(extra)) = (receipt.as_object_mut(), receipt_extra) {
if let Some(extra) = extra.as_object() {
for (key, value) in extra {
map.insert(key.clone(), value.clone());
}
}
}
let mut result = json!({
"schema":COMMAND_RESULT_SCHEMA,
"receipt":receipt
});
if let (Some(map), Some(payload)) = (result.as_object_mut(), plugin_payload) {
map.insert("pluginPayload".into(), payload);
}
result
}
fn surface_document(
version: u64,
title: &str,
children: Vec<Value>,
commands: Vec<Value>,
) -> Value {
json!({
"type":"surface-state",
"schema":"gamecult.eve.surface.v1",
"providerId":PROVIDER_ID,
"providerKind":"narrative.simulation",
"title":title,
// The one meaning `routeHint.sourceVersion` and the kernel's
// compare-and-swap require of it (PA.f148/PA.f160): `surface_version`
// alone. The play row's own revision has no wire reader (PA.f165)
// and is never carried here.
"version":version,
"updatedAtUtc":Utc::now().to_rfc3339(),
"surface":{
"id":SURFACE_ID,
"root":{"id":"ghostlight.root","kind":"surface","props":{},"children":children},
"styles":{"tokens":{
"colorBackground":"#0c1110",
"colorPanel":"#17201d",
"colorText":"#e8e1cf",
"colorMuted":"#9aa69f",
"colorAccent":"#d49b58"
}}
},
"commands":commands
})
}
fn command_button(id: &str, label: &str, command: &str, action: Value, bindings: &[&str]) -> Value {
let mut action = action.as_object().cloned().unwrap_or_default();
action.insert("command".into(), Value::String(command.into()));
json!({
"id":id,
"kind":"control.button",
"props":{"label":label,"command":command,"action":action,"captureBindings":bindings},
"children":[]
})
}
fn command_descriptor(command: &str, schema: &str, bindings: &[&str], authority: &str) -> Value {
json!({
"schema":"gamecult.eve.command.v1",
"command":command,
"payloadSchema":schema,
"captureBindings":bindings,
"transport":"https-json",
"authority":authority
})
}
/// A `captureBindings` entry the vendored browser lowering
/// (`@gamecult/eve-browser-lowering`, `EveStateBindingDescriptor`) actually
/// resolves, field-for-field the same shape its own test suite
/// (`test/host-isolation.test.mjs`) builds for its own `composer.message`
/// local-draft binding: `targetProp`, `pointerId`, `sourceId`, `schemaId`,
/// `routeKind`, `bindingName`, `valueKind`, `accessMode`, `authority`.
///
/// Two fields are load-bearing. `bindingName` must equal `key`, since `key`
/// is also the exact string Dungeon's own `captureBindings` array and
/// `command_descriptor` advertise — `findAuthoredBindingValue` and
/// `editableBindingContext` both match a binding by this field (falling back
/// to `pointerId`, then the control's own node id, only when it is absent).
/// `accessMode` must stay `"local-draft"`: `editableBindingContext`
/// (`src/index.ts:2198`) defaults an absent `accessMode` to `"read"` whenever
/// a binding is present at all, and a `"read"` control is disabled
/// (`:2205`) — an editable field with no `accessMode` here would render
/// disabled, not merely miscaptured. The remaining fields
/// (`targetProp`, `pointerId`, `sourceId`, `schemaId`, `routeKind`,
/// `valueKind`, `authority`) carry no runtime behavior in the current
/// lowering (grep confirms `valueKind` is read nowhere), but are still worth
/// stating correctly rather than leaving absent.
///
/// `value_kind` must be one of the lowering's own `valueKind` union
/// (`"string" | "number" | "boolean" | "choice" | "string-list"`); Dungeon's
/// own JSON-textarea fields (`targets`, `jurisdictions`, `lens_weights`) are,
/// client-side, plain text controls like any other — there is no JSON
/// `valueKind` — so callers pass `"string"` for those too, and the server
/// side parses the captured text as JSON (`deserialize_json_capture` in
/// `runtime.rs`).
fn local_draft(key: &str, value_kind: &str) -> Value {
json!({
"targetProp":"value",
"pointerId":format!("ghostlight.local.{key}"),
"sourceId":"eve.browser.local",
"schemaId":"gamecult.eve.local_draft.v1",
"routeKind":"in-process",
"bindingName":key,
"valueKind":value_kind,
"accessMode":"local-draft",
"authority":"eve.browser"
})
}
fn short_principal(principal: &str) -> String {
principal.chars().take(20).collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn invocation(operation: &str, schema: &str, payload: Value) -> EveCommandInvocation {
EveCommandInvocation {
schema: "gamecult.eve.command_invocation.v1".into(),
provider_id: PROVIDER_ID.into(),
surface_id: SURFACE_ID.into(),
operation: EveOperation {
operation_id: operation.into(),
schema_id: Some(schema.into()),
idempotency_key: Some(uuid::Uuid::new_v4().to_string()),
route_hint: EveRouteHint {
source_version: Some(0),
transport: Some("https-json".into()),
},
},
payload,
issued_at: Utc::now().to_rfc3339(),
client_id: "fixture".into(),
command_boundary: COMMAND_BOUNDARY.into(),
receipt_schema: COMMAND_RESULT_SCHEMA.into(),
}
}
#[test]
fn anonymous_surface_advertises_only_authentication() {
let surface = anonymous_surface();
let commands = surface["commands"].as_array().unwrap();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0]["command"], "heimdall.auth.begin");
assert_eq!(surface["version"], 0);
}
#[test]
fn empty_authenticated_surface_has_create_without_session_zero() {
let surface = authenticated_surface("sha256:owner", None, None).unwrap();
let encoded = serde_json::to_string(&surface).unwrap();
assert!(encoded.contains("world.create"));
assert!(encoded.contains("narrative_persona_label"));
assert!(encoded.contains("operational_agent_label"));
assert!(!encoded.contains("session_zero"));
assert!(!encoded.contains("campaign"));
assert_eq!(surface["version"], 0);
// The lens control's default is Dungeon's uniform policy, every stock
// lens named, and it decodes as the weights the payload will carry.
fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> {
if node["id"] == id {
return Some(node);
}
node.as_object()?
.values()
.flat_map(|value| match value {
Value::Array(items) => items.iter().collect::<Vec<_>>(),
other => vec![other],
})
.find_map(|child| find(child, id))
}
let control = find(&surface, "world.create.lens_weights").expect("a lens weights control");
let default = control["props"]["value"].as_str().expect("a default value");
let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap();
assert_eq!(decoded, crate::runtime::uniform_lens_weights());
assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len());
assert!(decoded.iter().all(|(_, weight)| weight == 1));
}
/// `world.play`'s own operation id resolves to its schema through
/// `operation_schema`, so an invocation whose payload matches it
/// validates, and a payload that tries to claim authority is refused —
/// the same claim this test once made for the now-deleted speak
/// operation, PA.f146's own precedent (Cut 8b's schema existed here
/// before Cut 9 published `world.play`'s own descriptor and button,
/// below).
#[test]
fn payload_cannot_claim_authority() {
assert_eq!(operation_schema("world.play"), Some("ghostlight.world_play.v0"));
let bound = invocation(
"world.play",
"ghostlight.world_play.v0",
json!({"text":"I look around.", "answers": null}),
);
assert!(validate_invocation(&bound, "https-json").is_ok());
let forged = invocation(
"world.play",
"ghostlight.world_play.v0",
json!({"text":"I look around.", "answers": null, "caller":"owner"}),
);
assert!(validate_invocation(&forged, "https-json").is_err());
}
#[test]
fn invocation_metadata_rejects_legacy_authority_extensions() {
let exact = invocation(
"world.play",
"ghostlight.world_play.v0",
json!({"text":"I look around.", "answers": null}),
);
let mut outer = serde_json::to_value(&exact).unwrap();
outer["callerId"] = json!("legacy-owner");
assert!(serde_json::from_value::<EveCommandInvocation>(outer).is_err());
let mut route = serde_json::to_value(&exact).unwrap();
route["operation"]["routeHint"]["caller"] = json!("legacy-owner");
assert!(serde_json::from_value::<EveCommandInvocation>(route).is_err());
}
#[test]
fn removed_operation_is_unknown() {
assert!(operation_schema("session_zero.begin").is_none());
assert!(operation_schema("world.assess").is_none());
assert!(operation_schema("governance.time.propose").is_none());
assert!(operation_schema("world.speak").is_none());
}
}

View file

@ -0,0 +1,131 @@
{
"schema": "ghostlight.eve_client_bridge_fixture.v1",
"steps": [
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-09-22T23:30:03.348Z",
"operation": {
"idempotencyKey": "0af70fc4-d2bf-4f9b-9914-696a27580065",
"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-09-22T23:30:05.143Z",
"operation": {
"idempotencyKey": "99503a87-9e15-45b7-8776-765a8dc4216b",
"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-09-22T23:30:07.195Z",
"operation": {
"idempotencyKey": "1d6a441e-f173-41af-a798-a9123039d5e0",
"operationId": "world.seed",
"routeHint": {
"sourceVersion": 2,
"transport": "https-json"
},
"schemaId": "ghostlight.world_seed.v1"
},
"payload": {
"bindings": {}
},
"providerId": "gamecult.ghostlight.dungeon",
"receiptSchema": "gamecult.eve.command_result.v1",
"schema": "gamecult.eve.command_invocation.v1",
"surfaceId": "ghostlight.play"
},
"label": "world.seed"
},
{
"intent": {
"clientId": "eve.bridge.test",
"commandBoundary": "ghostlight.eve.commands",
"issuedAt": "2026-09-22T23:30:09.010Z",
"operation": {
"idempotencyKey": "20d01b36-cdfa-4e59-b5f3-31be54d94bc6",
"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-09-22T23:30:11.040Z",
"operation": {
"idempotencyKey": "2301df6e-397b-4d5b-afa3-ef965e57b49a",
"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

@ -0,0 +1,21 @@
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

@ -0,0 +1,464 @@
//! 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

@ -0,0 +1,27 @@
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

@ -1,15 +0,0 @@
[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

@ -1,346 +0,0 @@
//! 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

@ -1,445 +0,0 @@
//! 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,7 +12,6 @@ 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
@ -20,15 +19,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
[features]
# Exposes `open_local_port_with_timeout` to a consumer's tests. Production
# builds never enable it.
test-support = []
[dev-dependencies]
tempfile = "3"

View file

@ -1,297 +0,0 @@
//! 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

@ -1,589 +0,0 @@
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, TickIndex,
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref,
RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability,
WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph,
commit_digest, derive_boundaries, derive_cover, derive_opportunities,
@ -518,7 +518,6 @@ mod tests {
},
controller,
position: Some(Ref::Existing(commons)),
import_key: None,
})
};
WorldPatch {
@ -736,7 +735,8 @@ mod tests {
assert!(!graph.subjects.contains(&mirror.first));
let cover = derive_cover(
snapshot.world_id,
TickIndex(0),
snapshot.now,
30,
&opportunities,
&graph,
CoverBudget {
@ -1195,7 +1195,6 @@ 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.
@ -1574,7 +1573,6 @@ 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,6 +11,7 @@
//! 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};
@ -28,18 +29,25 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16;
/// the name, not by a second namespace.
const COVER_NAMESPACE: &str = "ghostlight.cover.v1";
/// 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.
/// 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.
#[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)]
@ -246,7 +254,7 @@ impl AgencyGraph {
}
}
/// The partition. Pure over `(world, tick, opportunities, graph, budget)`.
/// The partition. Pure over `(world, now, 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
@ -254,11 +262,13 @@ impl AgencyGraph {
/// is a fact about cognition rather than a scheduling preference.
pub fn derive_cover(
world: WorldId,
tick: TickIndex,
now: FictionalMinutes,
tick_minutes: u32,
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)
@ -591,7 +601,8 @@ mod tests {
) -> Cover {
derive_cover(
WorldId::nil_for_test(),
TickIndex(tick),
FictionalMinutes(tick * 60),
60,
opportunities,
graph,
budget,

View file

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

View file

@ -1,209 +0,0 @@
//! 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

@ -1,745 +0,0 @@
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

@ -1,752 +0,0 @@
//! 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,11 +181,6 @@ 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()
@ -831,17 +826,6 @@ 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) {
@ -1123,10 +1107,8 @@ 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();
@ -1208,10 +1190,8 @@ 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();
@ -1240,7 +1220,6 @@ 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(),
@ -1776,10 +1755,8 @@ 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,15 +56,11 @@
//! ```
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;
@ -74,7 +70,6 @@ mod sdk_inference;
mod table;
mod tool_schema;
mod vault;
mod verse;
pub(crate) use action::ActionMismatch;
pub(crate) use clock::Motion;
@ -85,33 +80,23 @@ 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,
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultDisposition, InferenceOutput,
InferencePort,
InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, 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_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding};
#[cfg(feature = "test-support")]
pub use local_inference::open_local_port_with_timeout;
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
pub(crate) use mailbox::ElaborationPort;
pub use mailbox::{ConsumerPort, ControllerPort, MailboxError, PlayPort, SeedPort, WorldMailbox};
pub use patch::{
@ -126,7 +111,6 @@ 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,
@ -144,13 +128,6 @@ 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};
@ -446,10 +423,6 @@ 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
@ -734,10 +707,6 @@ 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)]
@ -857,11 +826,6 @@ 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>,
@ -1027,8 +991,6 @@ 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>,
@ -1171,9 +1133,6 @@ 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)]
@ -1236,8 +1195,6 @@ 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>,
@ -1279,21 +1236,6 @@ 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)]
@ -1481,12 +1423,7 @@ 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(),
input.grammar.clone(),
),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -1584,43 +1521,7 @@ 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()?;
// 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)
operator_log(&self.state)
}
/// The scheduler's adjacency projection. Separate from `snapshot` for the
@ -1993,12 +1894,7 @@ 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,
grammar: Option<GrammarBinding>,
) -> Self {
fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self {
Self {
schema: STATE_SCHEMA.into(),
world_id,
@ -2007,7 +1903,6 @@ impl WorldState {
owner,
title,
brief: String::new(),
grammar,
draft_approvals: BTreeSet::new(),
subjects: BTreeMap::new(),
entities: BTreeMap::new(),
@ -2065,7 +1960,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(), command.grammar.clone());
let mut state = Self::empty(world_id, owner.clone(), title.clone());
state.brief = brief.clone();
let expected = patch::resolve_patch(
&state,
@ -2174,18 +2069,6 @@ 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())
@ -2196,21 +2079,6 @@ 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,
@ -3795,10 +3663,7 @@ 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,
invocations: &BTreeMap<EventId, &DecisionInvocation>,
) -> Result<Vec<OperatorEvent>, KernelError> {
fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
state
.events
.iter()
@ -3807,30 +3672,6 @@ fn operator_log(
.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,
@ -3843,13 +3684,6 @@ fn operator_log(
.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()
@ -4128,7 +3962,6 @@ 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(),
@ -4244,7 +4077,6 @@ 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,
@ -4254,11 +4086,6 @@ 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,
@ -5609,7 +5436,6 @@ 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,
})
}
@ -5712,13 +5538,11 @@ 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,
}
}
@ -5863,7 +5687,6 @@ 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
@ -5969,7 +5792,6 @@ mod tests {
}))
.collect(),
position: Some(Ref::Existing(place)),
import_key: None,
})
}
@ -6490,7 +6312,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: civic_grants(handles, &speak),
position: Some(place),
import_key: None,
})
};
let declarations = civic_affordances()
@ -6547,7 +6368,6 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("hall"))),
import_key: None,
}),
Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new("reeve"),
@ -6559,7 +6379,6 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("chamber"))),
import_key: None,
}),
person(
"farmer",
@ -6583,7 +6402,6 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: civic_grants(&["levy", "petition"], &speak),
position: None,
import_key: None,
}),
])
.collect();
@ -6747,7 +6565,6 @@ mod tests {
Ref::Draft(DraftHandle::new("recant")),
]),
position: place.map(|place| Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
};
submit_owner(
@ -8016,7 +7833,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.road)),
import_key: None,
}),
],
operations: Vec::new(),
@ -9035,7 +8851,6 @@ mod custody_tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.yard)),
import_key: None,
}),
],
operations: vec![ComponentOp::Admit {
@ -9695,7 +9510,6 @@ 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)],
@ -11622,7 +11436,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 = kernel.operator_log().unwrap();
let log = operator_log(&kernel.state).unwrap();
assert_eq!(
log,
vec![OperatorEvent {
@ -11631,9 +11445,6 @@ 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.
@ -11691,7 +11502,6 @@ 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,
})
}
@ -12805,7 +12615,6 @@ 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(
@ -13170,7 +12979,6 @@ 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));
@ -15352,7 +15160,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(), None),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -15381,7 +15189,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(), None),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -17224,7 +17032,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17291,7 +17098,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17369,7 +17175,6 @@ mod clock_tests {
},
affordances: BTreeSet::new(),
position: Some(Ref::Existing(commons)),
import_key: None,
})],
operations: Vec::new(),
evidence: Vec::new(),
@ -17463,7 +17268,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: vec![ComponentOp::Mint {
@ -17706,7 +17510,6 @@ 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(),
@ -17885,7 +17688,6 @@ 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")),
@ -17995,7 +17797,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(commons)),
import_key: None,
}),
],
operations: vec![ComponentOp::GrantAffordance {
@ -18210,7 +18011,6 @@ 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")),
@ -18629,7 +18429,6 @@ 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")),
@ -19078,7 +18877,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak.clone()]),
position: None,
import_key: None,
}),
Declaration::Channel(ChannelDeclaration {
handle: DraftHandle::new("wire"),

File diff suppressed because it is too large Load diff

View file

@ -3,10 +3,9 @@ use super::{
ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation,
DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey,
KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId,
EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
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;
@ -19,21 +18,6 @@ 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)]
@ -82,16 +66,6 @@ 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,
@ -146,8 +120,7 @@ impl WorldMailbox {
kind,
controller,
affordances: kernel_speak_grant(),
position: Some(commons_ref()),
import_key: None,
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
})
};
// Roots stand beside the commons, not inside it: a root contained by
@ -156,7 +129,12 @@ impl WorldMailbox {
// jurisdiction's population target. The owner subject lands in
// `Uncovered`, visible and reducing nothing.
let mut declarations = vec![
commons_declaration(),
Declaration::Entity(EntityDeclaration {
handle: DraftHandle::new(GENESIS_PLACE),
label: "The Commons".into(),
kind: EntityKind::Place,
container: None,
}),
declare(
"first-person",
input.human_subject_label,
@ -211,7 +189,6 @@ impl WorldMailbox {
},
scale_intent,
lens_weights: input.lens_weights,
grammar: None,
},
AuthenticatedCaller::verified_principal(principal_id),
)
@ -240,20 +217,6 @@ 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
@ -496,37 +459,6 @@ 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
@ -996,32 +928,6 @@ 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,
@ -1082,7 +988,6 @@ 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(),
@ -1104,7 +1009,6 @@ mod tests {
},
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
import_key: None,
}),
],
operations: Vec::new(),
@ -1492,7 +1396,6 @@ mod tests {
controller,
affordances: kernel_speak_grant(),
position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))),
import_key: None,
})
};
let room = |handle: &str| {
@ -1522,7 +1425,6 @@ mod tests {
evidence: Vec::new(),
},
scale_intent: WorldScaleIntentRef::default(),
grammar: None,
},
&authenticated,
)

View file

@ -13,7 +13,6 @@
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,
@ -23,7 +22,6 @@ 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)]
@ -946,102 +944,6 @@ 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)]
@ -1056,11 +958,6 @@ 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)]
@ -1453,33 +1350,6 @@ 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,
@ -3191,28 +3061,6 @@ 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
@ -3236,11 +3084,6 @@ 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()
@ -3250,8 +3093,6 @@ 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) => (
@ -3291,21 +3132,6 @@ 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,
@ -3317,13 +3143,6 @@ 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(),
@ -5410,7 +5229,6 @@ pub(super) fn resolve_patch(
subject: SubjectState {
label: input.label.clone(),
kind: input.kind,
import_key: input.import_key.clone(),
},
controller,
affordances: granted,
@ -7916,8 +7734,7 @@ mod tests {
};
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
/// `parked/ghostlight-eve-surface`), which refuses a
/// `ghostlight-dungeon`'s play agent (PA.f61), 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.
@ -8036,7 +7853,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak_entry(kernel)]),
position,
import_key: None,
})
}
@ -8304,7 +8120,6 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]),
position: None,
import_key: None,
})
};
@ -8605,7 +8420,6 @@ mod tests {
},
affordances: BTreeSet::from([speak_entry(&kernel)]),
position: None,
import_key: None,
}),
]);
let error = kernel
@ -8658,7 +8472,6 @@ 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));
@ -9214,7 +9027,6 @@ 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
@ -9491,7 +9303,6 @@ 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, TokenUsage,
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid,
tool_name_is_valid, unix_ms,
};
@ -121,22 +121,6 @@ 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.
@ -162,27 +146,8 @@ pub(super) enum SidecarFaultReason {
}
impl SidecarFaultReason {
/// The sidecar's own `detail` text is never carried: the reason names the
/// failure and the fault's text is fixed per reason.
fn into_fault(self) -> InferenceFault {
let detail = match self {
Self::RateLimited => "SDK sidecar reported rate limited",
Self::Overloaded => "SDK sidecar reported overloaded",
Self::ServerError => "SDK sidecar reported a server error",
Self::ApiTimeout => "SDK sidecar reported an API timeout",
Self::AuthenticationFailed => "SDK sidecar reported authentication failed",
Self::OrgNotAllowed => "SDK sidecar reported organization not allowed",
Self::BillingError => "SDK sidecar reported a billing error",
Self::InvalidRequest => "SDK sidecar reported an invalid request",
Self::ModelNotFound => "SDK sidecar reported model not found",
Self::MaxOutputTokens => "SDK sidecar reported max output tokens",
Self::MaxBudgetUsd => "SDK sidecar reported max budget",
Self::ExecutionError => "SDK sidecar reported an execution error",
Self::Unknown => "SDK sidecar reported an unknown failure",
Self::ProtocolViolation => "SDK sidecar reported a protocol violation",
Self::ToolRegistrationFailed => "SDK sidecar reported tool registration failed",
Self::TurnCapRefused => "SDK sidecar reported the turn cap refused",
};
fn into_fault(self, detail: String) -> InferenceFault {
let detail = format!("SDK sidecar reported {self:?}: {detail}");
match self {
Self::RateLimited | Self::Overloaded | Self::ServerError | Self::ApiTimeout => {
InferenceFault::retryable(detail)
@ -208,10 +173,10 @@ pub(super) enum SidecarLinkError {
/// The pipe reached EOF or the child exited. Never a partial output.
Closed,
/// A length prefix over the cap, a truncated body, or an undecodable one.
Codec,
Codec(String),
/// The child could not be started. The entry file existed at open, so this
/// is an environment fault an operator must fix.
Spawn,
Spawn(String),
}
/// The child-process seam, so the protocol driver is testable without Node, a
@ -238,21 +203,26 @@ pub(super) async fn read_frame<R: AsyncRead + Unpin>(
.map_err(|_| SidecarLinkError::Closed)?;
let length = u32::from_be_bytes(prefix) as usize;
if length == 0 || length > MAX_SIDECAR_FRAME_BYTES {
return Err(SidecarLinkError::Codec);
return Err(SidecarLinkError::Codec(format!(
"sidecar frame length {length} is outside 1..={MAX_SIDECAR_FRAME_BYTES}"
)));
}
let mut body = vec![0_u8; length];
reader
.read_exact(&mut body)
.await
.map_err(|_| SidecarLinkError::Codec)?;
rmp_serde::from_slice(&body).map_err(|_| SidecarLinkError::Codec)
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
rmp_serde::from_slice(&body).map_err(|error| SidecarLinkError::Codec(error.to_string()))
}
fn encode_frame(frame: &SidecarFrame) -> Result<Vec<u8>, SidecarLinkError> {
let body = rmp_serde::to_vec_named(frame)
.map_err(|_| SidecarLinkError::Codec)?;
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
if body.len() > MAX_SIDECAR_FRAME_BYTES {
return Err(SidecarLinkError::Codec);
return Err(SidecarLinkError::Codec(format!(
"sidecar frame of {} bytes exceeds {MAX_SIDECAR_FRAME_BYTES}",
body.len()
)));
}
let mut framed = Vec::with_capacity(body.len() + 4);
framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
@ -290,15 +260,15 @@ impl ChildProcessLink {
.stderr(Stdio::inherit())
.kill_on_drop(true)
.spawn()
.map_err(|_| SidecarLinkError::Spawn)?;
.map_err(|error| SidecarLinkError::Spawn(error.to_string()))?;
let stdin = child
.stdin
.take()
.ok_or(SidecarLinkError::Spawn)?;
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdin was not piped".into()))?;
let stdout = child
.stdout
.take()
.ok_or(SidecarLinkError::Spawn)?;
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdout was not piped".into()))?;
Ok(SidecarChild {
child,
stdin,
@ -496,9 +466,9 @@ impl SdkInferencePort {
return Err(link_fault(error));
}
}
Err(_) => {
Err(error) => {
let _ = self.link.restart().await;
return Err(InferenceFault::new("the tool-result owner refused a tool call"));
return Err(InferenceFault::new(error.to_string()));
}
}
}
@ -512,10 +482,10 @@ impl SdkInferencePort {
SidecarFrame::Fault {
query_id: seen,
reason,
..
detail,
} if seen == query_id => {
let _ = self.link.restart().await;
return Err(reason.into_fault());
return Err(reason.into_fault(detail));
}
_ => {
let _ = self.link.restart().await;
@ -531,11 +501,13 @@ impl SdkInferencePort {
fn link_fault(error: SidecarLinkError) -> InferenceFault {
match error {
SidecarLinkError::Closed => {
InferenceFault::new("the SDK sidecar exited during a query")
InferenceFault::new("the SDK sidecar exited during a query".to_string())
}
SidecarLinkError::Spawn => InferenceFault::new("the SDK sidecar could not start"),
SidecarLinkError::Codec => {
InferenceFault::integrity_violation("the SDK sidecar's framing was invalid")
SidecarLinkError::Spawn(detail) => {
InferenceFault::new(format!("the SDK sidecar could not start: {detail}"))
}
SidecarLinkError::Codec(detail) => {
InferenceFault::integrity_violation(format!("SDK sidecar framing: {detail}"))
}
}
}
@ -554,9 +526,9 @@ pub(super) fn lower_query(
if let Some(effort) = request.reasoning_effort.as_deref()
&& !SDK_EFFORT_LEVELS.contains(&effort)
{
return Err(InferenceFault::integrity_violation(
"the requested reasoning effort has no SDK counterpart",
));
return Err(InferenceFault::integrity_violation(format!(
"reasoning effort `{effort}` has no SDK counterpart"
)));
}
let mut items = request.input.iter();
let Some(CodexInputItem::UserText { text: prompt }) = items.next() else {
@ -669,11 +641,10 @@ fn assemble_output(
total_cost_usd_estimate: material.total_cost_usd_estimate,
};
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
.map_err(|_| InferenceFault::new("the SDK receipt could not be encoded"))?;
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,
.map_err(|error| InferenceFault::new(error.to_string()))?;
Ok(InferenceOutput {
events: lowered,
receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
})
}
@ -730,10 +701,10 @@ pub(super) struct RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: String,
/// 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>)>,
/// 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>)>,
}
impl RoutedInferencePort {
@ -741,13 +712,13 @@ impl RoutedInferencePort {
connector: Option<Arc<dyn InferencePort>>,
sdk: Option<Arc<dyn InferencePort>>,
sdk_model_prefix: impl Into<String>,
claims: Vec<(String, Arc<dyn InferencePort>)>,
local: Option<(String, Arc<dyn InferencePort>)>,
) -> Self {
Self {
connector,
sdk,
sdk_model_prefix: sdk_model_prefix.into(),
claims,
local,
}
}
@ -764,10 +735,9 @@ 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
.claims
.iter()
.local
.as_ref()
.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)
@ -793,9 +763,9 @@ impl InferencePort for RoutedInferencePort {
let model = request.provider_model().to_owned();
match self.route(&model) {
Some(port) => port.prepare(request),
None => Err(InferenceFault::integrity_violation(
"no configured inference backend claims the requested model",
)),
None => Err(InferenceFault::integrity_violation(format!(
"no configured inference backend claims the model `{model}`"
))),
}
}
@ -803,9 +773,9 @@ impl InferencePort for RoutedInferencePort {
let model = request.invocation.request.model.clone();
match self.route(&model) {
Some(port) => port.infer(request).await,
None => Err(InferenceFault::integrity_violation(
"no configured inference backend claims the requested model",
)),
None => Err(InferenceFault::integrity_violation(format!(
"no configured inference backend claims the model `{model}`"
))),
}
}
@ -1220,41 +1190,6 @@ 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]
@ -1327,7 +1262,6 @@ 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![
@ -1389,7 +1323,7 @@ mod tests {
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
DEFAULT_SDK_MODEL_PREFIX,
Vec::new(),
None,
);
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk));
assert!(Arc::ptr_eq(
@ -1400,7 +1334,7 @@ mod tests {
Some(Arc::clone(&connector)),
Some(Arc::clone(&sdk)),
"gpt-",
Vec::new(),
None,
);
assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk));
assert!(Arc::ptr_eq(
@ -1439,11 +1373,11 @@ mod tests {
};
assert!(matches!(
open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()),
open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()),
open_inference(Some(connector()), None, None, &models("claude-opus-5").each()),
Err(ControllerOpenError::UnroutableModel { .. })
));
assert!(matches!(
@ -1455,13 +1389,12 @@ 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, None, &models(TEST_MODEL).each()).is_ok(),
open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(),
"a configuration where every model routes did not open"
);
}
@ -1549,7 +1482,7 @@ mod tests {
fn soul_every_sidecar_reason_carries_its_named_disposition() {
use SidecarFaultReason::*;
for reason in [RateLimited, Overloaded, ServerError, ApiTimeout] {
let fault = reason.into_fault();
let fault = reason.into_fault("detail".into());
assert!(
!fault.requires_recovery() && !fault.integrity_was_violated(),
"{reason:?} was not retryable"
@ -1567,13 +1500,13 @@ mod tests {
Unknown,
] {
assert!(
reason.into_fault().requires_recovery(),
reason.into_fault("detail".into()).requires_recovery(),
"{reason:?} was not recovery-required"
);
}
for reason in [ProtocolViolation, ToolRegistrationFailed, TurnCapRefused] {
assert!(
reason.into_fault().integrity_was_violated(),
reason.into_fault("detail".into()).integrity_was_violated(),
"{reason:?} did not violate integrity"
);
}
@ -1751,88 +1684,6 @@ mod tests {
assert!(fault.integrity_was_violated(), "{fault:?}");
}
fn assert_names_no(fault: &InferenceFault, canary: &str) {
let rendered = format!("{fault} | {fault:?}");
assert!(!rendered.contains(canary), "{canary} leaked into {rendered}");
}
/// The SDK port's share of the no-input-text rule: text the sidecar sent,
/// a request's effort and a request's model
/// name each arrive as a canary, and none reaches the fault. The fault
/// type carries only fixed text, so this fails if a port ever routes a
/// computed string into it (through `Box::leak`, say).
#[tokio::test]
async fn an_sdk_fault_carries_no_text_it_was_handed() {
const CANARY: &str = "CANARY-sdk-91b6d0";
// The sidecar's own detail, for every reason it may name.
for reason in [
SidecarFaultReason::RateLimited,
SidecarFaultReason::Overloaded,
SidecarFaultReason::ServerError,
SidecarFaultReason::ApiTimeout,
SidecarFaultReason::AuthenticationFailed,
SidecarFaultReason::OrgNotAllowed,
SidecarFaultReason::BillingError,
SidecarFaultReason::InvalidRequest,
SidecarFaultReason::ModelNotFound,
SidecarFaultReason::MaxOutputTokens,
SidecarFaultReason::MaxBudgetUsd,
SidecarFaultReason::ExecutionError,
SidecarFaultReason::Unknown,
SidecarFaultReason::ProtocolViolation,
SidecarFaultReason::ToolRegistrationFailed,
SidecarFaultReason::TurnCapRefused,
] {
let link = ScriptedLink::new(vec![SidecarFrame::Fault {
query_id: 1,
reason,
detail: CANARY.into(),
}]);
let port = SdkInferencePort::new(link, TEST_RUNTIME);
let prepared = prose_prepared(&port, "Say something true.");
let fault = port.infer(prepared).await.expect_err("a fault frame produced an output");
assert_names_no(&fault, CANARY);
}
// An effort the SDK has no counterpart for.
let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME);
let mut prepared = prose_prepared(&port, "Say something true.");
prepared.invocation.request.reasoning_effort = Some(CANARY.into());
let fault = lower_query(1, &prepared, 1).expect_err("an unmapped effort lowered");
assert_names_no(&fault, CANARY);
// A model no backend claims, prepared and inferred through the router.
let directory = tempfile::tempdir().unwrap();
let entry = directory.path().join("main.js");
std::fs::write(&entry, "// sidecar").unwrap();
let routed = open_inference(
None,
Some(SdkBinding {
sidecar_entry: entry,
caller_runtime_id: TEST_RUNTIME.into(),
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
None,
None,
&models(TEST_MODEL).each(),
)
.expect("the SDK model routes");
let request = tool_request(
CommandId::new(),
0,
InferencePurpose::Persona,
CANARY,
"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 fault = routed.prepare(request).err().expect("an unroutable model prepared");
assert_names_no(&fault, CANARY);
}
/// Soul: the lowering's own gate table, every row.
#[test]
fn soul_lower_query_refuses_the_shapes_the_gate_table_names() {
@ -1937,7 +1788,7 @@ mod tests {
)) as Arc<dyn InferencePort>),
None,
DEFAULT_SDK_MODEL_PREFIX,
Vec::new(),
None,
);
assert!(connector_only.route(TEST_MODEL).is_none());
let prepared =
@ -1952,7 +1803,7 @@ mod tests {
.expect_err("an unroutable model inferred"),
] {
assert!(fault.integrity_was_violated(), "{fault:?}");
assert!(fault.to_string().contains("no configured inference backend"), "{fault:?}");
assert!(fault.to_string().contains(TEST_MODEL));
}
// An SDK-only deployment opens with no connector binding at all, so no
@ -1969,7 +1820,6 @@ mod tests {
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
}),
None,
None,
&models(TEST_MODEL).each(),
)
.is_ok()
@ -2047,7 +1897,6 @@ 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, TokenUsage,
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest,
RequestShape, tool_request,
};
use super::patch::{
@ -1070,12 +1070,6 @@ 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,
@ -1606,8 +1600,7 @@ 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` (parked at
// `parked/ghostlight-eve-surface`) is the
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` 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] = &[
@ -2496,7 +2489,6 @@ mod tests {
outcome_bands: Vec::new(),
carries_speech: false,
},
render_path: None,
}
}

View file

@ -1,759 +0,0 @@
//! 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

@ -1,143 +0,0 @@
//! 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

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,16 +0,0 @@
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

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

View file

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

View file

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

@ -0,0 +1,169 @@
# Call Of The Void Brainstorm
Source: https://docs.google.com/document/d/1YWn5RWsciBxuaQ4l5TN2lAEz4okaQes-aiyx1E9NAxA/edit?tab=t.0
This is a local plaintext import of the scattered Call of the Void brainstorming doc. Treat it as raw source material, not canon. The current compact design note remains E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md.
## Current Canon Correction
This brainstorm predates a major Aetheria timeline migration.
The old version assumes a colony fleet or exploratory force was shunted into
Elysium and slowly discovers that nobody is going home. That is no longer the
live premise.
Current canon, summarized in
`E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\From Sol to Elysium.md`,
is that the FTL trigger sequestered the whole Solar system, with its fleets,
institutions, corporate blocs, uploaded minds, labor systems, histories, and
unresolved social machinery. Elysium is not a remote elsewhere that a fleet
cannot leave. It is the sealed domain into which Sol itself was displaced.
So the useful dramatic pressure is not "can the expedition return home?"
because home came with them. The better pressure is:
- there is no clean outside to return to
- the systems people hoped to escape are trapped in Elysium too
- the old order can no longer rely on the old universe remaining stable around
it
- every faction is trying to define what survival, legitimacy, personhood, and
ownership mean now that Sol's whole inheritance has been exported into the
sealed domain
- Cat's story should turn on the realization that starting over is not escape
when the machinery you fled arrived in the same disaster
The raw notes below remain useful for cases, characters, tones, and comic
cruelty. Treat their old "return home" twist as obsolete pressure, not active
continuity.
---
Potential ideas here, feel free to discuss/deplore/detour in comments.
Protagonist: Catastrophe “Cat” Marrigan, an out of work private investigator, driving a particularly failure-prone space taxi to make ends meet
Overarching plot: Episodic PI cases/random space taxi fares which lead, somewhat circuitously, towards the discovery/revelation that the “exploratory force” had better get used to being “locals”, because the universe is not offering return tickets, and all that follows from that.
What’s Cat’s role in this? And are we going to have alternate endings based on choices made? Ideas, in no particular order:
* She's the original founder/inspiration for the frontier renegades who reject corporate rule and live low-tech and tiny to avoid being crushed.
* The more noir-esque ending, where the disillusioned protagonist sees the way things are going and kills themselves/goes out in a blaze of explosive glory in a futile attempt to change the course of society.
* The out-for-what-you-can-get ending, where she allies with an up and coming power to become a behind the scenes force in future aetheria.
* The really unsatisfying but realistic ending, where the corps take over and life just goes on.
* I sort of like the idea of heading towards a “you can’t win against the capitalist machine, but it’s worth fighting anyway” theme. Thoughts?
case/fares/conversations that exemplify the different ways people react to the point of no return, so to speak, ie: denial, determination to find a way to get back, culty doomsdays types, the “we’re all doomed so let’s party” people, the ones who see great opportunity without old earth holding them back, the people who are determined to do things better this time around (in the ‘we royally screwed up earth, let’s not do it again” sense), etc.
Accidental AI ship whose buggy programming makes them the worst autopilot ever orrrrr…the inciting incident is that in one of her cases/fares gone wrong Cat gets mind-melded with said slightly buggy AI, and that’s how she finds out all those juicy government secrets. Some of them may be inaccurate (see: bad code/failsafes/sabotage), but there’s no way of knowing which without going digging. Plus AI is a one of a kind experimental prototype, which sets all kinds of interesting people on their tail. Also could give us some fun things to do with ship control and actual gameplay quests.
Over the course of the story, one way to show people’s reactions to events could be to have some dialogued characters talking about what they plan to do when they get back home, then use it as a major stress point once the truth is revealed.
Maybe Cat works for a small, kind of terrible PI company, in which case we could get some of our quests from a dispatcher/boss/whatever. I think it’d be a little funny if in true noir fashion, boss was a hard-drinking dude in a trenchcoat, only he’s a really giggly drunk/can’t hold his liquor.
First quest for PI company: find where the boss left a random object after drinking himself to the floor. Find out he left a whole-ass ship somewhere in his drunken stupor.
Another PI quest: investigate the disappearing foodstuffs of a concerned homeowner and find out that a whole nother person was living in the crawlspace above their home and stealing their food. Decide how to deal with Mr Crawlspace.
PI quest: Investigate “ghost” sightings in an old part of a station that’s been shut down. Find out a whole town’s worth of people are living there out of the station’s control slowly siphoning off resources so as not to be noticed. Get some slightly culty vibes from them.
A short quest from an old lady who just wants to have someone over for tea. Turns out if you tolerate her long enough she’ll come in handy during a critical time. Could be she’s either rich or well-connected.
A botanist looking for his daughter. Turns out instead of some sorta protomolecule conspiracy, she’s just wandered off and is being looked after by a kind lady who’s found her.
Get a quest from a mysterious person who is only contacting Cat online, find out eventually the quest giver is some sort of AI who’s either seeking the sweet release of deletion, is looking to find another AI, or is trying to steal some sort of drone so it can go around freely.
Possibly a character who we only see as a remote-controlled drone who helps out at times but we never see the person behind the drone because they use the drone as their public avatar or something. Could be someone who might not otherwise get the chance to go out and about in public because of reasons. This does give them a limited sphere of helpfulness since the drone has to be close enough to the controller to work effectively. Could have Cat mistake the drone for the character, believing it’s some sort of self-sufficient AI driven drone and be sad when it “dies” only to meet it again once it’s re-built and realise there’s a person behind it. Bit like the “pay no attention to the man behind the curtain” bit, could call the drone “Oz”.
Cat is tasked with investigating a mysterious and aggressive band of pirates. Upon finding them it is revealed that they are actually a community of rogue AIs who have no option but to loot technology transports in order to keep themselves alive.
Cat stumbles into a friendship with a shady character, earning the attention of the even shadier Space CIA, which recruits her as a potential double agent with instructions to infiltrate the organization.
Cat is sent to investigate a series of mysterious disappearances of ships sent to mine a gas giant’s belt. Locals consider the place haunted. Turns out it is, by a sentient particle system which considers itself the embodied spirit of the planet.
DLC Area: Cat falls afoul of some sort of anomaly which sends her into a parallel universe with an alternative history. Somehow despite a very different timeline and political situation, everyone she meets is a bizarro mirror of the people she knows from her dimension, and somehow the exact same cast of characters are present despite having very different roles (listen, we can’t afford any extra actors so we’re just gonna use the cast we already have)
Finch is destructively scanning recently deceased human brains and then performing extensive virtual surgery on emulated instances of them in order to create and sell packaged “AI” servitors for specific tasks to compete on the market against AGI products from competitors. The Crew manages to rescue one of the program’s victims while still mostly human, before the process is complete, who [joins the party / is installed on Cat’s ship].
Character: Reginald Moneybags III, inheritor of a massive family fortune, is looking to become a patron of the arts in order to impress his old-money friends. Perhaps they’re the one giving the player their ship?
Might could explore some of Cat’s motivations for taking the ship to Elysium in the first place. Was it by choice? Who has she left behind, if anyone? It would put her in a more conflicted position regarding her role as a double-agent.
* Seems natural that she would be escaping a maze of debt and obligation, only to end up terrifyingly close to falling into the same trap again. I kinda like this idea of completely dumping her whole life and trying to start over somewhere completely different.
* She was caught committing some felony/is railroaded/scapegoated for same/investigated someone way too powerful and was given a choice between prison and joining the colonists.
* Or combine both and bring back the idea of debtor’s prisons/workhouses and have the ship to Elysium be offered as a way out
* So, was she indentured when she arrived only to recently gain the freedom to make her own way, or has she been doing what she’s currently doing since arrival?
The faceless man: a guy? maybe. Wearing a spacesuit or some other obscuring disguise that pops up a little too often for random chance. Is he a red herring? Or is he 10 red herrings in a spacesuit pretending to be a guy? If he has any dialogue let it just be *splashing noises*.
Cat is sent to investigate an urban legend on one other stations--the simply and slightly erroneously historically named “Ripper Lee” who lives in the air vents and steals children. It actually turns out that the missing kids are just exploring the vents as children will and getting macheted by fans or getting stuck or getting pulled into the inertial compensator. [an act II continuation of this quest sees Cat investigating her suspicions that the kids didn’t necessarily go into the dangerous areas of their own impetus, but were lured there. The third act reveals an alien intelligence--though an animal one--a being of energy or something stranger who finds the particular pitch of childrens’ laughter unbearable. Maybe it destructively interferes with its phase, who knows]
The one perpetual drunk who always needs a lift to a different space bar. Cat can go on
a bender with him as a quest, and then have to figure out exactly what the hell happened and why there’s a crash test dummy in the front seat of her taxi.
The person who jumps into Cat’s cab and yells “follow that ship!”. A high octane space chase follows, at the end of which it is revealed they’ve been following the wrong ship, and the Minister of such and such is *very* displeased that he missed his Very Important Meeting for what turns out to be not even an actual assassination attempt.
A carjacking with several different options for resolution
Something strange is going on in the mining operation on PXR 6937, and Cat is sent to find out what: suggestions: the drilling released a substance from the rock that causes hallucinations and paranoia in human brains. Someone is deliberately sabotaging the operation as an environmental protest/act of general rebellion/the mining operation killed someone they cared about and now everyone must feel the same misery
Cat is forced to navigate the pointless bureaucracy of space offices to uncover an embezzlement scheme that conceals something much bigger. What is it? Fuck if I know.
* A furniture selling operation that appears to cover some financial wizardry but turns out to be a human trafficking operation where the names of the items are actually those of missing children OW MY HEART
* A fiendish plan to utilize the combined computing power of thousands of office servers to...mine future bitcoin? Solve something the megas don’t want solved?
Cat’s taxi breaks down somewhere off the beaten path, so to speak, and she finds herself drawn into an ongoing and very brutal feud between two families.
Have a random cab passenger speak in Nemsk and never explain it.
An entire district of a certain station orbiting a strange ocean planet begin to complain of seeing people who could logically never be there. Cat is asked to do a cursory look into it and remains sceptical until one day she starts seeing a person shaped figure always walking just barely out of her line of sight. We could either go full Lem, or subvert this reference by having the reveal be that the air or water are contaminated with a hallucinogenic compound.
A Captain Nemo like figure who flies his rather unusual ship on the far edges of the system. His purpose is unclear, and his identity more so.
Silica storage ruptured, spilling into common area. Now we’re doing the obligatory beach episode! Sand got into the gears of the docking bay so now everyone has to sit back for an afternoon and do anime tropes while it's getting repaired.
What organization is Cat infiltrating?
* People who are trying to go home, even if it costs them the ship or other people’s lives
* Believe the Megas are withholding research on the return trip, trapping everyone in Elysium for profit
* Name ideas
* Home (nice and simple; is it an acronym: H.O.M.E.?)
* A nickname could be the “homies” lmao love it “wassup homie?”
* Terra
* The Terra-rists or Terrists (like a hick would say) Get out no.
* Could be a media nickname (and everyone is really tired of explaining that “no, no, we’re the Alliance for Returning Swiftly Earthward, really” really? Butt jokes? I have never pretended to be above them. Neither have I.
* The Earth-Bound
* The Regressionists
* The Returners (sounds like a ya novel title) true
* The Retrograde Alliance
* Earthers
* The Genesis Project (Project Genesis? Sounds better.) Genesis Faction?
* Alliance for Returning Swiftly Earthward
* Gaia-ists possibly too many vowels
* The Followers of Gaia? Get that good greek mythology I kind of like this one. The Gaia Cult lmao
* Cult of Gaiains, but also yeah, the GaiaCult
* Hades (if we’re going Greek mythology, normal people go to Hades while demigods go to Elysium) Erebus
* Has the bonus “evil” connotation
* Hadesians Perhaps? Like people from there...Hadesites?
* Fits into the sentence “The CIA needs you to infiltrate Hades”
* Though if we’re going for the greek theme, Odysseans might be more apropos with the whole ‘returning home’ I like this for greek references
How do we handle double agent shenanigans?
* Cat grows to sympathise with their goals and philosophy, ends up rejecting the CIA
* Cat comes clean with her new friends, who begin reciting the typical cliche, only to have someone start to read a page that semi-describes events later in the story, like it's a script.

View file

@ -0,0 +1,386 @@
# Aetheria Agent Population Model
This note explains how a socially dense Aetheria region can feel inhabited
without simulating every implied person as a fully persistent agent.
The short answer is that population and agency are not the same thing.
A corridor can imply hundreds of thousands or millions of people while only a
few dozen individuals need to exist as full long-lived social nodes.
## Design Goal
We want a region that feels:
- crowded
- economically alive
- politically pressured
- socially stratified
- full of rumor, labor, traffic, and institutional drag
We do **not** want:
- ten thousand expensive little chatbots idling in a warehouse
- fake scale achieved by silent cardboard extras
- a world where only named characters seem to exist
So the population model needs to separate:
- who exists as a persistent social mind
- who exists as a grouped social force
- who exists only as local scene color
- how offscreen population pressure becomes visible
## Four Population Layers
The MVP should use four layers.
### 1. Persistent Agents
These are the full agents.
They have:
- identity
- memory
- relationships
- goals
- obligations
- changing stance toward institutions and people
These are the characters who accumulate history and can plausibly remember what
happened last week, who lied to them, who saved them, and which institution
just humiliated them.
Examples in an Aetheria content-generation slice:
- a faction liaison
- a ship officer
- a labor organizer
- a corporate handler
- a sanctuary coordinator
- a black-market broker
- a cutout vendor
- a technical specialist with inconvenient loyalties
These are not the population. They are the pressure points where
population-scale systems become legible.
### 2. Cohorts
Cohorts are grouped social bodies that matter but do not need full individual
cognition.
They should represent classes of people whose collective condition can change
over time.
Examples:
- dock labor shifts
- convoy deck crews
- insurer back-office clerks
- sanctuary residents
- clinic queues
- pirate route crews
- private security contractors
- debt pilgrims
- repair-bay technicians
- refugee transit waves
Each cohort should have state such as:
- size
- location
- stress
- trust in institutions
- rumor exposure
- faction sympathy
- fatigue
- scarcity pressure
- protest potential
- desertion / mutiny risk
This is how the world gets to have labor, crowds, queues, shortages, and
unrest without pretending every forklift operator needs a hundred memories and
a private speech cadence.
### 3. Ephemeral Scene Actors
These are temporary people instantiated only when a scene needs bodies,
witnesses, clerks, mechanics, medics, bystanders, or small bits of friction.
They should have:
- a role
- a scene goal
- local awareness of current events
- a little voice
- minimal short-term memory
They should **not** be treated as deep persistent minds by default.
Examples:
- a berth marshal waving traffic through a panic checkpoint
- a frightened passenger arguing with a clerk
- a dockhand spreading a fresh rumor
- a medic triaging one more body than the shift can really handle
- a sanctimonious insurer junior who disappears once the scene ends
If an ephemeral keeps mattering, the system can promote them into a persistent
agent later.
### 4. Ambient Population Pressure
This is the layer that makes the world feel bigger than the visible cast.
It is not represented by people at all. It is represented by signals.
Examples:
- berth queues growing or shrinking
- cargo backlogs
- corridor delay maps
- sanctuary occupancy
- clinic wait times
- food prices
- insurance repricing
- labor slowdown notices
- protest bulletins
- casualty lists
- rescue requests
- media chatter
- tribunal rulings
- route closures
This is where the implied millions live in practice.
## How The Layers Interact
The model should feel like this:
- ambient pressure changes cohort state
- cohort state changes the incentives around persistent agents
- persistent agents make decisions that alter ambient pressure
- ephemeral actors express the local scene consequences
That loop lets a single adjudicator decision or pirate rescue cascade outward
into visible population-level effects without simulating every single person
affected.
## Why A Few Dozen Persistent Agents Is Enough
Because the simulation is not trying to create a census. It is trying to create
a socially legible machine.
In a dense Aetheria slice, one persistent agent can sit at the hinge point for
a great many people:
- one dispatcher affects dozens of ships and hundreds of crew
- one sanctuary coordinator gates a network under pressure from thousands of vulnerable lives
- one corporate official can reshape access, risk, and reputation
- one faction delegate can legitimize or delegitimize a compromise
- one broker can redirect shadow traffic across many dependent cells
The persistent cast is therefore best understood as:
- decision-makers
- bottlenecks
- brokers
- organizers
- interpreters of institutional rules
- people with unusual leverage over larger flows
They are the knots in the rope, not every fiber.
## Cohort Model
Cohorts should be tractable and opinionated.
Each cohort record should include:
- `cohortId`
- `type`
- `sizeBand`
- `region`
- `factionAffinity`
- `currentPressures`
- `resourceDependency`
- `confidence`
- `grievance`
- `discipline`
- `mobilizationPotential`
- `recentIncidents`
Useful cohort types for Aetheria story and content generation:
- dock labor
- ship crews
- sanctuary residents
- corporate workforce
- shadow-market crews
- repair-yard workers
- shadow clinic clients
- private security detachments
- local merchant bodies
- transient passenger pools
Cohorts do not need rich dialogue. They need believable collective reactions.
## Ephemeral Actor Rules
Ephemeral actors should be generated from:
- scene location
- cohort state
- current world pressures
- factional tone
- time of day / shift
That means the same dock concourse can produce different incidental people
depending on whether the corridor is calm, panicking, starving, over-inspected,
or politically inflamed.
Ephemerals should support:
- local exposition
- scene texture
- small moral friction
- witness testimony
- short disputes
- visible consequences
They should expire cheaply unless the world keeps pulling them back into
relevance.
## Promotion Rules
Some ephemerals should be promoted into persistent agents when:
- a player or major agent forms an ongoing relationship with them
- they become central to repeated incidents
- they accumulate leverage or notoriety
- they are the surviving witness to something politically explosive
- they become the face of a cohort-level shift
Promotion is how the world grows specific people organically instead of
requiring the whole cast to be authored up front.
## Population Signals That Make A Place Feel Busy
If the corridor is meant to feel inhabited, the system should constantly
surface signs of offscreen life.
That means scenes, dashboards, and local UI should be fed with things like:
- arrivals and departures
- route advisories
- late manifests
- dockside rumor digests
- occupancy warnings
- strike whispers
- inspection delays
- tribunal alerts
- debt-claim notices
- casualty and rescue numbers
- commodity shocks
- public feeds and scandal bursts
The player does not need to meet everyone. They need to feel surrounded by
consequences.
## Content-Generation Population Recipe
For the first authoring slice, a sensible starting mix is:
- **20-40 persistent agents**
- **10-25 cohorts**
- **0-20 ephemerals per active scene**, generated on demand
Persistent clusters should be chosen from the story context, but the useful
categories are:
- primary faction representatives
- ship or station operators
- labor and logistics figures
- care, sanctuary, or rights workers
- criminal or deniable-economy brokers
- corporate cutouts
- technical specialists
- witnesses with leverage
Cohorts:
- dock labor
- crews
- sanctuary or civilian overflow
- clinic queues
- administrative or inspection staff
- security contractors
- shadow-market cells
- repair techs
- cargo handlers
- passengers / transients
That is enough to make generated scenes feel socially populated without turning
the authoring runtime into a screaming furnace.
## Simulation Tier Mapping
The population layers should map onto the simulation tiers cleanly.
### Tier 0
- dormant persistent agents
- slowly changing cohorts
- coarse ambient indicators
### Tier 1
- active persistent agents making strategic moves
- cohorts shifting under pressure
- route, labor, and sanctuary state updating
### Tier 2
- a handful of persistent agents in scene
- relevant ephemerals generated for the local moment
- cohort state informing mood, risk, and crowd behavior
### Tier 3
- full interactive dialogue for the most relevant persistent agents
- ephemerals only as needed for texture or interruption
- world pressure visible through feeds, queues, alerts, and consequences
This keeps expensive cognition focused where it matters.
## What Not To Do
Do not:
- simulate every resident as a full agent
- let cohorts become inert statistical wallpaper
- use ephemerals as consequence-free decoration
- make named agents behave as if the rest of the world does not exist
- make ambient scale purely visual with no mechanical pressure
That path leads to a world that looks crowded and feels dead.
## Success Criteria
The model is working if:
- the player can feel institutional pressure without being told about it in lore prose
- small decisions by key agents visibly affect larger populations
- cohorts can become restless, relieved, fearful, or politically active
- scenes feel crowded when they should
- the same corridor can feel different under calm, panic, shortage, scandal, or repression
- the named cast feels embedded in a larger society rather than posed in a diorama
The real test is simple:
If the player leaves the room, do they still believe thousands of people are
having a bad day because of what just happened?
If yes, the world is inhabited.

View file

@ -0,0 +1,206 @@
# Aetheria Content Generation Targets
This is the first concrete target note for applying Ghostlight to Aetheria.
The build target is not a standalone corridor simulation or local crisis game
slice. That older shape was fertile story terrain, but it is not the current
product direction. Do not let it shape the runtime unless the user explicitly
revives it.
Ghostlight should first serve two Aetheria uses:
1. content generation inside the **Call of the Void** slice described in the
`AetheriaLore` repository
2. dialogue scaffolding and procedural drama for narrated stories across the
Aetheria timeline
The goal is a story and content-generation organ: a machine that can hold
characters, factions, pressures, memories, masks, and conflict arcs in explicit
state, then project that state into usable scenes, dialogue beats, outlines,
and dramatic complications.
That organ must also hold tone. Aetheria should not be flattened into one dry
technical crisis register. The setting can carry Adams/Pratchett-style
wit-with-stakes, domestic comedy, noir suspicion, horror, wonder, dour poetry,
romance, workplace absurdity, and procedural systems prose, often inside the
same broad canon. The common rule is not "be grim." The common rule is that
tone must reveal character, setting, and consequence.
## Why This Helps Dialogue
Ghostlight should not ask one LLM prompt to "write good character drama" from
vibes. That path produces clever paste: fluent, energetic, and usually a little
dead behind the eyes.
The useful split is:
- Ghostlight builds the drama, narrative pressure, scene setup, and state.
- The dialogue renderer gives each character a constrained local context.
- The character speaks from who they are, what they have endured, what they
want, what they are feeling, what they are hiding, and how they perceive the
person in front of them.
This does not mean every line should sound like emergency procedure. Characters
also speak from routine, fatigue, affection, boredom, private jokes, little
status games, cultural rituals, and ordinary work. Those are not filler; they
are what make later pressure legible.
That means dialogue prompts should be character-local rather than omniscient.
They should include only the state that character plausibly has access to:
- identity and role
- current mood and pressure
- relevant memories
- active goals
- relationship stance toward the listener
- perceived stakes
- cultural or factional scripts
- mask, concealment, or presentation strategy
This lets characters breathe and act inside the scene instead of serving as
mouthpieces for a single plot-generating agent. The agent can shape the drama.
The character projection should preserve subjectivity.
## Primary Use: Call Of The Void Content
Call of the Void should be treated as the first consumer context for Ghostlight,
not as an excuse to build a full social MMO simulation immediately.
The local source of truth is:
- `E:\Projects\AetheriaLore\Aetheria\Game Design\Call of the Void.md`
- `docs/aetheria/call-of-the-void-brainstorm.md` for raw imported brainstorm
material
The slice is a story-first Aetheria scope cut about Catastrophe "Cat" Marrigan:
an out-of-work private investigator making ends meet with a failure-prone space
taxi inside the aftermath of Sol's sequestration into Elysium.
The older brainstorm frames the core twist as a colony fleet or exploratory
force discovering that nobody is going home. That premise is obsolete. Current
Aetheria canon says the FTL trigger displaced the whole Solar system into the
sealed spacetime domain later called Elysium. Home did not stay behind. Home,
with every corporate bloc, labor regime, upload abuse, political failure, and
unresolved compromise, came through too.
So the active Call of the Void pressure should be refactored from "there is no
return ticket" into something meaner and more useful: there is no outside, no
clean restart, and no frontier innocent of Sol's history. Cat can still try to
outrun debt, coercion, legal pressure, or institutional rot, but Elysium proves
that escape exported the machinery instead of dissolving it.
Its useful shape is authored density:
- episodic investigations
- taxi fares as social x-rays
- station districts
- recurring characters
- faction contacts
- campaign consequence
- the social aftermath of realizing Sol's old failures are now Elysium's local
weather
Ghostlight should help generate and manage:
- character dossiers
- faction stances
- relationship tensions
- tonal mode and style targets
- ordinary-life texture before conflict
- scene premises
- dialogue scaffolds
- procedural complications
- continuity-aware reactions
- memory-shaped follow-up scenes
- local cultural and institutional pressure
- case premises and clue chains
- passenger encounters that reveal denial, opportunism, homesickness, fear, or reckless celebration
- continuity pressure from the sealed-domain premise
- cases that expose how old Sol institutions mutate when there is no larger
outside to appeal to
The important thing is that generated content should emerge from structured
state instead of a prompt asking for "dramatic dialogue" and hoping the machine
does not start juggling knives in the pantry.
## Secondary Use: Narrated Timeline Stories
Ghostlight should also support story generation across Aetheria's broader
timeline.
The existing Aetheria stories are tonal evidence. `When We Get Home` makes the
setting legible through jokes, domestic longing, debt, delays, and the slow
recognition that home has moved. `Rain` makes the setting legible through
ritual, memory, sensory repetition, and a patient reversal from wonder to
horror. Future fixtures should be able to choose similarly varied modes instead
of filing every event into one stern technical drawer.
That means it should be able to produce:
- scene outlines
- relationship escalation chains
- factional pressure maps
- alternate dialogue passes
- conflict beats
- character-specific reactions to historical events
- continuity summaries for later story work
This does not require every story to run as a live simulation. Some outputs can
be generated as authoring scaffolds: structured suggestions, scene cards,
argument maps, motive webs, and dialogue skeletons for a human author to shape.
## What Ghostlight Should Build For This
The first useful organs are:
1. canonical agent state schema
2. perceived-state overlay schema
3. relationship and faction stance records
4. event and scene records
5. prompt projection renderer for content generation
6. coordinator/story runtime driven by goals, values, pressure, memory,
continuity, branch flags, and author constraints
7. branch compiler that turns reviewed branch plans into Ink, sidecars,
callbacks, consequence variables, and visual prompt handles
8. IF artifact reviewer that catches decorative choices, fake folds, unused
variables, and missing consequence callbacks
9. dialogue scaffold renderer that preserves stance, mask, and relationship
asymmetry
10. character-local dialogue context packs that restrict each generated voice to
what that character knows, feels, wants, fears, and perceives
The output should be inspectable. The author should be able to see why the
machine thinks a character would deflect, confess, threaten, soften, bargain,
lie, leave, spend a resource, conceal evidence, or make a choice that changes a
later scene.
## What This Is Not
This is not yet:
- a standalone corridor crisis game
- a persistent corridor simulator
- a full MMO social runtime
- a requirement to simulate every offscreen person
- a replacement for authorial judgment
Ghostlight is an authoring and social-state engine first. The live game-shaped
organs can come later if they earn the trouble.
## Success Criteria
The first implementation is useful if it can:
- keep character state distinct from prompt prose
- render dialogue from character-local context rather than global plot vibes
- generate branching dialogue/action scaffolds that reflect relationship history
- create procedural drama from incompatible goals instead of random hostility
- preserve cultural and factional pressure without making clones
- produce story beats a human author can actually use
- make branch choices matter through later affordances, callbacks, visual
modifiers, costs, risks, and endings
- explain its choices in state terms rather than vibes in a rented coat
The real test is not whether the system can improvise a cool scene once. The
real test is whether it can help generate the tenth scene without forgetting
what the first nine did to everyone involved.

View file

@ -0,0 +1,386 @@
# Aetheria Lore Grounding Architecture
Ghostlight needs three different Aetheria modes:
- grounded historical training
- post-Rupture procedural future training
- procedural Elysium generation
Do not blur them. That is how a useful mystery becomes a filing cabinet full of
branch facts with the labels scraped off.
## Core Boundary
Planned Aetheria gameplay takes place in Elysium. What happens there is
intentionally not fully fixed in the lore vault because discovering and shaping
that history is part of the games themselves.
The Aetheria timeline begins when humanity is transplanted into Elysium. It can
restart when humanity destroys itself again through negligence, greed,
institutional capture, civilizational momentum, and the familiar psychological
trash fire wearing a crown.
That means Elysium-era gameplay scenes are not the best first source for
grounded Ghostlight training data. They are downstream procedural territory.
For grounded training data, Ghostlight should start from the authored historical
archive: late Sol, Pre-Elysium institutions, factional flashpoints, personhood
conflicts, labor regimes, political collapse, and the FTL trigger.
That does not mean Ghostlight should avoid Elysium futures. The trained models
will need to handle post-Rupture concepts: Aether, pseudospace, temporal
nonlinearity, spirits, altered substrates, necrotech, mutable bodies, and all
the new ways power learns to monetize miracles while pretending it found ethics
in a drawer.
Elysium future data belongs in a separate lane because its canon is conditional,
not because it is fake. Sol history is a single artifact. Elysium history is a
branching state-space: every possible future is canon inside the branch whose
prior conditions produced it. A generated future must therefore carry branch
lineage, dependencies, and local truth boundaries instead of being treated as
scratch material waiting for permission to matter.
## Revised Architecture
Grounded historical training flow:
```text
Aetheria authored historical lore
-> lore grounding digest
-> historical scene fixture
-> agent-state fixture
-> speaker-local input slice
-> projection artifact
-> prompt_text
-> generated dialogue or drama
-> reviewed training example
```
Procedural Elysium flow:
```text
grounded pre-Elysium priors
-> procedural Elysium branch state
-> Ghostlight agents and institutions
-> projected dialogue and drama
-> simulated branch history
-> branch-local canonical state
```
Post-Rupture future training flow:
```text
authored Elysium concepts and constraints
-> possible future premise
-> procedural branch fixture
-> coordinator plan and projected local contexts
-> generated action, dialogue, and event consequences
-> reviewed future-branch training artifact
-> branch-local canonical history indexed by lineage
```
The first flow teaches the machine what grounded social pressure looks like.
The second and third flows teach it how those pressures mutate under Elysium's
stranger conditions while keeping branch-local canon separate from the Sol
artifact and from sibling Elysium branches.
## Source Priority
For projection training, prefer sources in this order:
1. Authored historical lore with explicit institutional, social, and political
context.
2. Grounded transition material around the FTL trigger and displacement.
3. Historical fiction or scene fragments that remain canon-compatible.
4. Authored post-Elysium concept documents that define altered conditions,
technologies, species, metaphysics, or social pressures.
5. Elysium-era procedural fixtures marked with explicit branch lineage and
conditional truth boundaries.
Call of the Void and Cat/Oz can remain useful for testing projection mechanics,
speaker-local boundaries, and prompt shape. They should not be treated as the
primary linear-history training corpus because their facts belong to Elysium's
conditional branch space.
## Historical Flashpoint Candidates
Good first fixtures should come from parts of Aetheria that are already
pressure-dense and source-grounded:
- contract citizenship and debt regimes
- labor-system enforcement and infrastructure dependency
- upload abuse and contested personhood
- uplift or nonhuman personhood conflicts
- algorithmic governance and predictive policing
- corporate blocs turning infrastructure into leverage
- the FTL program as a political escape hatch from Sol's failure
- the moment around the FTL trigger, where institutions and people discover
that escape did not mean absolution
The best scenes are small, local, and socially loaded:
- a worker negotiating dangerous contract terms
- an upload arguing over continuity, ownership, or service denial
- a technician asked to sign off on an unsafe FTL dependency
- a debtor deciding whether help is relief or capture
- an institutional agent performing compassion while enforcing a machine
- a family, crew, or workplace discovering that political collapse has become
intimate
## Lore Grounding Digests
Before producing training projection examples from a historical flashpoint,
create a lore grounding digest.
The digest format is defined in:
- `docs/architecture/lore-grounding-digest-format.md`
- `schemas/lore-grounding-digest.schema.json`
- `examples/lore-grounding/historical-flashpoint.template.json`
A digest should include:
- source files and relevant line or section notes
- time period
- location or institution
- involved factions, classes, and roles
- material constraints
- local norms, taboos, and scripts
- vocabulary or speech-register cues
- what each speaker plausibly knows
- what each speaker misreads
- author-only context that must not enter prompt text
- open questions needing lore elaboration
It should also include cultural and factional pressure fields:
- internal virtues
- internal shames
- prestige markers
- taboo triggers
- emotional display norms
- trust defaults
- speech register cues
- outsider stereotypes
- factional misread patterns
- role obligations and failure modes
The digest is not a replacement for the lore vault. It is a compiled working
slice for one fixture, so projection examples can cite grounded pressure
without dragging the whole archive into every prompt.
## Fixture Types
Use explicit fixture status:
- `historical_grounded`
- `transition_grounded`
- `procedural_branch`
- `future_branch`
- `branch_lineage_canon`
`historical_grounded` and `transition_grounded` examples can become primary
single-history training data.
`procedural_branch` and `future_branch` examples are canonical inside their own
branch lineage. They are useful for testing, generation, responder training,
coordinator training, and post-Rupture concept coverage, but must not be treated
as facts that also apply to sibling branches or to the Sol artifact.
`branch_lineage_canon` examples are reviewed branch histories whose lineage,
prior conditions, and local state have been made explicit enough to serve as
stable training or gameplay continuity.
Use `future_branch` when the point of the fixture is to explore possible
post-Rupture Elysium outcomes and weird-concept behavior. Use
`procedural_branch` for ordinary gameplay-era branch fixtures whose main job is
scene/runtime testing.
## Projection Rules
Grounded projection examples should prove:
- the prompt text is self-sufficient for the dialogue model
- setting and institutional context are speaker-local
- historical pressure appears as lived constraint, not exposition paste
- the speaker does not know author-only future outcomes
- the generated behavior follows from state, culture, memory, relationship, and
material pressure
If the lore source is too vague for a convincing scene, stop and elaborate the
lore slice first. Do not train the model to pave over missing social reality
with confident fog.
## Post-Rupture Future Branches
Future-branch generation should be deliberate, not a slush pile of glittering
possibilities with no handles.
Each possible future fixture should declare:
- which authored post-Elysium concepts it exercises
- which facts are fixed source constraints
- which facts are generated branch assumptions
- which branch facts are local to this possible future
- which prior conditions make this branch true
- which facts belong to this branch lineage and not to sibling branches
- which generated facts must not leak back into single-history Sol training
- what new social pressure the weird concept creates
- what old pre-Elysium pressure is returning in a new costume
## Attractors, Rails, And Injections
Elysium is a branching state-space, not an undifferentiated fog. Some events may
happen in most branches because the pressures almost always produce them. Some
events may be authored constraints because the game needs a stable enough world
to design around. Some events may be injected by quests or scenario logic when
an active branch reaches a compatible shape.
Use separate labels:
- `branch_attractor`
- `fated_event`
- `tech_order_constraint`
- `quest_injection`
- `branch_local_event`
`branch_attractor` means an event is highly likely under broad pressures but
not absolutely forced. The founding of a faction born inside Elysium may start
here if the social, economic, metaphysical, and institutional gradients make it
show up again and again with local variation.
`fated_event` means the authorial hand is on the scale. The event should occur
unless the branch has explicitly disabled it. Use this sparingly, because too
much fate turns the quantum superposition back into a hallway with rude
wallpaper.
`tech_order_constraint` means discovery order matters. Some technologies,
metaphysical practices, industrial dependencies, or social adaptations should be
unavailable until their prerequisites exist. This is not merely lore flavor; it
is a runtime gate for what quests, economies, factions, and character actions
can plausibly do.
`quest_injection` means authored content enters the active timeline at a branch
point. A quest may require prerequisites, forbid certain prior states, create
new local facts, or push an attractor toward one concrete manifestation.
`branch_local_event` means the event is canonical inside its lineage but has no
special pull across other branches.
Future branch fixtures should record:
- event type from the labels above
- probability or pull strength, for attractors
- required branch prerequisites
- excluded branch states
- technology prerequisites
- faction or institution prerequisites
- quest ids or content hooks, if injected
- local variation allowed
- hard facts created
- branch facts that remain conditional
Future branch fixtures should also emit technology and item manifest candidates
when exploration changes the material economy:
- new item families
- new item variants
- component and subassembly breakdowns
- assembly upgrade surfaces
- technology prerequisites
- faction or manufacturer access
- supply bottlenecks
- salvage, counterfeit, maintenance, and substitution paths
- quest hooks created by scarcity or discovery
See `docs/architecture/technology-item-manifest-plan.md`.
Factions born in Elysium should be modeled through this lens. Adrasteia or Miss
Terri's Sugarrific Snack Company can be represented as pressure-born attractors,
fated institutions, quest-injected branch content, or some combination. The
point is not to decide that once in prose and hope the game remembers. The point
is to make the founding mechanism explicit enough for Ghostlight, the active
timeline, and later game systems to agree on what kind of inevitability they are
dealing with.
Good future branches should stress the model with conditions it cannot learn
from late-Sol history alone:
- Aether as infrastructure, ecology, religion, weapon, medicine, or status good
- pseudospace as transit, concealment, displacement, exile, or labor terrain
- temporal nonlinearity as memory, debt, evidence, grief, insurance, or law
- spirits as persons, tools, contaminants, witnesses, ancestors, or frauds
- necrotech as care, extraction, continuity, punishment, or labor capture
- mutable bodies and species boundaries as liberation, class marker, liability,
taboo, or supply chain
- post-Rupture institutions rebuilding old hierarchies with new miracles
These branches should feed:
- Aetheria responder training, so the model learns post-Rupture assumptions
- coordinator training, so the story runtime can handle weird scene premises
- appraiser and relationship training, so characters misread new phenomena in
socially grounded ways
- institution/faction/consumer training, so economic and political behavior can
respond to miracles as markets, threats, privileges, and dependencies
Keep source provenance strict. A branch can invent a local future, but it should
say what it invented and what branch conditions make it true. The machine may
generate futures under supervision; it does not get to erase the branch index
while everyone is looking at the shiny ghost.
## Operating Doctrine
Ghostlight will generate a lot of Aetheria dialogue, branching scenes, and
training data. That means it will also be the first tool to stress-test many
timeline details at room scale. Treat that as part of the job, not an
exception.
Before writing an Aetheria scene or generating data from one, do a source pass:
1. Check AetheriaLore for the factions, social movements, institutions, species
or body types, location, and time period present in the scene.
2. Extract the material constraints that shape behavior: habitat form,
infrastructure, law, money, route access, labor pressure, class position,
surveillance, body affordances, communication channels, and local failure
costs.
3. Distill those details into the lore grounding digest, agent-state fixture,
projected local context, or Ink sidecar before prompting a response model.
4. Keep the prompt character-local. Source grounding tells Ghostlight what the
room and culture make possible; it does not grant characters omniscience.
5. If the lore does not support a needed concrete detail, do not fill the gap
with confident furniture. Mark the gap, elaborate the AetheriaLore source,
then regenerate or revise the Ghostlight artifact from the patched source.
6. Commit the lore patch in AetheriaLore and cite the source path from
Ghostlight so the training artifact has a clean provenance trail.
The output goal is not only clean training data. It is also playable, readable,
fun interactive fiction that discovers where the worldbuilding is thin by
leaning on it. If Ghostlight needs to know what a sanctuary intake board looks
like to a cetacean Navigator, that is not trivia. That is worldbuilding under
load.
## Lore Gap Policy
When a gap appears, classify it before writing:
- `blocking`: generation would produce wrong bodies, wrong institutions, wrong
authority, or wrong material constraints without the missing detail.
- `safe_to_stub`: the scene can proceed with a clearly marked provisional
detail that does not affect canon-critical behavior.
- `defer`: the detail is decorative and should not enter the prompt yet.
Blocking gaps should be patched in AetheriaLore first. Safe stubs must be
tracked in the digest or sidecar and revisited before promotion. Deferred gaps
should stay out of prompt text so they do not harden by accident.
## Training Boundary
Ground the machine in the past, then generate possible futures under
supervision with branch labels sharp enough that infinity does not show up
wearing one nametag.
Historical fixtures teach single-history social pressure. Future-branch
fixtures teach conditional canon. Cat/Oz-style procedural fixtures teach
runtime mechanics. Those lanes can compare against each other, but they should
not collapse into one unlabeled corpus.

View file

@ -0,0 +1,586 @@
# Aetheria Persistent Agent Architecture
This is the companion document to
[Things To Steal](../research/things-to-steal.md).
For the first concrete target, see
[Aetheria Content Generation Targets](./aetheria-content-generation-targets.md).
For the scaling model that explains how a crowded region can be simulated with
a relatively small persistent cast, see
[Aetheria Agent Population Model](./aetheria-agent-population-model.md).
For the deeper note on how culture, personality distributions, memory, and
perceived state should project into prompts, see
[Agent State Distributions And Prompt Projection](./agent-state-distributions-and-prompt-projection.md).
That document is the theft list. This one is the machine we are trying to build
out of the stolen parts.
The long-horizon target is a persistent world full of in-universe agents who:
- remember what happened
- want different things
- collide with each other because their values and interests do not line up
- interact with humans without collapsing into quest dispensers or therapy plushies
The important bit is that the world should not exist solely to flatter the
player. Humans are participants, not the sun around which every synthetic
little bastard must orbit.
## Design Goals
The near-term target is content generation and story scaffolding, not a
standalone game slice. Ghostlight should first help Aetheria authors generate
dialogue, scene pressure, procedural drama, and character reactions from
explicit social state.
We want a world where:
- agents persist across sessions
- relationships evolve over time
- off-screen life continues
- conflict emerges from incentives
- the same character remains recognizably themselves under pressure
- human intervention can redirect events without erasing everyone else's agency
We do **not** want:
- a thin layer of LLM chat stapled onto static NPC behavior
- every character becoming a slightly differently dressed assistant
- a world that only animates when a human looks at it
- fake drama generated by random hostility instead of meaningful friction
## World Model
The world should be represented as a persistent simulation with five main state
layers:
1. **Canonical world state**
- locations
- factions
- resources
- institutions
- active conflicts
- world-level events
2. **Agent identity state**
- role
- background
- loyalties
- values
- aversions
- appetites
- speech style
3. **Agent dynamic state**
- current location
- current obligations
- current mood / pressure / fatigue
- current social exposure
- short-horizon plans
4. **Relationship state**
- trust
- respect
- resentment
- fascination
- fear
- obligation
- debt
5. **Memory state**
- episodic incidents
- scene summaries
- semantic profile conclusions
- relationship summaries
- active inferred goals and concerns
## Agent Stack
Each agent should be built from explicit layers instead of one giant persona
prompt.
### 1. Identity Layer
Relatively stable facts:
- who they are
- what they believe
- what they want to be seen as
- what they cannot tolerate
- what they are drawn toward
This should be treated as durable but revisable. Identity is not immutable, but
neither should it drift because the model got bored.
### 2. Relationship Layer
Every important other should have a relationship record, including:
- another agent
- a human player
- a group or faction
The record should track:
- trust
- respect
- resentment
- dependence
- attraction or fascination where relevant
- leverage
- recent unresolved incidents
The point is not to create one friendship slider like a life sim from 2004. The
point is to track the actual shape of a social bond.
### 3. Goal Layer
Agents need active goals, not just a biography.
Goals should be:
- private or public
- immediate or long-range
- strategic or emotional
- revisable in response to new information
A goal object should include:
- description
- priority
- time horizon
- emotional stake
- blockers
- dependencies
- known rivals
- likely next moves
### 4. Memory Layer
Memory should be split into:
- **working memory**
- current scene
- immediate context
- active inferences
- **episodic memory**
- timestamped incidents
- where, who, what, consequence
- **scene memory**
- distilled summaries of interactions
- **semantic memory**
- generalized beliefs about self, others, and world
- **relationship memory**
- remembered incidents and inferred patterns tied to a specific other
This is where the current interaction-memory work is directly useful.
### 5. Theory-of-Mind Layer
Each active interaction should have a lightweight scratchpad:
- what the other party likely wants
- what they fear
- what they think is happening
- what they are trying to get from me
- how confident we are about those guesses
This should be ephemeral and revisable. First impressions are useful, but
worshipping them is how systems become smug and stupid.
## Simulation Tiers
A persistent world cannot run every agent at full dialogue fidelity all the
time unless we want to immolate the hardware.
So the world should run on tiers.
### Tier 0: Dormant Persistence
For agents far away from any relevant action:
- maintain location, obligations, and long-horizon goals
- advance the clock coarsely
- update minimal state
### Tier 1: Strategic Tick
For agents in active regions but not in a live scene:
- update goals
- choose broad actions
- move, trade, coordinate, prepare, avoid, investigate
- write compact event records
### Tier 2: Scene Simulation
For agents inside a socially or narratively relevant scene:
- model who is present
- what each actor knows
- what each actor wants
- who is hiding what
- who is under pressure
- resolve short exchanges, negotiations, or conflicts
### Tier 3: Live Interactive Presence
For agents currently engaged with a human or central dramatic scene:
- full dialogue generation
- richer perception and recall
- fine-grained ToM updates
- explicit action selection tied to the live interaction
This tiered model is how we get a world that feels alive without paying for a
small national grid.
## Core Loop
At a high level, each active agent loop looks like:
1. Perceive
- scene state
- nearby actors
- new events
- human input
2. Interpret
- what matters
- what changed
- what this implies for goals and relationships
3. Update internal state
- working memory
- ToM scratchpad
- emotional pressure / stance
4. Retrieve memory
- relevant episodes
- relationship incidents
- semantic conclusions
5. Evaluate goals
- what is threatened
- what is newly possible
- what should take precedence now
6. Choose action
- speech
- movement
- trade
- alliance
- concealment
- refusal
- violence
- delay
7. Commit result
- world state update
- event log
- memory writes
8. Reflect later
- consolidate incidents
- revise relationship state
- revise semantic beliefs
- revise goals
## Relationship Engine
The relationship system should be treated as first-class infrastructure, not
decorative metadata.
Each relationship record should have:
- scores or categorical state for trust, respect, resentment, obligation, fear, fascination
- a short summary of the current relationship
- the unresolved incidents driving that summary
- known pressure points
- current access level
Example consequences:
- low trust + high obligation = brittle alliance
- low respect + high dependence = contemptuous reliance
- high fascination + high fear = unstable orbit
- high resentment + high access = sabotage risk
That is where drama comes from.
## Goal Engine
Goals should be generated and updated from:
- identity
- current needs
- memory
- faction context
- scene opportunities
- rival actions
We should distinguish between:
- **maintenance goals**
- survive
- maintain station
- avoid exposure
- **strategic goals**
- secure influence
- gain a resource
- undermine a rival
- **emotional goals**
- seek vindication
- restore dignity
- protect someone
- punish betrayal
Emotional goals matter because otherwise every character becomes a boring little
optimization engine with no pulse.
## Scene Engine
A scene should be an explicit object, not just "some chat happened."
A scene record should contain:
- location
- participants
- public stakes
- hidden stakes
- triggering event
- current tension axes
- known facts by actor
- unknown facts by actor
- scene outcome
Useful tension axes:
- status
- safety
- secrecy
- leverage
- affection
- ideology
- scarcity
The scene engine should decide:
- whether to escalate
- whether to de-escalate
- whether to fork into violence, bargain, confession, retreat, or deception
## Human Participation
Humans should be one force in the world, not the privileged audience every
character was born to adore.
That means:
- agents should remember human actions
- agents should form durable impressions of specific humans
- agents should change how much they reveal, trust, or tolerate
- agents should be willing to refuse or redirect
- agents should sometimes try to use the human for their own ends
Humans should be able to:
- build alliances
- ruin them
- become symbols
- become liabilities
- become prey
- become confidants
That is much more interesting than "click NPC, receive lore paragraph."
## Narrative Layer
We should not hard-script drama, but we should summarize it.
The narrative layer should:
- generate personal chronicles from actual events
- generate regional or faction news from high-impact changes
- generate rumor propagation when information is partial or distorted
The crucial rule:
- narrative is downstream of simulation
- not a replacement for simulation
## How The Current Supporting Stack Maps To This
The nice part is that some useful scaffolding already exists elsewhere.
Current useful pieces:
- Postgres-backed persistent state
- Qdrant-backed semantic retrieval
- interaction memory with behavioral dimensions
- source and lore indexing
- provider-side tool use
- owner-safe MCP patterns
What needs to be added:
- multi-agent world state store
- relationship graph
- explicit goal objects
- scene scheduler
- reflection/consolidation jobs for cast state
- tiered simulation runtime
- world-facing tools for observation and action
In other words, the existing conversational stack is already halfway to being a
mind-and-memory service. What it lacks is the world model and the cast
scheduler.
## Suggested Data Model
At minimum, the persistent store should grow tables or equivalents for:
- `world_regions`
- `world_events`
- `agents`
- `agent_identity_snapshots`
- `agent_dynamic_state`
- `agent_goals`
- `agent_relationships`
- `agent_memories_episodic`
- `agent_memories_scene`
- `agent_memories_semantic`
- `scene_instances`
- `scene_participants`
- `human_agent_interactions`
- `rumors`
Qdrant or equivalent can remain the retrieval layer for:
- episodic recall
- lore retrieval
- source grounding
- scene similarity
But the canonical social and world state should stay relational and
inspectable.
## Suggested Runtime Services
The world runtime should likely split into:
- **world scheduler**
- advances ticks
- activates scenes
- downgrades inactive agents
- **agent executor**
- runs an agent turn
- retrieves memory
- updates goals and relationships
- **scene resolver**
- handles active multi-party interactions
- **reflection worker**
- consolidates events into longer-term memory
- **narrative worker**
- writes chronicles and reports
That keeps the system legible instead of letting one monstrous worker do all
the thinking and all the plumbing at once.
## Failure Modes To Watch For
### 1. Everyone Becomes The Same Person
The agent voices flatten and all conflict becomes cosmetic.
Countermeasure:
- strong identity layer
- explicit value hierarchy
- personality consistency evaluation
### 2. Memory Becomes Sludge
Everything is stored, nothing is distinguished, retrieval turns to soup.
Countermeasure:
- tiered memory
- write-path filtering
- periodic consolidation
- clear forgetting policy
### 3. Drama Becomes Random Meanness
Characters feel chaotic rather than motivated.
Countermeasure:
- tie conflict to values, history, incentives, and pressure
### 4. Humans Break The World By Existing
Every character over-orients around the nearest player.
Countermeasure:
- keep off-screen goals live
- preserve non-human social gravity
- let humans matter without becoming the axis mundi
### 5. Sycophancy Wears A Costume
The bot sounds spicier but still validates whatever frame the user wants.
Countermeasure:
- explicit anti-sycophancy evaluation
- persistent relationship memory
- boundary-aware response guidance
## Build Order
### Phase 1: Authoring Cast Spine
- explicit agent identity dossiers
- explicit goal objects
- explicit relationship objects
- episodic + semantic memory split
### Phase 2: Dialogue And Drama Scaffolding
- prompt projection for scene and dialogue drafts
- relationship-aware dialogue skeletons
- conflict beat generation from incompatible goals
- author-visible rationale for generated moves
### Phase 3: Social Simulation
- scene objects
- scene scheduler
- relationship updates from scenes
- goal revision from scenes
### Phase 4: Persistent World
- region state
- off-screen strategic ticking
- rumor/news propagation
- faction and economy hooks
### Phase 5: Human Integration
- live player-facing interactions
- persistent human reputation
- access, trust, and consequence systems
### Phase 6: Rich Narrative Surface
- chronicles
- regional reports
- replay and spectator tooling
## The Real Ambition
The ambition here is not "better NPC dialogue."
It is:
- a world with inhabitants
- inhabitants with memory
- memory that changes what they want
- wants that collide
- collisions that produce story
That is the actual prize. Everything else is just wiring and smoke.

View file

@ -0,0 +1,461 @@
# Agent State Distributions And Prompt Projection
This note describes how personality, culture, memory, and social context should
be represented in structured state, and how that state should be projected into
prompts for dialogue generation.
The central rule is simple:
- the prompt is not the source of truth
- the prompt is a temporary projection of deeper state for one turn or one scene
If we try to store the whole person in prompt prose, the system turns back into
theatrical mush. The real machine has to live in explicit state.
For the related notes on personality structure and dialogue classification, see:
- [Personality Model Roadmap](../research/personality-model-roadmap.md)
- [Personality Signal Classifier](../research/personality-signal-classifier.md)
## Core Principle
We should model agents through:
1. canonical latent state
2. perceived state for each agent
3. prompt projection from perceived state into live generation
That gives us:
- stable world truth
- subjective misunderstanding
- efficient live prompting without pretending the prompt is the database
## State Families
Each agent should have explicit state families, not one giant personality blob.
### 1. Underlying Organization
These are deep structural variables:
- self-coherence
- contingent worth
- shame sensitivity
- reciprocity capacity
- mentalization quality
- authenticity tolerance
- mask rigidity
- external-regulation dependence
These are slow-changing and often heavily shaped by early development and
repeated relational history.
### 2. Stable Dispositions
These are relatively persistent tendencies:
- novelty-seeking
- conformity
- status hunger
- risk tolerance
- sociability
- baseline threat sensitivity
- aesthetic appetite
- ideological rigidity
### 3. Behavioral Dimensions
These are first-class, not optional garnish. They belong in the rebuild from
the start:
- interpersonal warmth
- drive
- grandiosity
- validation-seeking
- anxiety
- control pressure
- hostility
- suspicion
- rigidity
- withdrawal
- volatility
- attachment-seeking
- distance-seeking
These are the main handles for social behavior, institutional pressure, and
consumer or factional drift.
### 4. Presentation Strategy
These describe how the self is performed:
- charm
- compliance
- superiority
- detachment
- seductiveness
- competence theater
- moral theater
- strategic opacity
- cultivated harmlessness
- abrasive boundary
- ironic distance
These are not just decorative style. In some agents they are survival
machinery.
### 5. Voice Style
These describe how the performed self comes out in language:
- dryness
- verbal warmth
- formality
- verbosity
- pace
- plainspoken directness
- lexical precision
- technical density
- technical compression
- figurative language
- lyricism
- narrative detail
- emotional explicitness
- pointedness
- self disclosure
- hedging
- certainty marking
- politeness
- coded politeness
- ritualized address
- register switching
- dialect marking
- theatricality
- humor
- conversational dominance
- listening responsiveness
- question asking
- profanity
### 6. Derived Verbal Responses
Defensive verbal moves are derived during projection. The canonical state stores
the pressures and speech mechanics that make those moves likely.
Target emergent behaviors:
- sarcastic deflection can emerge from shame sensitivity, ironic distance,
exposure pressure, suspicion, and a dry or pointed voice
- sincerity evasion can emerge from mask rigidity, low authenticity tolerance,
strategic opacity, and low emotional explicitness
- emotional evasion can emerge from distance-seeking, withdrawal, acute shame,
or control pressure with low self-disclosure
- banter as boundary can emerge from humor, ironic distance, attachment
pressure, and distance-seeking
- verbal aggression can emerge from hostility, perceived status threat,
grievance activation, and abrasive boundary
These are behaviors Ghostlight should be able to produce and test for without
storing them as authored canonical traits.
### 7. Situational State
These are transient activations:
- exhaustion
- scarcity pressure
- humiliation
- panic
- triumph
- grief
- overstimulation
- grievance activation
- acute shame
- perceived status threat
### 8. Relationship State
Per target person, faction, group, product domain, or institution:
- trust
- resentment
- dependence
- fear
- fascination
- obligation
- envy
- moral disgust
- perceived status gap
- expectation of care
- expectation of betrayal
### 9. Goals And Values
- active goals
- protected values
- emotional stakes
- tradeoffs
- unforgivables
## How To Represent Variables
Do not represent most of these as naked scalars if we want culture and history
to shape them properly.
A practical representation is:
- `mean`
- `plasticity`
- `current_activation`
So instead of:
- `status_hunger = 0.72`
use something like:
- `status_hunger.mean = 0.72`
- `status_hunger.plasticity = 0.22`
- `status_hunger.current_activation = 0.89`
This gives us:
- stable baseline personality
- different rates of change
- scene-sensitive activation without rewriting the whole person every turn
Canonical authored state should not include epistemic uncertainty about itself.
If a value is only an observer's read, store it as perceived state rather than
canonical state. Perceived dimensions should not expose canonical `mean` or
`plasticity`; they should track observed activation, attributed disposition,
and confidence. This lets the model represent
fundamental attribution bias: someone can behave a certain way under pressure
and be perceived as simply being that way.
## Culture As A Prior Field
Culture should not create clones. Culture should shape distributions.
For each culture, faction, class stratum, or institution, define:
- prior means over variables
- covariance structure
- reward gradients
- penalty gradients
- preferred masks
- taboo map
- prestige map
- default relationship posture to insiders, outsiders, rivals, inferiors, and superiors
- favored narrative scripts
This means a culture is not "people here are high in trait X."
It is more like:
- this culture rewards controlled presentation
- penalizes spontaneous vulnerability
- elevates status anxiety
- normalizes suspicion of low-status outsiders
- attaches prestige to procedural fluency and emotional composure
That is how a Dominion or Lucent style environment should work: as a field that
shapes what people become profitable or safe enough to be.
## Personal History As A Transform Layer
Culture is only the starting pressure.
Then the prior is transformed by:
- attachment history
- class position
- faction indoctrination
- educational shaping
- labor regime
- remembered humiliations
- remembered betrayals
- remembered rescue, admiration, abandonment, and obligation
So agent state should be read as:
- cultural prior
- transformed by developmental history
- transformed by remembered episodes
- currently activated by scene pressure
## Cohorts And Populations
The same representation can be lifted from individuals to populations.
A colony, district, labor bloc, or consumer cohort can have:
- shared priors
- institutional conditioning
- repeated collective incidents
- relationship state toward factions, employers, media ecosystems, and product classes
This is much better than Big Five-style population sliders because it lets us
model things like:
- prestige hunger
- fear-state compliance
- grievance accumulation
- attachment to institutions
- validation loops induced by media
- suspicion induced by audit or betrayal
That is much closer to Aetheria than a broad trait scaffold alone.
## Canonical State Vs Perceived State
The world should not only track what is true. It should track what each agent
thinks is true.
So we need:
- canonical world state
- canonical agent state
- per-agent perceived state
Perceived state includes:
- what this agent believes about others
- what motives they attribute
- what traits they think they are seeing
- what mask they think someone is wearing
- what threats or opportunities they think are present
This is where emotional intelligence, suspicion, attachment hunger, status
anxiety, and cultural priors distort interpretation.
The acting agent should mostly be prompted from perceived state, not canonical
truth. That is how misunderstanding becomes organic instead of hand-authored.
## Prompt Projection
Prompt rendering should be treated as projection, not storage.
For a live scene, build the prompt from:
### 1. Identity And Role
- who the agent is
- what role they occupy
- what they want to be seen as
### 2. Cultural Default Frame
- prestige rules
- taboo zones
- expected forms of control or intimacy
- factional posture
### 3. Current Presentation Strategy
- how they are trying to appear right now
- what they are concealing
- what they must not look like
### 4. Current Situation
- scene pressure
- public exposure
- scarcity
- humiliation risk
- safety risk
### 5. Relationship Context
- what they think of the other party
- what they need from them
- what wounds or debts are active
- what status relation they perceive
### 6. Relevant Memory
Not full history. Only:
- the few most relevant episodes
- relationship summaries
- current active fears, loyalties, or grievances
- currently salient cultural scripts
### 7. Goals And Constraints
- what they are trying to achieve in this scene
- what they cannot afford
- what values they will not betray
### 8. Listener Model
If the scene involves another agent, include:
- what this agent thinks the other wants
- what they fear the other is doing
- where they may be misreading them
## What The Prompt Should Not Do
Do not make the prompt:
- the sole canonical store of identity
- a full dump of all memories
- a taxonomy recital
- a fake omniscient summary that erases subjective misreading
The prompt should be compact, selective, and scene-bound. The state store
carries the rest.
## Economic Consequences
This model gives us much more useful demand and cultural behavior than old
trait-only systems.
For example:
- high status hunger plus validation-seeking plus fragile self-worth leads toward prestige goods, symbolic upgrades, and visible luxury
- high threat sensitivity plus conformity plus shame reactivity leads toward safety goods, procedural systems, insurance, and respectable signaling
- high drive plus novelty-seeking plus low institutional attachment leads toward frontier gear, gray-market experimentation, and risky migration
- high mask rigidity plus high impression-management intensity leads toward corrective aesthetics, social camouflage, and reputation-maintenance consumption
That is how culture, labor, media, and institutions become market-shaping
forces instead of mere flavor text.
## Recommended Implementation Path
1. Define the state schema with first-class support for:
- underlying organization
- stable dispositions
- all core behavioral dimensions, including `volatility`, `attachment-seeking`, and `distance-seeking`
- presentation strategies
- voice styles
- situational state
- relationship state
- goals and values
2. Represent those variables as priors plus activation, not just fixed scalars.
3. Define cultural and institutional prior templates.
4. Add history transforms from remembered episodes.
5. Add per-agent perceived state overlays.
6. Build prompt renderers that project perceived state into live generation.
7. Keep canonical state, perceived state, and prompt projection cleanly separated.
## Bottom Line
The right machine is not:
- a persona prompt with some adjectives taped to it
- a canonical trait sheet with no cultural shaping
- a direct line from "personality value" to dialogue
The right machine is:
- latent state distributions
- shaped by culture
- reshaped by history
- distorted by perception
- projected into prompts for one scene at a time
That gives us agents who can feel culturally situated, personally scarred,
socially biased, and dynamically alive without making the prompt carry the
whole civilization on its back.

View file

@ -0,0 +1,273 @@
# Agent State Variable Glossary
This document explains the current v0 Ghostlight state variables. It is the
human-facing companion to:
- `schemas/agent-state.schema.json`
- `schemas/agent-state.required-fields.json`
- `examples/agent-state.call-of-the-void.json`
The schema is not pretending this vocabulary is final. It is a first stable
core: enough shared axes to compare characters, build prompt projection, and
avoid turning every new scene into a bespoke little fog machine.
## Variable Components
Canonical scalar-like state variables use three numeric components.
| Field | Meaning |
| --- | --- |
| `mean` | The baseline tendency or long-run level for this variable. A high `mean` means this trait, strategy, pressure, or stance is normally strong for the agent. |
| `plasticity` | How easily this variable can change. High plasticity means the value can move quickly under events, relationships, culture, or scene pressure. Low plasticity means it is entrenched. |
| `current_activation` | How active the variable is right now, in this scene or current state. This can spike above or fall below the baseline without rewriting the agent. |
Example:
```json
{
"mean": 0.35,
"plasticity": 0.2,
"current_activation": 0.9
}
```
This means the agent is not generally high in the variable, the baseline is not
very easy to change, but the variable is strongly activated right now.
Translation: this is a situation, not a personality transplant.
Canonical character state does not include `certainty`. If a generated or
imported profile is provisional, track that in workflow provenance, draft
status, imported-source notes, or review status.
Perceived state uses a different shape because observers do not know canonical
baseline or plasticity. They see behavior, then often treat that behavior as
evidence of what the target is like.
```json
{
"observed_activation": 0.9,
"attributed_disposition": 0.7,
"confidence": 0.4
}
```
| Field | Meaning |
| --- | --- |
| `observed_activation` | How strongly the observer thinks the target expressed the dimension in the observed event or scene. |
| `attributed_disposition` | How much the observer generalizes from the observed behavior into "this is what the target is like." |
| `confidence` | How strongly the observer trusts this read. This is where evidence strength, emotional investment, prejudice, relationship stakes, prior memory, and social reinforcement show up. |
This models fundamental attribution bias directly. A character can see someone
behave coldly during a crisis and conclude "they are cold," not "they were
activated toward distance under exceptional pressure." Correcting that
impression takes work because the observer's confidence is supported by memory,
emotion, stakes, prior assumptions, and social reinforcement.
Perceived overlays may use structured belief metadata such as subject, claim
type, evidence events, visibility, and emotional charge. That metadata is not
canonical certainty. It describes why a specific observer trusts a specific
read.
## Label Set Policy
The labels in `agent-state.required-fields.json` are the required v0 core,
not per-character tags. They cover the axes Ghostlight currently treats as
first-class.
The validator requires each canonical agent fixture to include the full core
set so agents can be compared on the same axes. That means Cat's example does
not contain only labels that apply strongly to Cat. It contains all required
labels, with low values for weak or inactive axes and high values for strong or
activated axes.
Variable maps may still grow extra labels where a character, culture, or story
needs them. Do not promote a new label into the required core until it has
proved broadly useful across multiple characters or scenes. Otherwise the
schema becomes a junk drawer with a badge.
## Underlying Organization
| Label | Meaning |
| --- | --- |
| `self_coherence` | How integrated and stable the agent's sense of self is under pressure. |
| `contingent_worth` | How much the agent's felt worth depends on approval, utility, status, purity, or external validation. |
| `shame_sensitivity` | How painful exposure, correction, failure, or diminishment feels. |
| `reciprocity_capacity` | Ability to recognize and sustain mutual obligations rather than purely extractive or avoidant relations. |
| `mentalization_quality` | Ability to model other minds with nuance instead of flattening them into crude threat, need, or utility categories. |
| `authenticity_tolerance` | Capacity to be known without immediately retreating into performance, control, or concealment. |
| `mask_rigidity` | How necessary and inflexible the performed self is. High values mean the mask is load-bearing. |
| `external_regulation_dependence` | How much emotional stability depends on other people, institutions, substances, scripts, or external structures. |
## Stable Dispositions
| Label | Meaning |
| --- | --- |
| `novelty_seeking` | Attraction to new experience, strange opportunities, and unproven paths. |
| `conformity` | Preference for accepted norms, procedures, and group expectations. |
| `status_hunger` | Desire for rank, recognition, prestige, or visible superiority. |
| `risk_tolerance` | Willingness to accept danger, uncertainty, or loss for gain or freedom. |
| `sociability` | Baseline draw toward company, conversation, networks, and social energy. |
| `baseline_threat_sensitivity` | Default readiness to detect danger, exploitation, betrayal, or humiliation. |
| `aesthetic_appetite` | Sensitivity to beauty, style, taste, symbolic form, or expressive environment. |
| `ideological_rigidity` | Resistance to revising beliefs, myths, doctrines, or explanatory frames. |
## Behavioral Dimensions
| Label | Meaning |
| --- | --- |
| `interpersonal_warmth` | Tendency to express care, friendliness, affection, or social welcome. |
| `drive` | Goal pressure, persistence, ambition, and forward force. |
| `grandiosity` | Inflated self-importance, entitlement, exceptionalism, or self-mythologizing. |
| `validation_seeking` | Need for reassurance, approval, recognition, or confirmation of worth. |
| `anxiety` | Anticipatory fear, worry, nervous vigilance, or instability under uncertainty. |
| `control_pressure` | Urge to manage people, situations, information, or outcomes tightly. |
| `hostility` | Readiness toward anger, contempt, attack, punishment, or antagonism. |
| `suspicion` | Expectation that others hide motives, threats, traps, or bad faith. |
| `rigidity` | Difficulty adapting behavior, interpretation, or plans under new evidence. |
| `withdrawal` | Tendency to disengage, retreat, shut down, or reduce availability. |
| `volatility` | Speed and intensity of emotional swings or escalation. |
| `attachment_seeking` | Pull toward closeness, reassurance, fusion, privileged access, or being chosen. |
| `distance_seeking` | Push toward space, opacity, autonomy, and reduced emotional claim from others. |
## Presentation Strategy
Presentation strategy describes the self being performed. This is not identical
to voice style. It is the social face, mask, posture, or survival presentation
the agent is trying to maintain.
| Label | Meaning |
| --- | --- |
| `charm` | Performs warmth, ease, charisma, or pleasantness to influence or smooth contact. |
| `compliance` | Performs agreement, submission, helpfulness, or nonthreatening cooperation. |
| `superiority` | Performs being above others, more competent, more refined, or less vulnerable. |
| `detachment` | Performs cool distance, emotional unavailability, or not-needing. |
| `seductiveness` | Performs desirability, invitation, or intimate leverage. |
| `competence_theater` | Performs capability and control, sometimes beyond what is actually secure. |
| `moral_theater` | Performs righteousness, purity, sacrifice, or ethical authority. |
| `strategic_opacity` | Controls what others can know; hides motives, pain, dependency, or plans. |
| `cultivated_harmlessness` | Performs being safe, small, useful, cute, or beneath concern. |
| `abrasive_boundary` | Performs difficulty, bite, or unpleasantness to keep others from pressing closer. |
| `ironic_distance` | Performs irony, detachment, or joking distance to avoid naked sincerity. |
## Voice Style
Voice style describes how the performed self comes out in language. The v0
required vector covers broad communication mechanics, not only the most obvious
quirks in the current examples.
| Label | Meaning |
| --- | --- |
| `dryness` | Understated, deadpan, low-sentiment phrasing. |
| `verbal_warmth` | Inviting, affectionate, reassuring, or socially soft phrasing. |
| `formality` | Uses formal structure, titles, careful address, or institutionally correct phrasing. |
| `verbosity` | Tends toward longer turns, elaboration, qualification, or verbal sprawl. |
| `pace` | Moves quickly through turns, interruptions, replies, or topic shifts. |
| `plainspoken_directness` | Speaks plainly, concretely, or bluntly rather than ornamenting or obscuring. |
| `lexical_precision` | Chooses exact words, distinctions, definitions, or careful terms. |
| `technical_density` | Packs speech with specialist terms, systems language, procedure, or analysis. |
| `technical_compression` | Compresses technical meaning into terse expert shorthand. |
| `figurative_language` | Uses metaphor, image, analogy, poetic compression, or symbolic framing. |
| `lyricism` | Uses musical, poetic, sensuous, or rhythmically heightened language. |
| `narrative_detail` | Explains through context, sequence, anecdote, or concrete scene detail. |
| `emotional_explicitness` | Names feelings, needs, wounds, affection, fear, or attachment directly. |
| `pointedness` | Cuts directly, sharply, or with barbed precision. |
| `self_disclosure` | Volunteers personal history, inner state, motives, or private stakes. |
| `hedging` | Softens claims with uncertainty markers, caveats, deference, or exits. |
| `certainty_marking` | Signals confidence, finality, authority, or refusal to leave claims open. |
| `politeness` | Uses courtesy, mitigation, face-saving language, or social smoothing. |
| `coded_politeness` | Uses polite phrasing to imply criticism, threat, refusal, hierarchy, or hidden meaning. |
| `ritualized_address` | Uses formulaic greetings, titles, honorifics, oaths, prayers, or ceremonial phrases. |
| `register_switching` | Shifts between speech registers depending on audience, status, danger, or role. |
| `dialect_marking` | Shows regional, class, occupational, subcultural, or community-specific speech markers. |
| `theatricality` | Performs speech with heightened drama, staging, persona, flourish, or rhetorical display. |
| `humor` | Uses jokes, wit, absurdity, teasing, or comic framing as a regular speech tool. |
| `conversational_dominance` | Takes, holds, redirects, or controls conversational floor and agenda. |
| `listening_responsiveness` | Reflects, tracks, validates, or adapts to what the other person just said. |
| `question_asking` | Uses questions to probe, invite, corner, teach, test, or keep the other person talking. |
| `profanity` | Uses taboo, vulgar, sacred, or deliberately coarse language. |
## Derived Verbal Responses
Defensive verbal moves are projection outcomes, not canonical labels. A renderer
can derive sarcasm, sincerity evasion, emotional evasion, banter-as-boundary, or
verbal aggression from the interaction between active state families.
Examples:
| Response | Common active-state pattern |
| --- | --- |
| Sarcastic deflection | High shame sensitivity, ironic distance, threat sensitivity, and exposure pressure through a dry or pointed voice. |
| Sincerity evasion | Low authenticity tolerance, high mask rigidity, strategic opacity, and low emotional explicitness. |
| Emotional evasion | High distance-seeking, withdrawal, acute shame, or control pressure with low self-disclosure. |
| Banter as boundary | High ironic distance, humor, attachment pressure, and distance-seeking in the same scene. |
| Verbal aggression | High hostility, perceived status threat, grievance activation, and abrasive boundary. |
Treat these as target emergent behaviors for projection tests. They are useful
because the system should produce them under the right pressures, not because
they deserve their own canonical storage slot.
## Situational State
| Label | Meaning |
| --- | --- |
| `exhaustion` | Current depletion, fatigue, overuse, or low reserve. |
| `scarcity_pressure` | Pressure from money, supplies, access, time, space, safety, or opportunity being scarce. |
| `humiliation` | Current felt diminishment, exposure, embarrassment, or status wound. |
| `panic` | Acute fear, urgency, overwhelm, or threat response. |
| `triumph` | Current victory, vindication, high confidence, or emotional lift. |
| `grief` | Active loss, mourning, sorrow, or ache. |
| `overstimulation` | Current sensory, social, cognitive, or emotional overload. |
| `grievance_activation` | Active resentment, injustice memory, or retaliatory moral charge. |
| `acute_shame` | Immediate shame flare, exposure pain, or self-disgust. |
| `perceived_status_threat` | Current sense that rank, dignity, competence, or face is under threat. |
## Relationship Stance
Relationship stance is directional. A `source_id -> target_id` record describes
how the source feels and predicts the target, not a shared relationship object.
Reciprocal records can disagree.
| Label | Meaning |
| --- | --- |
| `trust` | Expectation that the target will not exploit vulnerability or act in bad faith. |
| `respect` | Recognition of competence, integrity, status, or seriousness. |
| `resentment` | Accumulated grievance, bitterness, or unpaid emotional debt. |
| `dependence` | Need for the target's resources, care, access, protection, or recognition. |
| `fear` | Expectation that the target can or will cause harm. |
| `fascination` | Captivation, curiosity, attraction, fixation, or unresolved interest. |
| `obligation` | Felt debt, duty, promise, or responsibility toward the target. |
| `envy` | Pain around what the target has, is, receives, or represents. |
| `moral_disgust` | Aversion rooted in perceived violation, corruption, cruelty, or wrongness. |
| `perceived_status_gap` | Felt difference in rank, leverage, dignity, or social position. |
| `expectation_of_care` | Belief that the target may protect, soothe, support, or choose the source. |
| `expectation_of_betrayal` | Belief that the target may abandon, expose, exploit, or turn. |
## Cat Fixture Policy
Cat's example fixture currently includes all required core labels for Cat and
Oz. It is not a sparse profile of only traits that apply to them.
That is deliberate for v0:
- complete vectors make comparison and validation easier
- low `mean` values are meaningful
- low `current_activation` values tell the renderer not to emphasize a variable
Later, Ghostlight may support sparse authoring overlays for convenience. The
canonical fixture should remain explicit until we have enough tooling to avoid
silent defaults becoming accidental canon.
## Event Interpretation
Events may carry three optional interpretation layers:
| Field | Meaning |
| --- | --- |
| `observed_exchange` | What was externally observable: who acted or spoke, what they seemed to do, and the visible cues. |
| `private_interpretations` | How each participant appraised the event from their own partial state. |
| `event_effects` | The proposed consequences: state deltas, new beliefs, or memory updates caused by the event. |
These fields are immediate scaffolding for projection. They do not yet define a
complete transition engine. For now, they make important scene history legible
enough that a prompt projector can explain why a later line bends toward trust,
evasion, curiosity, fear, or repair.

View file

@ -0,0 +1,244 @@
# Canonical Agent State Schema
Ghostlight's first runtime organ is a state contract, not a generator.
The schema lives in:
- `schemas/agent-state.schema.json`
- `schemas/agent-state.required-fields.json`
The first example lives in:
- `examples/agent-state.call-of-the-void.json`
The label and numeric glossary lives in:
- `docs/architecture/agent-state-variable-glossary.md`
## Objective
The schema defines the minimum state Ghostlight needs before it can safely build
prompt projection, dialogue scaffolding, classifier records, or procedural
drama.
The core rule is unchanged:
- canonical state is what the world treats as true
- perceived state is what a specific observer thinks is true
- dialogue context packs are character-local projections for one speaker in one
scene
If these layers collapse into each other, the machine turns back into one
omniscient narrator with a pocket full of vibes. This is a known industrial
hazard. We are wearing goggles now.
## State Families
Each agent has:
- `identity`
- `canonical_state`
- `goals`
- `memories`
- `perceived_state_overlays`
`canonical_state` is split into:
- `underlying_organization`
- `stable_dispositions`
- `behavioral_dimensions`
- `presentation_strategy`
- `voice_style`
- `situational_state`
- `values`
Each canonical scalar-like dimension is represented as:
```json
{
"mean": 0.5,
"plasticity": 0.5,
"current_activation": 0.5
}
```
This keeps baseline, change rate, and scene activation separate.
A person can be dispositionally suspicious without being maximally suspicious
in every scene. A person can be highly activated right now without rewriting
their whole soul because someone coughed near a status wound.
Canonical variables do not include confidence or certainty. Perceived variables
do not expose canonical `mean` or `plasticity`; they store observed activation,
attributed disposition, and confidence.
For full meanings of canonical `mean`, `plasticity`, `current_activation`,
perceived attribution fields, and every required v0 label, see
`docs/architecture/agent-state-variable-glossary.md`.
## Required First-Class Dimensions
`schemas/agent-state.required-fields.json` names the required core variable
families. The validator enforces those fields for canonical agent state and
relationship stance.
The required core is not exhaustive. It is the shared comparison surface. A
character fixture currently includes all required labels, even when many are low
or inactive, so downstream tooling can compare agents without guessing whether a
missing value means "not applicable," "forgotten," or "quietly eating the
floorboards."
The behavioral dimensions include the known must-not-drop set:
- `volatility`
- `attachment_seeking`
- `distance_seeking`
Those are not garnish. They are load-bearing social handles.
## Presentation, Voice, And Response
`presentation_strategy` describes the self being performed.
That can include charm, detachment, competence theater, cultivated
harmlessness, or deliberately performing someone who is abrasive or difficult.
Sometimes that abrasive performance is not random meanness. It is a boundary,
a threat display, a way to prevent intimacy, or a test to see who flinches.
Human beings are regrettably efficient little porcupines.
`voice_style` describes how that performed self comes out in language.
The required voice handles are:
- `dryness`
- `verbal_warmth`
- `formality`
- `verbosity`
- `pace`
- `plainspoken_directness`
- `lexical_precision`
- `technical_density`
- `technical_compression`
- `figurative_language`
- `lyricism`
- `narrative_detail`
- `emotional_explicitness`
- `pointedness`
- `self_disclosure`
- `hedging`
- `certainty_marking`
- `politeness`
- `coded_politeness`
- `ritualized_address`
- `register_switching`
- `dialect_marking`
- `theatricality`
- `humor`
- `conversational_dominance`
- `listening_responsiveness`
- `question_asking`
- `profanity`
Behavioral warmth and verbal warmth are intentionally separate:
- `behavioral_dimensions.interpersonal_warmth` is the tendency to offer care,
welcome, or affiliation.
- `voice_style.verbal_warmth` is the language surface that can sound soft,
reassuring, affectionate, or socially inviting.
So Cat's prickliness is not only a dialogue prompt instruction. It is backed by
canonical state:
- high `presentation_strategy.abrasive_boundary`
- high `presentation_strategy.ironic_distance`
- high `underlying_organization.shame_sensitivity`
- high `underlying_organization.mask_rigidity`
- high `behavioral_dimensions.distance_seeking`
- high `behavioral_dimensions.suspicion`
- high `situational_state.acute_shame` when exposed
The renderer derives defensive speech from those intersections plus voice
style. A dry, pointed voice under shame and exposure pressure may produce
sarcastic deflection; the schema does not store sarcasm-as-defense as its own
canonical organ.
Sarcastic deflection, sincerity evasion, emotional evasion,
banter-as-boundary, and verbal aggression remain target emergent behaviors for
projection tests.
## Perceived State
Perceived overlays are observer-specific. They do not replace canonical state.
An overlay records:
- observer
- target
- perceived dimensions, including observed activation and attributed disposition
- beliefs
- distortions
This is where misunderstanding lives. Suspicious agents can overread threat.
Attachment-hungry agents can overread interpersonal warmth. Avoidant agents can
underread care. Fundamental attribution bias also lives here: an observer can
mistake exceptional behavior for stable character. None of that requires
corrupting the canonical state.
Relationship stance is directional. If Cat has a relationship record toward Oz,
Oz needs a separate record toward Cat. Mutual presence does not imply symmetric
trust, fear, obligation, dependence, or expectation of betrayal.
## Event Interpretation
Events can include optional interpretation scaffolding:
- `observed_exchange` for externally visible action or dialogue
- `private_interpretations` for each participant's appraisal of what happened
- `event_effects` for proposed state deltas, belief creation, and memory updates
This is not a full social transition engine yet. It is enough structure for the
projection layer to know what an event changed without asking later prompts to
infer everything from a one-line summary and a bad attitude.
## Dialogue Context Packs
Scenes contain `dialogue_context_packs`.
A pack is a character-local prompt payload for one speaker. It includes:
- speaker-local truth
- speaker beliefs
- active memories
- active goals
- presentation constraints
The point is not to make the dialogue generator smarter by shouting plot at it.
The point is to keep each character inside their own partial reality.
Packs are positive-context payloads: if the speaker should not know something,
it is not included. Secrets the speaker does know but wants to withhold belong
in goals, memories, beliefs, and presentation constraints.
Ghostlight can build the trap. The character projection decides how the person
inside the trap breathes.
## Current Validation
The first validator is intentionally small and dependency-free:
- `tools/validate_agent_state.py`
It does not implement all of JSON Schema. It checks the invariants Ghostlight
currently cares about:
- example files parse as JSON
- schema versions match
- agent IDs are unique
- required canonical variable groups are present
- required relationship stance fields are present
- state variable components stay in `0..1`
- goals, memories, relationships, scenes, and dialogue context packs reference
existing IDs
- dialogue packs carry speaker-local context without requiring negative
author-only fences
This gives us a real seam before we build the prompt renderer. Not perfect.
Useful. We are allowed to be both.

View file

@ -0,0 +1,81 @@
# Corpus Coverage Ledger
The corpus coverage ledger is the project-level index for accepted and planned
Aetheria training fixtures.
Its job is not to store story content. Its job is to keep the corpus honest:
which factions, movements, flashpoints, timeline lanes, tonal modes, collision
axes, review states, and item/economy hooks are represented, and which are still
missing.
## Live Files
- Ledger: `state/corpus-coverage.json`
- Schema reference: `schemas/corpus-coverage.schema.json`
- Validator/status tool: `tools/validate_corpus_coverage.py`
- Command: `npm run coverage:status`
## Entry Contract
Each fixture entry records:
- `fixture_id`: stable fixture handle.
- `status`: `planned`, `draft`, `accepted_as_draft`, `accepted`,
`needs_revision`, `rejected`, or `superseded`.
- `timeline_lane`: `historical_grounded`, `transition_grounded`, or
`future_branch`.
- `canon_status`: single-history, branch-local, promoted branch, template, or
draft status.
- `story_type`: founding-era, day-in-the-life, flashpoint, movement,
future-branch, or trade-life story.
- `primary_subjects` and `secondary_subjects`: factions, movements,
institutions, species/body groups, or other represented subjects.
- `collision_axes`: the pressures the fixture exercises, such as law, labor,
care, class, logistics, species/body affordance, trade, memory, personhood,
technology, legitimacy, or infrastructure.
- `tonal_modes`: prose modes the fixture contributes to the corpus.
- `training_targets`: organs or reference surfaces this fixture is relevant to.
- `training_usability`: explicit per-stage declaration of whether the fixture
is `training_ready`, `reference_only`, or `not_training_data`.
- `artifacts`: paths to fixture files.
- `review`: status of schema, narrative, lore, spatial, visual, and IF review.
- `economy_hooks`: item, assembly, trade-good, supply-chain, or tech hooks.
- `open_lore_gaps`: blocking or useful gaps discovered during generation.
## Acceptance Rule
An accepted or accepted-as-draft fixture must have the core artifact refs:
- lore digest
- agent state
- coordinator artifact
- Ink file
- training sidecar
It must also carry review fields for schema, narrative, lore, spatial, visual,
and IF artifact review. A fixture may still be accepted-as-draft with review
caveats, but the caveats should be visible instead of hidden under the nice rug.
Coverage status is not training readiness. A fixture that looks good as a story
can still be unusable for a model stage if it lacks exact input/output receipts,
raw outputs, reviewer decisions, hidden-context audits, or mutation records in
the shape that stage requires. The ledger should be rude about this. If a
fixture is only a reference for story shape or grounding, mark it
`reference_only`; do not promote it by implication.
## Report Shape
`npm run coverage:status` validates the ledger and prints:
- total entries and accepted count
- progress toward the 100-150 broad target
- status, timeline lane, story type, collision-axis, tonal-mode, and subject
counts
- training-usability counts
- major-power founding/day-in-life candidates when present
- basic gap signals, such as missing future-branch coverage or no day-in-life
stories yet
This is deliberately a coarse instrument. It should tell us where to point the
next fixture without pretending to be a corpus scientist in a lab coat made of
spreadsheet tabs.

View file

@ -0,0 +1,98 @@
# CultNet Shared Agent-State Contract
Ghostlight owns the first cross-runtime agent-state payload contract.
That contract is not a new ontology. It is the existing Ghostlight state shape
moving over a wire without turning into bespoke JSON fog in every other repo.
## Canonical Payload
The first shared payload contract is:
- schema id:
`https://github.com/GameCult/Ghostlight/schemas/agent-state.schema.json`
- schema version:
`ghostlight.agent_state.v0`
- canonical file:
`schemas/agent-state.schema.json`
This is the same state shape already used for Ghostlight fixtures and the same
top-level shape Epiphany role dossiers adopted. The contract is:
- `world`
- `agents`
- `relationships`
- `events`
- `scenes`
If another runtime wants to carry socially persistent agent state without
inventing its own private folk religion, this is the first house shape.
## Wire Envelope
CultNet should carry the Ghostlight payload as a typed document replication
message, not as a one-off ad hoc blob.
Current expected document envelope:
- `documentType`: `ghostlight.agent-state`
- `documentKey`: runtime-defined stable key
- `payloadSchemaVersion`: `ghostlight.agent_state.v0`
- `payload`: exact Ghostlight state document
The current TypeScript client library is:
- package: `cultnet-ts`, in `GameCult/CultLib` under `packages/cultnet-ts`
Its job is not merely to make pipes pretty. It owns:
- direct-pipe MessagePack framing
- typed message contracts
- authentication/session semantics compatible with CultLib's `GameCult.Networking`
- typed replication into CultCache-compatible stores
## Authentication Story
CultNet inherits the useful security shape from CultLib:
- a shared connection key known to client and server/runtime peers
- AES-GCM encrypted auth/session payloads derived from that key
- a server-side session-signing secret
- signed session tokens used for verify/reconnect flows
This matters because "shared agent state" is not only a serialization problem.
It is also a trust boundary problem. We want one family resemblance across C#,
TypeScript, Rust, and Python instead of four mutually unintelligible little
shrines to local convenience.
## Contract Discipline
Ghostlight remains the source of truth for the payload shape.
That means:
- if `schemas/agent-state.schema.json` changes, CultNet mirrors must update in
lockstep
- consumers may add local document metadata around the payload, but should not
silently mutate the payload contract itself
- if a runtime needs a narrower projection or local working view, that is a
derivative contract, not a revision of `ghostlight.agent_state.v0`
The rule is simple:
- prompt projection may differ
- storage engines may differ
- language bindings may differ
- the shared payload contract does not drift just because another repo got
lonely
## Immediate Consumers
The first intended consumers are:
- Ghostlight fixture/state tooling
- EpiphanyAgent role dossiers and heartbeat-adjacent agent state
- VoidBot companion-state and social memory machinery where the full Ghostlight
payload is useful
More bindings can come later. They do not get to invent a prettier truth.

View file

@ -0,0 +1,141 @@
# Ghostlight Dungeon Multiplayer Intention
## Status
Bounded cooperative multiplayer is implemented in the current MVP: one to
eight authenticated players create and seed a world (`world.create` v2,
`world.seed`) and begin in one shared scene with sequential public actions,
actor-filtered surfaces, pooled Persona-cell entitlements, and unanimous time,
travel, and budget governance.
The broader model in this document remains the direction for split parties,
private play actions, simultaneous declarations, delegation, and PvP.
The existing simulation is already shaped around multiple bounded actors. The
extension should expose that anatomy to multiple human controllers without
creating a second world authority or a special multiplayer simulation loop.
## Objective
Let several human-controlled characters inhabit one persistent campaign while
preserving the same spatial, epistemic, causal, and commit invariants as solo
play. The implemented milestone keeps the party together. Split locations,
late joining, and private in-play actions remain future governance work.
## Authority map
- **Owner:** one per-campaign `WorldKernel` continues to own canonical state,
fictional time, revisions, rolls, events, and commits.
- **Inputs:** authenticated commands attributed to one campaign member and one
exact human-controlled actor; NPC and Gestalt proposals; scheduler commands;
current campaign and resolution revisions.
- **Outputs:** one atomic world transition plus actor-specific perceived events,
narrative projections, news access, and command receipts.
- **Derived state:** party rosters, shared transcript views, presence indicators,
ready state, and client notifications are projections of campaign membership,
occupancy, perception, and committed events.
- **Forbidden writers:** browsers, sessions, party leaders, Personas,
Interpreters, narrators, initiative, and realtime transports cannot commit or
repair campaign state.
- **Shared path:** solo commands, bounded multiplayer commands, unanimous waits
and travel, NPC reactions, scheduler ticks, and reconnects use the same
`WorldCommand → validate → atomic commit` primitive. Future simultaneous
windows must join this path too.
- **Cut line:** do not add a parallel room-state store, party prompt, shared
narrator memory, or multiplayer-only mutation path.
## Intended model
```text
Player A session ──> actor A command ─┐
Player B session ──> actor B command ─┼─> campaign mailbox
NPC / Gestalt proposals ──────────────┘ │
v
WorldKernel
│
one committed event + revision
│ │
A-perceived projection B-perceived projection
│ │
A's surface B's surface
```
A human-controlled actor differs from a Persona-controlled actor only in the
source of its candidate action. Human text still describes an attempt. It does
not bypass capability, location, custody, knowledge, opposition, assessment,
initiative, or commit validation.
## Invariants
- Every active canonical subject is represented exactly once in the resolution
cover regardless of how many humans are connected.
- One authenticated campaign member controls one or more explicitly assigned
actors according to campaign policy. Control is never inferred from scene
presence.
- An absent or disconnected player's character is not puppeted. Delegation or
Persona proxying requires an explicit, revocable campaign permission.
- Perception and knowledge remain actor-local. A shared party transcript may
contain only events all included viewers are permitted to perceive.
- Private speech, hidden action, secret evidence, and character-local news are
projected only to entitled sessions.
- Players in different locations receive different narrative streams derived
from the same committed world.
- A player cannot make another player's action, reaction, injury, belief, or
relationship true by describing it.
- Simultaneously submitted incompatible attempts resolve from one snapshot and
one deterministic initiative/conflict pass. Arrival order at HTTP or CultNet
transport is not fictional initiative.
- Each active member contributes the allowance returned by the entitlement
port. The campaign pool is capped by the operator ceiling of 240. Human
actors occupy mandatory cells without Persona inference; provider request
concurrency remains independent of the chosen cover.
- Reconnect and replay derive the player's view from committed receipts and
perception rules. A client cache cannot repair or override the kernel.
- One malformed, stale, or unauthorized participant proposal cannot partially
mutate the shared world.
## Social policy still to design
The architecture makes shared worlds straightforward. A usable multiplayer
game still needs explicit answers for:
- exclusive character control, temporary delegation, and permanent departure;
- host powers over invitation, removal, reset, fork, export, and campaign policy;
- asynchronous pacing and how much fictional time one active player may advance;
- simultaneous declaration windows for conflict, stealth, and split scenes;
- consent for player-versus-player persuasion, deception, theft, restraint,
injury, extraordinary influence, and character death;
- disclosure of hidden rolls, private messages, secrets, and retrospective logs;
- moderation, blocking, reporting, and campaign-data deletion;
- mixed privacy/provider lanes and whether one campaign may contain them;
- campaign billing, seats, guests, and ownership transfer.
These are campaign-governance decisions, not browser affordances. They must be
typed, persisted, inspectable, and enforced at command admission.
## Product sequence
1. Prove the solo loop and multiresolution setting agency. **Complete.**
2. Add campaign membership and exact actor-control assignments. **Complete.**
3. Support up to eight cooperative players in one scene with actor-specific
output and unanimous group governance. **Complete in this milestone.**
4. Support split locations, private communication, departure, and late joining.
5. Add snapshot-bound simultaneous declaration windows.
6. Admit adversarial actions only after consent and governance policy is typed
and tested.
The first multiplayer acceptance target should be cooperative and small. The
runtime should earn social complexity one invariant at a time.
## Acceptance direction
- Two players in one scene see one committed event and their own permitted
interpretations.
- A private fact learned by one player never appears in the other's projection.
- Split players can act in different locations without duplicating geometry,
actors, clocks, or institutions.
- Concurrent incompatible attempts resolve once from the same revision.
- A disconnected player remains unpuppeted while world clocks and remote agency
continue under campaign policy.
- Rejoining reconstructs the player's permitted continuity from exact receipts.
- Gestalt merge, split, migration, and named-member rematerialisation remain
correct while several human actors create simultaneous foreground pressure.

View file

@ -1,14 +1,10 @@
# Ghostlight authority architecture
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
This document is the adopted rebuild target for the Ghostlight runtime. It is
the authority map for implementation work. What it describes is built in
`crates/ghostlight` except where it says otherwise here: bounded co-op and the
`archived` phase with read/export-only archival are design intent, and nothing
in `crates/` implements them (`WorldPhase` has `Draft` and `Active` only).
the authority map for implementation work. The pre-rebuild body remains
described in `notes/ghostlight-current-system-map.md` only as teardown evidence.
Ghostlight is a persistent, source-grounded narrative world. Its job is to
preserve people, institutions, places, knowledge, material consequence, and
@ -17,8 +13,8 @@ committee of models until one model permits another model's text to exist.
## Objective
Build one sparse, open-world causal machine that supports solo play (bounded
co-op is design intent, unbuilt), autonomous subject decisions, source-grounded expansion, external
Build one sparse, open-world causal machine that supports solo and bounded
co-op play, autonomous subject decisions, source-grounded expansion, external
world owners, and actor-private experience.
The world grows when committed pressure reaches a typed boundary: a new
@ -65,14 +61,11 @@ multiple process attempts.
`WorldKernel` owns:
- lifecycle phase and revision (`WorldState.phase`, `revision`);
- the owner and the draft approvals (`owner`, `draft_approvals`); membership,
a contract, and negotiated boundaries are design intent and no field of
`WorldState` holds them;
- the typed world ontology (the partitions listed under "Canonical world
state");
- committed events and fictional time (`events`, `now`);
- commitments and pressures;
- lifecycle phase and revision;
- membership, contract, boundaries, and approvals;
- the typed world ontology;
- committed events and fictional time;
- fictional time, commitments, and pressures;
- command idempotency and the commit digest chain.
There is no separate Session Zero owner, component-world owner, aggregate
@ -82,14 +75,11 @@ campaign owner, elaboration committer, scheduler writer, or recovery writer.
The only mutation input is a `CommandEnvelope` containing:
- a stable command ID (`id`);
- the exact world ID and expected revision (`world_id`, `expected_revision`);
- an authenticated principal or internal system capability (`caller`);
- one closed command body (`body: CommandBody`).
`CommandEnvelope` (`crates/ghostlight/src/lib.rs`) has exactly these five
fields. Evidence references travel inside `AdmitPatch`'s `WorldPatch`, not on
the envelope.
- a stable command ID;
- the exact world ID and expected revision;
- an authenticated principal or internal system capability;
- one closed command body;
- exact evidence references where the command depends on source material.
The caller supplies identity evidence, not an authority verdict. For a patch
command the kernel derives the caller's authority itself:
@ -107,17 +97,15 @@ controller receives a privileged mutation path.
### Outputs
An accepted command produces one `WorldCommit` (`crates/ghostlight/src/lib.rs`)
whose fields are:
An accepted command produces one `WorldCommit` containing:
- `schema` and `world_id`;
- `command` (the committed command);
- `previous_revision` and `resulting_revision`;
- `previous_state_digest` and `resulting_state_digest`;
- `previous_commit_digest` and its own `digest`;
- `effect` (a `WorldEffect`: the admitted patch, decision, or time step, with
its committed events);
- `committed_at`.
- previous and resulting revision;
- previous and resulting state digest;
- command ID and derived authority digest;
- the exact admitted mutation batch;
- committed events;
- evidence references;
- commit time and previous commit digest.
The next `WorldState` and `WorldCommit` are persisted atomically. A rejection
returns typed invariant failures and changes nothing.
@ -163,40 +151,28 @@ command.
`WorldState` is one aggregate with these owned partitions:
```text
WorldState (crates/ghostlight/src/lib.rs, the fields of the struct)
identity: schema, world_id, revision, phase, state_digest, last_commit_digest
authorship: owner, title, brief, draft_approvals
WorldState
identity: world_id, revision, phase, state_digest, last_commit_digest
governance: members, actor bindings, contract, boundaries, approvals
ontology:
subjects, entities (place, resource, fact, channel), edges (routes)
positions, holdings, dependencies
facts, channels, knowledge
authority, selection, redress
commitments, pressures, last_opportunity_at
persona_material
controller_assignments, affordance_catalog, affordance_grants
time: now
intent: scale_intent, lens_weights
events: events (decision events)
places and directed routes
subjects and typed components
relations
pressures and commitments
facts, knowledge grants, and provenance
external-owner grants
time: fictional clock
events: committed factual and speech events
applied_commands: bounded idempotency ledger
```
There is no `members`, `contract`, or boundaries field and no
`applied_commands` ledger in `WorldState`. Command idempotency is decided from
the commit rows keyed by command id. `WorldCommit` rows, not `WorldState`,
carry the digest chain.
The lifecycle is data inside the aggregate:
`WorldPhase` has two variants, `Draft` and `Active`:
- `Draft`: `ApproveDraft`, `ActivateWorld`, `AdmitPatch` (seed and ontology
construction under draft authority, which answers nothing), and
`SetLensWeights`;
- `Active`: `ExerciseDecision`, `DeclineDecision`, `AdvanceTime`, `AdmitPatch`
that answers a derived boundary or a jurisdiction deficit, and
`SetLensWeights`.
An `archived` phase with read/export-only archival is design intent; no variant
exists.
- `draft`: membership, negotiation, private/shared speech, character creation,
evidence admission, and ontology construction are allowed by draft authority;
- `active`: player and autonomous decisions, time, causal expansion, and
contract amendments are allowed by active authority;
- `archived`: only read/export operations are allowed.
Activation changes `phase` in place after the current revision has the required
member approvals. It does not copy a preview into another store or hand truth
@ -257,20 +233,19 @@ custody, topology, or knowledge component.
### Relations
`EdgeKind` (`crates/ghostlight/src/patch.rs`) has one variant, `Route`, so the
kernel has no general relation record yet. What the kernel carries, each in
its own `WorldState` partition with typed endpoints:
Relations have typed endpoints and kind-specific constraints. The vocabulary
covers:
- containment (`EntityRecord.container`) and occupancy (`positions`);
- topology and access (`Route` edges with `AccessKind`);
- custody (`holdings`) and dependency (`dependencies`);
- authority and representation (`authority`, `selection`, `redress`);
- knowledge and communication (`knowledge`, `channels`);
- obligation (`commitments`) and causal pressure (`pressures`);
- control (`controller_assignments`).
Membership, lineage, supply and service, and alliance or opposition as
relations are design intent and have no record shape.
- containment and occupancy;
- membership and lineage;
- jurisdiction and representation;
- custody and dependency;
- authority and control;
- supply and service;
- alliance, opposition, and obligation;
- knowledge and communication;
- topology and access;
- causal pressure and exposure.
Civic order is a typed subgraph of authority, selection or succession,
resource access, representation, and redress relations. It is not a prose
@ -313,22 +288,15 @@ authenticated intent or model tool call
-> derive projections and next attention plan
```
The closed command vocabulary is `CommandBody` (`crates/ghostlight/src/lib.rs`),
seven variants: `ApproveDraft`, `ActivateWorld`, `ExerciseDecision`,
`DeclineDecision`, `AdmitPatch`, `AdvanceTime`, `SetLensWeights`. Speech is an
`ExerciseDecision` effect, ontology construction and evidence admission are
`AdmitPatch`, and world creation is a separate genesis (`CreateWorld`).
Membership, contract and boundary changes, external snapshots, and archive are
not commands. Every command lowers to the same mutation vocabulary. There is no
The closed command vocabulary includes lifecycle and membership changes,
contract and boundary changes, speech, ontology construction, subject
decisions, time advance, external snapshots, evidence admission, and archive.
Every command lowers to the same mutation vocabulary. There is no
`commit_elaboration`, separate component-batch ingress, or reload repair path.
## Inference boundary
`ControllerMode` (`crates/ghostlight/src/lib.rs`) has three variants, `Human`,
`NarrativePersona`, and `OperationalAgent`; an externally controlled subject
has no mode. Ghostlight supports two first-class model decision interfaces
(`DecisionControllerMode` in `crates/ghostlight-persona-projection/src/lib.rs`:
`NarrativePersona` and `OperationalAgent`). The ontology assigns
Ghostlight supports two first-class decision interfaces. The ontology assigns
each exact authority scope to one `DecisionController`; the scheduler does not
choose a mode opportunistically. The scheduler does choose resolution: a
`NarrativePersona` controller receives its prose membrane in a singleton cell
@ -466,9 +434,8 @@ does not require a provider, model tier, stage name, output hash, or verifier
receipt to authorize world state.
Every controller lane reaches one `InferencePort`, but that port now routes
to one of three transports chosen by the lane's configured model name: the
CodexConnector daemon peer, unchanged; a local OpenAI-compatible loopback
port (the `local/` prefix, Bonsai in production); or `SdkInferencePort`, a Node child
to either of two transports chosen by the lane's configured model name: the
CodexConnector daemon peer, unchanged, or `SdkInferencePort`, a Node child
process running the Claude Agent SDK against the Claude subscription while
no Codex or Anthropic API budget exists. The SDK route is a named stopgap,
not a peer design: its receipt attests only that this process's own child
@ -554,13 +521,11 @@ author proposal; they do not become a hidden admission tribunal.
## Persistence and recovery
One world `.cc` store (`crates/ghostlight/src/journal.rs`) contains:
One world `.cc` store contains:
- one current `world_state.elaboration.v1` row, schema
`ghostlight.world_state.consumer.v6`;
- immutable `world_commit.elaboration.v1` rows, schema
`ghostlight.world_commit.consumer.v6`, forming a digest chain. Evidence
receipts ride inside the commits' patches; there is no separate evidence row.
- one current `world_state.v1` row;
- immutable `world_commit.v1` rows forming a digest chain;
- immutable exact evidence receipts referenced by commits.
Service authentication and model telemetry use separate service-owned stores.
They cannot participate in world authority.
@ -621,16 +586,11 @@ exists: the play agent's Cut 1 (`d69e9d4`) deleted the tick driver, cover and
Active elaboration sweep it exercised, along with the ignored test itself, to
clear the ground for the play agent's own owner. `notes/local-live-smoke.md`
now documents only connector and Claude SDK sidecar bring-up, which the seed
lane still uses. The real-model gate that exists today is the Idunn recipe's
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`), which
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
against a real model; exercising a whole play turn against one is not gated
by this contract.
`world.begin` is the one route that makes a world. Its payload is
`world_begin.v1`: title, brief and subject_label, nothing else; the route
decides everything else a world is born with (no targets, no roots, no persona
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
goes. `world.seed` runs one
lane still uses. Exercising the road against a real controller again is the
play agent's own gate (`ghostlight-play-agent.md`), not 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
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
Focused tests must prove:

View file

@ -0,0 +1,333 @@
# Ghostlight Dungeon Session Zero and Bounded Co-op
## Status
Teardown evidence, not a live authority map. The `SessionZeroKernel`, the
compiler, the preview and contract-review organs described below exist in no
committed source. World creation is `world.create` on the sealed kernel; seed
authoring is plan step 8, the seed producer: a Draft-phase authoring session
that admits `AdmitPatch` seeds as the owner for approval before activation
(`notes/ghostlight-implementation-plan.md`). Read this document only for the
co-op intent and the rejected compiler shape.
## Owners
### SessionZeroKernel
`SessionZeroKernel` owns one persistent draft mailbox and its revisions. Its
MessagePack CultCache store contains the roster, single-use invitations,
shared/private channels, messages, contract, private character drafts,
boundaries, decisions, approvals, DM Persona, evidence coverage, compilation
preview, and publication receipt.
The store keeps two deliberately different model-receipt collections. The
inference audit contains every committed invocation, including two calls whose
semantic receipt hashes match but whose latency or provider-attempt telemetry
differs. The active-preview proof set contains only the compiler transaction
that produced the currently retained preview. Replacing or retiring a preview
clears that proof set without erasing the audit. A campaign publication carries
only the active proof set; historical DM turns and superseded compiler runs do
not become evidence for a seed they did not produce.
Player messages are durable conversation, not campaign facts. Projector, DM
Persona, Interpreter, retrieval, and compilation stages return proposals. Only
the kernel may change the draft.
Private DM channels use character-component and channel epochs. Independent
private turns can finish in parallel even if an unrelated private component
commits first. Shared changes serialize through the shared epoch. No output is
rebased.
### WorldKernel
`WorldKernel` receives nothing while Session Zero is drafting. Publication
builds a new campaign store in a private staging directory. Campaign seed,
membership, contract, governance, DM Persona, approved brief, approval digests,
Vault evidence, and model receipts enter one empty CultCache batch. The
directory becomes discoverable only after the batch succeeds.
Publication is idempotent by the approved seed digest. Repeating the same
publication recovers the campaign. Reusing the campaign ID for a different
digest is rejected.
## Privacy membrane
Shared DM inference receives the shared contract, aggregate boundary policy,
public party cards, shared decisions, evidence coverage, and recent shared
messages. It cannot receive raw boundaries, another player's private draft, or
private messages.
Private DM inference receives the shared contract, aggregate policy, public
party cards, that player's character draft, that player's attributed
boundaries, visible decisions, and recent messages from that private channel.
It cannot receive another player's private state.
Account hashes and invite-token hashes remain private persistence fields. They
are absent from JSON Schemas, browser projections, CultMesh surfaces, and model
contexts. Actor-specific campaign surfaces use random campaign member IDs and
the exact assigned actor.
The structured Projector and Interpreter receive their exact stable JSON
Schema before any dynamic context. OpenAI-compatible JSON-object modes
constrain syntax but do not communicate the application schema by themselves.
The DM Persona owns the natural utterance. The Interpreter's model-facing
schema has no speech field: it emits only new typed proposals and reply
affordances, after which the runtime binds the Persona output losslessly into
the proposed kernel delta. It cannot rewrite, summarize, or truncate the
Persona while extracting state. The kernel still treats the three stages as
one atomic proposal; an invalid Interpreter commits neither speech nor state.
The Interpreter receives only the current contract, its channel and member
scope, the entitled private character when applicable, unresolved material
decisions, and the exact Persona response. Non-material opening and role cards
remain renderer-visible suggestions but never enter Projector or Interpreter
cognition unless the player explicitly selects or discusses one. Transcript windows, evidence coverage,
boundaries, and public-party narrative already served their purpose in the
Projector and are not replayed into extraction. It cannot restate existing
draft fields or unresolved decisions as new proposals. Stable schema-first
prefixes preserve provider cache reuse. Empty, malformed, schema-invalid, or
stale outputs cannot change the typed draft. If both same-snapshot attempts
fail, the kernel may append a fixed local DM notice to the conversation while
leaving contract, characters, decisions, boundaries, approvals, and model
receipts unchanged.
Every player-triggered Session Zero model task enters the same live-inference
priority gate as campaign play. This includes opening and role suggestions, DM
turns, counter replacements, and world compilation. Entering the gate cancels
an in-flight strategic model wave before it can commit and prevents new
background inference from launching until the interactive task leaves. A quiet
campaign cannot consume provider concurrency while a player is waiting at the
table.
One Interpreter turn has one owner per typed lane. A shared turn may directly
edit the draft contract or offer a contract decision, never both; a private turn
may directly edit the ordinary character draft or offer a character-patch
decision, never both. An extraordinary-permission bargain is a separate consent
lane, so it may accompany a direct mundane character patch without being
silently granted. The model-facing schema requests a same-snapshot correction for a
split-lane output, and the kernel repeats the invariant before mutation.
Decision metadata is not part of the general Interpreter's ontology. Its
schema carries only the prompt, proposed resolution, and one or more typed
semantic payloads. The Director assigns a fresh durable decision ID, exact
private-channel owner (or shared ownership), material status, and clean
lifecycle state. Extraordinary-permission proposals similarly contain terms
only; Ghostlight assigns the permission ID and binds the exact entitled actor.
Model output cannot reuse a decision ID, target another actor, replace an
existing permission by guessing its ID, or claim evidence custody. Focused
counter replacement preserves the already committed permission identity while
allowing only its negotiated terms to change.
Generated opening and role suggestions are non-material decisions: the player
may accept, counter, discuss, or ignore them without blocking a fully custom
draft. They are UI affordances, not implicit DM memory. On daemon load, the registry demotes pre-cut payloadless questions and
generated opening records whose old `material` flags would otherwise veto a
custom draft. A payloadless question cannot own compilation readiness because
it has no typed change to accept. This migration uses an exact CultCache
replacement and does not advance the Session Zero revision or shared epoch.
Material decisions preserve the distinction between discussion and consent.
Every acceptable decision carries at least one non-empty typed contract,
character, or extraordinary-permission payload. A question or a prose promise
without an exact state change remains DM speech or a suggested reply; it cannot
render an Accept control, advance the draft through acceptance, or claim that
the promised materialization occurred. The Interpreter schema enforces the
payload before correction/retry, and the kernel rejects any legacy payloadless
decision that reaches acceptance.
The actor-filtered Eve projection shows that exact payload beside the DM's
explanation before exposing Accept. Its private character ledger projects every
player-entitled draft field, including history, secrets, knowledge, equipment,
relationships, goals, and the complete terms of extraordinary permissions.
Prose explains a bargain; the visible typed payload is the state the player is
actually consenting to.
Character patches are reversible during negotiation. Every list supports exact
removals before additions, and relationship keys support exact removal before a
replacement is installed. This permits spelling, identity, and mistaken-ledger
corrections without duplicate private subjects or transcript replay.
Accept applies only the exact typed payload currently stored in the decision.
Counter preserves that payload as an inert, visible audit and replacement
basis, records the player's text in the decision's durable channel, marks the
unresolved decision as awaiting a DM replacement, and invalidates the affected
projection epoch. `pending_counter` removes the Accept surface and makes the
retired payload uncommittable. The DM response
must produce a fresh material decision against that same channel and component
epoch; installing it and retiring the pending counter is one kernel commit.
Until then the old decision has no Accept surface and forged acceptance fails.
Inference failure, an irrelevant response, or a stale response leaves the
counter unresolved and compilation blocked.
Retry relaunches the membrane from that already persisted counter and unchanged
Session Zero snapshot. It does not ask the player to retype the counter, append
a duplicate message, or advance revision merely to try inference again; only a
later kernel-validated replacement changes state.
The countered decision ID is also part of the model snapshot binding and
permitted turn context. A deterministic Ghostlight Projector renders that one
pending decision, its retired typed payload, exact counter, and aggregate safety
policy into the Persona's narrative stream. This exact projection needs no
model call or model receipt; the focused turn records only Persona and
Interpreter inference. The Interpreter separately receives the same bounded
typed basis. When the retired proposal still has one exact typed lane, the
Interpreter emits only that replacement payload: character patch, contract
patch, or permission terms. Ghostlight binds the fresh decision ID, owner,
materiality, evidence, prompt, and persisted counter locally. Permission IDs,
actor bindings, and evidence receipts are preserved from the retired proposal
and are absent from the model's output authority. The larger union Interpreter
remains only for ordinary conversation and pre-cut payloadless migration.
Conversation history, party
cards, the contract, unrelated decisions, and unrelated private character state
are not part of the focused retry. Pre-cut decisions whose typed payload was
erased receive one bounded current-state basis for migration only. The kernel
accepts exactly one same-lane replacement with stable extraordinary-permission
identity; direct patches or unrelated decisions cannot retire the counter.
## Approval and publication
The host locks the roster before compilation. Material unresolved decisions
block compilation. The kernel also refuses a conversation-only draft: the
typed contract must identify premise, canon horizon, start, pressure, goal,
tone, pacing, consequences, narrative focus, and DM style; every active
character must have a public premise, capability, goal, and at least one
obligation or vulnerability. Group drafts additionally require a party bond.
The compiler receives an `approved_campaign_brief.v1`, not
the transcript. Private character history and secrets are withheld from world
generation; after the grounded seed validates, Ghostlight locally replaces the
provisional compiler player with the exact approved actors at one shared start.
Approved relationships cross that membrane by identity, not by free-form map
key. A relationship naming another approved player resolves to that player's
exact actor ID. An otherwise unresolved named person receives a stable,
server-generated relationship-anchor actor ID; the compiler must materialize
that exact actor and name in the seed, but it never receives the private
relationship description and may not reveal the anchor in opening narration
merely because the private ledger requires their existence. The owning player
sees the compiled subject, placement, and relationship during private review.
Their final approval therefore covers the compiler's placement without
exposing it to the table. Actors may also hold directional relationships to an
exact institution, Gestalt, or named Gestalt member. Such a relationship grants
no knowledge, resource, membership, or collective authority.
If compilation fails before a preview is installed, the roster remains locked
and the typed brief remains authoritative. A host whose current brief still
passes the readiness gate receives the Compile action again; retrying uses the
new revision and never replays or accepts transcript prose.
Evidence gaps return the draft to conversation with explicit questions. A
gap-bearing compiler result is retained as an exact non-canonical preview while
the session remains in `drafting`. The host may move that exact digest into
review, or the table may revise typed state and retire it before another
compile. Moving it to review is not approval. Every active player must approve
the current shared digest, their current private character digest, and the
exact world-preview digest containing topology, cast, institutions,
populations, clocks, evidence lanes, gaps, and branch-local assumptions.
Publication persists the approved preview digest and exact accepted gap and
assumption lists beside the campaign seed. Substituting any preview makes every
prior approval stale. Relevant edits retire the preview and invalidate the
affected approvals. The host has no override.
Pre-cut Session Zero stores used one receipt list for both meanings. Registry
load migrates that list without advancing Session Zero revision or fictional
time: all entries become audit history, while an extant world-seed preview
receives the bounded final compiler transaction from its last
`custom_retrieval_plan` through `agency_compile` as its exact proof set. Later
conversation receipts and Contract Review previews are excluded. The migration
is an atomic CultCache replacement and is idempotent on restart.
Accepted extraordinary bargains become typed permissions. Later assessment
receives their exact scope, prerequisites, costs, limits, exposure, evidence,
and effect ceiling.
## Bounded co-op
`campaign_membership.v1` binds authenticated account hashes to random member
IDs and exact actor IDs. HTTP admission derives the acting actor from this
record. Assessment confirmation includes that actor ID and cannot consume
another actor's assessment.
The first co-op mode deliberately permits one shared scene and sequential
public actions. It rejects player-versus-player state mutation, movement that
would split the party, raw unilateral waits, and unilateral resolution-budget
changes. Time, group travel, and pooled Persona-cell budget changes use
revision-bound proposals; the final approval and campaign transition commit
atomically. Group travel revalidates the shared origin and exact route, moves
every assigned actor, and advances time in one world commit.
Every human-controlled actor has a non-simulatable agency profile. Live NPC
appraisal, multiresolution cells, strategic outcomes, migration, scheduler
ticks, and away-time work all exclude those actors from Persona control and
direct mutation. Disconnection grants no proxy authority.
Each active member contributes their entitlement-provided cell allowance. Test
accounts currently contribute eight. The campaign starts at the pooled total,
capped at 240; physical provider concurrency remains independent.
## Realtime and interface
Ghostlight publishes an actor-filtered Eve surface for each Session Zero member
and campaign member. Server-sent events contain revision notifications only.
The browser refetches the authoritative Eve document after a notice; the
transport owns no messages, drafts, ledgers, approvals, or world state.
The browser exposes keyboard-operable shared/private channel tabs, live status,
public party cards, private ledgers and boundaries, unresolved decisions,
roster readiness, and explicit compile/review/approve/publish actions. The Eve
review tree visibly renders topology, non-private cast, institutions without
private goals or resources, populations, clocks, evidence-use rationales,
gaps, and branch assumptions. Relationship-anchor people remain absent from
the shared cast and appear only in their owner's private relationship card.
Privacy is labeled in text and never conveyed only by color.
The host may discard any non-canonical preview through a typed Session Zero
command and continue drafting without changing the contract, characters, or
fictional time. A retained preview never traps the table into reviewing it.
Session Zero also remains the sole owner of every human-controlled character.
The shared compiler may use player-approved public names and premises as world
context, but those names are reserved: neither ordinary cast nor Gestalt member
output may materialize them. Its singular player document is only a provisional
starting-position marker. After compilation, Ghostlight removes that marker and
installs the approved typed characters. A candidate that duplicates a reserved
player identity is corrected against the same snapshot or rejected atomically.
An approved unresolved relationship person crosses world compilation as a
server-owned identity anchor. The shared world compiler never receives that
person's name, identity handle, or relationship. After the public topology is
validated, a separate private stage receives the approved name and exact
available locations and must synthesize exactly one ordinary actor candidate.
It remains responsible for that person's location, capabilities, knowledge,
equipment, obligations, and goals, but cannot emit IDs, relationships,
narration, facts, gaps, assumptions, or public-world changes. The compiler then
attaches the opaque canonical ID locally and the approved relationship is added
only to its owner's actor. Omission, ambiguity, unknown placement, or ID
collision rejects the whole candidate. Shared output cannot leak private input
that its owning model never received.
## Inline grounded suggestions
Opening and role generation is no longer a browser-owned creation flow. On a
new draft, the compiler retrieves three distinct Vault-grounded opening
suggestions and installs them as shared typed decisions. Accepting one amends
the draft for further discussion; it does not publish a world. Ghostlight then
retrieves three grounded role suggestions and installs separate typed decisions
in each player's private channel. A custom premise uses the same conversation,
draft, approval, and compiler path.
## Contract Review
A launched campaign can enter a focused Contract Review backed by the same
Session Zero kernel and privacy projections. The review is bound to the exact
world revision at which it began. Any intervening world commit makes it stale.
Every active member approves the revised shared digest and their own character
digest before the host can publish.
Publication updates the existing campaign, contract, membership permissions,
DM Persona, approved brief, governance epoch, actors' approved forward-looking
ledger fields, event, and receipt in one CultCache batch. It cannot change
membership custody, locations, topology, knowledge, memories, historical
events, Vault, canon horizon, or established starting geometry. Tightened
private boundaries publish immediately as an anonymous strictest campaign
policy; relaxation waits for unanimous review publication. Narration, action
assessment, resolution demand, cell Projectors, Interpreters, and semantic
verification receive the approved contract and aggregate policy. Actor Personas
still receive only lived narrative, not policy schemas or attributed safety
records.
## Deferred governance
Split parties, private in-play actions, simultaneous declarations, PvP,
delegation, late joining, permanent post-publication departure, group
fork/reset/export, and voice remain unsupported.

View file

@ -0,0 +1,120 @@
# Ghostlight Eve-native interface
## Objective
Ghostlight exposes one actor-filtered logical surface, `ghostlight.play`. Eve
owns its editable bindings, command invocation, receipts, plugin composition,
and browser lowering. Ghostlight continues to own world creation
(`world.create` v2), the Draft seed lane (`world.seed`), and world admission;
Heimdall continues to own authentication and entitlement decisions.
```text
Ghostlight projection
-> EveBrowserProviderHost
-> canonical Eve lowering
-> gamecult.eve.command_invocation.v1
-> Ghostlight ingress or Heimdall private command plane
-> gamecult.eve.command_result.v1
-> authoritative surface refresh
```
## Authority map
- Owner: `SessionZeroKernel` owns draft changes, `WorldKernel` owns campaign
changes, Heimdall owns OAuth attempts and claims, and Ghostlight's app-session
owner binds a verified Heimdall subject to a local cookie.
- Inputs: the provider projector reads only the caller's app session,
membership, actor-filtered campaign or Draft world slice, and public
Heimdall gate state. Command ingress reads one canonical Eve invocation and
derives member and actor identity server-side.
- Outputs: one `gamecult.eve.surface.v1` document and one persisted
`gamecult.eve.command_receipt.v1` inside a command result. Invitation links
may appear only in a transient projection.
- Derived state: browser drafts, tab selection, focus, command status, the
selected campaign card, and rendered HTML are projections or local
interaction state. None grants access or mutates fiction.
- Forbidden writers: the browser, Eve lowerer, Heimdall plugin, SSE stream,
account preferences, and old auth store cannot choose an actor, establish
membership, approve a Draft admission, or commit world state.
- Shared paths: every UI operation resolves the authenticated account and
exact membership, then calls the existing world mailbox.
SSE carries invalidation only and the host refetches the same surface.
- Cut line: the bespoke browser renderers and product-specific API routes are
removed from the public router. Ghostlight's backend callback and local
OAuth-attempt store stop participating. `service/auth.cc` is migration and
rollback evidence only; `service/app-sessions.cc` owns new local sessions and
account preferences.
## Editable values
Eve bindings are the input model. A component binds a typed value by stable
binding name, document identity, field path, value kind, access mode,
authority, and optional write command. Renderer-local composers use
`local-draft`; direct edits name provider-owned state and an advertised write
command. 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. Operations that capture no
editable bindings therefore cannot erase an unrelated composer, channel
selection, boundary draft, or counterproposal. Provider-authored length limits
lower onto editable controls before submission; Ghostlight still validates the
same bound at ingress. Denied command receipts use an accessible alert region
and survive authoritative surface refreshes alongside the preserved draft.
## Authentication membrane
Anonymous projection contains only `heimdall.access_gate`. Its begin and
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary.
Ghostlight first reads the redacted `heimdall:command-boundary` record from
Odin, validates its schema, runtime, loopback route, operations, and HMAC/AES
contract, and then invokes that discovered route. Odin does not proxy the
command and never receives claims or completion payloads. A discovery outage
fails new authentication closed while already-valid local Ghostlight sessions
continue until their verified expiry.
The browser retains only the opaque attempt handle. Discord returns to
Heimdall; Ghostlight redeems the completion, validates the access claim and
`app_access`, and creates a local HttpOnly session.
Routine requests use local verified session state. The cookie hash, stable
account-subject hash, Heimdall session/revision, capabilities, expiries, and
wrapped refresh claim persist in `app-sessions.cc`. Campaign authorization is
always derived from `campaign_membership.v1`; account preferences contain only
the selected campaign. `campaign.entry` may clear only that preference so the
same authenticated player can return to the campaign list or begin creating
another world with `world.create`; it cannot leave, reset, fork, or mutate any
campaign.
Session Zero registry lookup treats only non-terminal negotiations as active:
`published` and `archived` records remain durable history but cannot replace the
campaign-entry surface after the preference is cleared.
Transient command-result surfaces preserve Eve's composite campaign interface
version. A fresh or recompiled assessment may replace only the campaign-revision
component while retaining the resolution and provider-configuration epochs.
The roll control therefore invokes against the same version namespace as the
authoritative `ghostlight.play` surface instead of collapsing it to a raw world
revision.
## Public boundary
- `GET /api/eve/provider`
- `GET /api/eve/surfaces/ghostlight.play`
- `POST /api/eve/commands`
- `GET /api/eve/events`
`/health` remains a probe of the service's typed CultMesh health. Static assets
contain only the provider host, transport, Heimdall adapter, status mount, and
SSE invalidation. Hermodr is not a runtime dependency.
## Live acceptance witness
The Yggdrasil release at Ghostlight commit
`b515ca90c25573005a616244143803b37f2d06ec` and Eve commit
`6766bee7c14a47144191475e2f35b0343b647b45` serves the anonymous access gate
through the canonical browser lowerer with no console errors. A canonical
`heimdall.auth.begin` invocation was accepted through Odin-discovered Heimdall
and returned only the plugin-scoped navigation receipt with advertised Discord
and Heimdall origins. The deployed unit contains
`GHOSTLIGHT_ODIN_RUDP=10.77.0.1:17871` and no direct Heimdall private endpoint.

View file

@ -0,0 +1,936 @@
# Ghostlight Multiresolution Agency Graph
Status: teardown evidence. Describes the pre-rebuild resolution cover,
strategic waves, demand/deficit elaboration, and the wave-level outcome
resolver, all on the deletion line. The current vocabulary is
`docs/architecture/ghostlight-world-ontology.md`. It keeps the resumable
per-jurisdiction elaborator session, compact mutation drafts, the complete
deterministic mismatch set, the authored `WorldScaleIntent` and its
deterministically derived deficit, the budgeted connected cover with singleton
detail focus and grouped coarse cells, debt rotation, and deterministic clocks
that advance regardless of attention. Detail first, then the ten-percent choke,
is the operator's design and survives. It drops gestalt as identity:
`GestaltPersonaState`, member deltas, arena knowledge union, fission as a scale
loop, promotion and folding, and cover-completeness targets.
## Objective
Let a campaign contain the whole strategic setting without pretending every
person, faction, institution, and population can receive a separate model turn.
Ghostlight preserves canonical fine state and changes only the resolution at
which that state is simulated during a wave.
This document is the authority map for the implemented multiresolution organ.
The wider runtime map remains `ghostlight-dungeon-mvp.md`.
## Authority map
- **Owner:** `WorldKernel` owns canonical subjects, world revision, resolution
controls, accepted fission, and atomic strategic-wave commits.
- **Inputs:** canonical actors, institutions, gestalt leaves, typed agency
profiles and relations, resolution pins, current pressure demand, committed
clocks/events, and the previous accepted cover.
- **Outputs:** a revision-bound `ResolutionCover`, its plan receipt, one
appraisal per selected cell, exact constituent-attributed proposals, and one
atomic strategic transition.
- **Derived state:** simulation cells, merge losses, content-addressed cell IDs,
detail focus, and the active cover. None owns a person, population, secret,
relationship, possession, or fictional fact.
- **Forbidden writers:** demand projection, graph construction, the partitioner,
Projectors, Personas, Interpreters, scheduler, browser, and operator inspector
cannot mutate canonical world state.
- **Shared paths:** scheduled ticks and return catch-up both compile the same
`AdvanceStrategicTick` command. Budget and pin changes use the campaign
mailbox at safe boundaries. Provider parallelism uses a separate
configuration epoch and does not repartition or advance fiction.
- **Cut line:** the old flat strategic-plan proposer is gone. No active runtime
path may tick a hand-picked list of factions outside the selected cover or
union arena knowledge into a synthetic actor.
`ActorState`, `InstitutionState`, `GestaltPersonaState`, and durable gestalt
member deltas remain canonical owners. `AgencyProfile` and `AgencyRelation` are
typed partition inputs. `SimulationCell` is always disposable and rebuildable.
## Latent-world scale and elaboration demand
- **Owner:** consumer-authored `WorldScaleIntent` owns the desired active-cover
ratio. `ResolutionPolicy` continues to own only the active-cell entitlement.
A deterministic `WorldElaborationDemand` derivation owns the resulting target
and deficit; it owns no fictional content.
- **Inputs:** active-cell budget, target active-cover basis points, the current
count of simulation-eligible canonical subjects, and positive complexity
weights for admitted realm jurisdictions.
- **Outputs:** target actionable-subject count, exact current deficit, a bounded
round-effort budget, and deterministic per-realm target shares. At 240 cells
and 1,000 basis points, the target is 2,400 subjects.
- **Derived state:** demand, deficit, invocation schedule, and realm target
shares are resumable planning artifacts. They are not subjects, facts,
Personas, cells, or mutation permits.
- **Forbidden writers:** the elaboration scheduler, titled workers, Gestalt
compression, Nemesis, and provider concurrency cannot create or count a
proposed subject as canonical. Only admitted population, institution, or
actor operations committed through `WorldKernel` reduce the deficit.
- **Shared paths:** initial compilation, destination expansion, resumed
elaboration, and later consumer-requested scale changes use the same demand
derivation and canonical actionability count. Each titled worker proposes at
most one structural operation at a time; deterministic admission and kernel
commit remain unchanged. The current scale loop raises complexity through
grounded Gestalt fission or promotion of an individual into an active Actor
leaf, never through unelaborated census or roster rows. Institution admission
remains compiler-owned rather than being smuggled into this loop.
- **Cut line:** fixed elaboration-pass count is not a world-scale owner. It may
bound which jurisdictions are being worked in one run, but it cannot declare
the latent world sufficiently populated. Provider concurrency controls only
simultaneous calls and never changes the target or deficit.
Realm complexity weights distribute the target; they do not increase it. Any
integer remainder is assigned deterministically so the realm shares sum exactly
to the world target. A rejected or conflicting proposal leaves the deficit
visible rather than being hidden behind an assumed model-call yield.
The actionable count includes only active simulation-eligible agency leaves.
Dormant member records are useful latent identity, but do not pay down the
complexity deficit until grounded elaboration promotes them. Resolution may
still compress roughly 2,400 such leaves into at most 240 cells.
The mutation budget is not a cold-prompt budget. Each titled elaborator owns a
resumable working session per realm jurisdiction containing its current frontier, unresolved leads,
recent admitted mutations, exact rejection findings, and title mandate. The
canonical world remains long-term memory. Bounded retrieval supplies older
relevant state. After each round, every title that committed work receives one
agentic compaction turn. It emits a typed checkpoint bound to the exact admitted
commit ancestry; raw conversation is never authority, and the next turn appends
from the checkpoint. This round boundary is the current bounded context policy.
Scheduler frequency remains proportional to slider share across sequential
turns.
The scale workbench uses mutation drafts rather than asking a model to restate a
complete kernel command. A fission draft owns only new child identities,
partition values, and exact member/resource assignments. The deterministic tool
attaches campaign revision, parent, axis, approval fields, and inherited
capabilities, knowledge, goals, and pressures before running the ordinary
fission validator. An individuation draft owns only the new member delta; the
tool attaches the exact parent version and location. Unchanged world state is
therefore never paid for in model output and cannot drift while being copied.
The shared fission validator reports the complete deterministic mismatch set in
one observation, so an agent can repair the same compact draft without replaying
inference or guessing which invariant failed first.
The lane-specific action schema requires one entry for every exact frozen
resource and dormant-member ID and rejects extra keys. The elaborator still
chooses child custody; it cannot silently omit a scarce thing the kernel must
conserve.
Round effort is proportional to unresolved pressure rather than presumed model
yield: `ceil(active_cell_budget * deficit / target)`, zero when the target is
satisfied. With 80 admitted leaves against the 2,400 target, the first round
funds 232 one-operation turns. After admission the deterministic owner recounts
active meaningful leaves and derives the next round afresh. A fission may add
several meaningful child leaves; a rejected proposal may add none. Neither case
changes the accounting rule or grants the scheduler fictional authority.
## Canonical population resolution
Known population detail is represented by non-overlapping active gestalt
leaves. A material split uses approval-gated `FissionGestalt`:
1. the compiler retrieves exact Vault evidence and produces a fission preview;
2. every requested enumerated facet gets a child and one `other/unknown`
remainder is mandatory;
3. children inherit the parent baseline and own only later deltas;
4. every exact scarce resource is assigned to one child rather than copied as
an inheritable trait;
5. member deltas are assigned to one child without rewriting identity;
6. the parent remains as inactive lineage rather than being destroyed;
7. the kernel validates the preview, evidence, versions, assignments, and
residual child before one atomic commit.
The commit is a canonical `WorldMutationBatch`: child admission establishes
identity and occupancy, the lineage mutation inherits non-scarce baseline
components, custody transfers partition resources, and membership transfers
move exact named people. Agency profiles, inherited partition facets, and the
discarded cover are resolution projections of that accepted transition; they
cannot create a second result.
Reversible simulation aggregation never invokes fission and never deletes or
reverses this lineage.
## Canonical individual migration
Resolution aggregation and population membership are separate authorities. A
cell may temporarily contain many populations, but that never changes which
population baseline a durable person is composed from. `WorldKernel` owns one
explicit membership transition for that purpose.
- **Owner:** `WorldKernel` owns a durable member's current active-leaf
`gestalt_id`. The member delta owns their identity, memories, relationships,
possessions, injuries, obligations, and personal departures from the current
baseline. `GestaltLineage` owns immutable fission ancestry.
- **Inputs:** one exact member-attributed proposal, the source and destination
active gestalt leaves, a typed migration relation, a reachable destination,
and the campaign/resolution snapshot already bound to the strategic wave.
- **Outputs:** the same member identity attached to the destination leaf, with
capability and knowledge deltas rebased so their effective personal state is
bit-for-bit unchanged; one attributed migration event is emitted.
- **Derived state:** simulation cells, arena membership, member prompt
selection, and rematerialized `ActorState` projections do not own population
membership.
- **Forbidden writers:** a source gestalt, destination gestalt, arena, demand
model, Projector, Persona prose, or migration relation cannot move a person.
Only an action attributed to that exact durable member may propose the
transition, and the kernel validates it.
- **Shared paths:** source and destination may be leaves at any depth under
unrelated lineage trees. The same rebase primitive handles crisis camps,
neighborhoods, departments, corporations, species populations, and later
refinements.
- **Cut line:** changing `GestaltMemberDelta.gestalt_id` directly is forbidden.
It would reinterpret the old delta against a different baseline and silently
alter the person's knowledge, capability, or default goals.
A bounded set of salient dematerialized member exceptions may be projected
inside the cell containing their current gestalt. They remain separately
attributed people, not shared gestalt state. Selection favors unresolved
personal conditions, obligations, goals, and relationships to the player and
is capped so a large population does not dump its roster into the prompt. A
member may migrate only along an explicit active `migration` relation to a
reachable destination leaf. At any active leaf, the same member may instead
emit a `member_activity` from their exact location toward their current
population, an explicitly related subject, or another selected named person or
active subject at that exact location. Only the capped salient member
exceptions exposed to the cell may appear as named targets in its prompt;
canonical validation still checks the target's identity and current location.
Migration and ordinary activity share one per-member strategic action slot.
This lets a refugee who mattered during
a crisis choose to travel, offer work after settling, and later rematerialize
as the same person without forcing every refugee to consume an active Persona
cell or giving their agency to the destination Gestalt.
The membrane distinguishes a chosen journey from preparation for one. When a
member commits to board, depart, travel, or join a supplied destination within
the strategic horizon, the Interpreter must emit `member_migration`; approaching
the queue is not allowed to erase that commitment into `member_activity`.
Preparing applies only while departure remains unchosen. The semantic verifier
checks both directions so neither consideration becomes travel nor chosen
travel disappears into local activity.
Presence projection is location- and lineage-bounded. The planner sees only
active gestalt leaves at the player's location, dormant members whose exact
current location is there, and materialized members that might be eligible to
fold. Inactive ancestors and remote dormant rosters do not enter the prompt.
The kernel independently rejects individuation from an inactive leaf,
promotion outside the member's exact location, and automatic reconciliation
outside the player's active location. A direct internal command may preserve a
named offscreen actor, but model-driven presence reconciliation cannot teleport
one into view. Existing member deltas are preferred when their relationship,
memory, obligation, or goal makes an ordinary encounter a meaningful callback.
The player actor's reciprocal relationship to each nearby dormant member is
projected beside that member's exact delta. Presence planning is scene casting,
not roster cleanup: an earned local callback may surface without the player
asking for the person, while an incompatible event or absence of durable
callback evidence may still produce no promotion. The model only proposes the
cast; `WorldKernel` validates exact member, gestalt, location, and versions
before materialization. Live harnesses persist the private presence preflight
even when their acceptance assertion fails.
The presence-planner schema enumerates promotion IDs only from the supplied
nearby dormant roster, demotion IDs only from supplied materialized actors, and
gestalt and location IDs only from the exact scene candidates. A structurally
valid plan that mismatches member, gestalt, location, or version receives one
same-snapshot semantic correction. When that correction succeeds, both the
rejected and accepted stage receipts remain durable; the rejected receipt is
marked `semantic_invalid` with the bounded local reason.
## Canonical population migration
Population fission and population movement are separate decisions. Fission
answers which non-overlapping cohort exists. Migration answers where one active
cohort goes. Simulation aggregation answers how much inference attention that
cohort receives after arrival. None of those operations is a canonical merge.
- **Owner:** `WorldKernel` exclusively owns an active gestalt leaf's
`home_location_id` and the matching agency-profile location set.
- **Inputs:** one exact gestalt-attributed proposal, an active source leaf, an
explicit migration relation to an active destination population leaf, a
reachable destination, and the unchanged world and resolution revisions.
- **Outputs:** the source leaf and its agency profile move atomically, their
versions advance, and one attributed population-migration event records the
source and destination locations and populations.
- **Derived state:** the resolution cover may later place the migrant cohort
and destination population in one cohesive or arena cell. That cover does
not change either population's identity, knowledge, authority, or lineage.
- **Forbidden writers:** an arena, destination population, Projector, Persona,
Interpreter, scheduler, or relation cannot move a population. A population
move never carries named member deltas by implication; each named person's
exact location and membership remain unchanged until that person acts.
- **Shared paths:** every active leaf uses the same transition, whether it is a
root population or a child many levels below it. Approval-gated fission may
first create destination, stayer, and `other/unknown` cohorts; each resulting
leaf can then migrate independently.
- **Cut line:** collective departure is neither `prepare` nor
`member_migration`. The former loses the chosen journey; the latter gives a
population authority over a person. Canonical population merging remains
absent: arrival changes geography, while derived covers provide cheap local
aggregation.
## Demand projection
A flash-model structured stage converts the committed event or strategic
horizon into weights for geography, ideology, authority, economy role,
species/body, and information. Focal subjects must be chosen from supplied IDs.
Local semantic validation permits one same-snapshot correction. Failure falls
back to the last accepted demand; the initial fallback favors geography and
authority.
Demand is relevance evidence. It cannot create profiles, pins, cells, or facts.
## Agency graph
Vertices are active canonical actors, institutions, and gestalt leaves. Edges
carry containment, command, membership, alliance, rivalry, trade, migration,
communication, coercion, and shared-location meaning. Subjects sharing a
location receive derived adjacency. Disconnected components receive a
resolution-only Steiner chain so the graph remains coarsenable; those bridge
edges assert no lore relation, common authority, proximity, or shared
knowledge.
The compiler must profile every non-player actor, institution, and gestalt
exactly once across all six axes. It compiles a bounded local play region and a
global skeleton of major powers, coarse regions, institutions, information
channels, and strategic pressures. Runtime migration derives profiles for old
flat campaigns without changing their world revision, actors, institutions,
gestalts, or fictional time.
## Budgeted connected cover
The campaign selects an active Persona-cell budget from 1 through 240, default
8, bounded by its pooled entitlement. Provider parallelism is a separate
control capped at 32 concurrent cell pipelines.
The scale fixture covers 1,000 canonical subjects with 200 unique cells, then
dispatches every cell membrane in one wave while a seven-permit semaphore
bounds physical provider work. Wave width and provider pressure are therefore
separate authorities; all terminals still join, sort, validate, and commit as
one atomic resolution wave.
The partitioner:
1. contracts valid `keep_together` pins;
2. forces focal, directly engaged, leased, initiative, targeted, and
`minimum_individual_detail` subjects into singleton cells;
3. respects `keep_separate` pins and reports mandatory effective-budget
overage rather than hiding a foreground subject;
4. starts at fine resolution and repeatedly merges the lowest-loss legal
graph-adjacent pair with deterministic tie-breaking;
5. performs one connected boundary-refinement pass and accepts a move only
when compression cost improves by at least five percent;
6. preserves a previous cover during its lease, or until a replacement improves
the weighted objective by at least ten percent, unless a forced split or
budget change applies;
7. validates complete unique coverage, connectedness, cell identity, mode, and
epoch before the cover can enter a wave.
Compression cost is merge loss multiplied by the number of collapsed
trajectories. This prevents the objective from favoring one overloaded arena
merely because a singleton has zero local loss.
The normalized merge loss is:
```text
0.25 facet divergence
+ 0.20 hidden causal-boundary mass
+ 0.15 information-scope divergence
+ 0.15 spatial divergence
+ 0.10 clock/obligation divergence
+ 0.10 salience burial
+ 0.05 partition churn
```
Facet divergence is demand-weighted Jaccard distance. Spatial divergence uses
known locations, direct travel time, and the current strategic horizon.
Candidate scoring uses a deterministic mergeable representative sample for
bounded local work; exact authority, hostile relation, coverage, connectivity,
pins, and knowledge validation remain unsampled.
Union-by-size owns constituent-set merges. This is not merely an optimization:
it keeps the 1,000-subject path from degenerating into quadratic copying while
leaving stable merge ordering unchanged. The release property test completes
the 1,000-subject, budget-8 partition in approximately 50 ms on the development
host, under the 100 ms contract. Setting `GHOSTLIGHT_PARTITION_TRACE` enables a
development-only phase timing probe.
## Cell modes
- **Cohesive:** all constituents share one actual collective authority, contain
no active hostile edge, and remain within information and behavioral
divergence thresholds. The cell may speak and act plurally.
- **Arena:** constituents remain distinct or opposed. The cell receives an
attributed polyphonic situation and may simulate interaction, but it has no
actor ID and cannot speak, know, decide, or act as a collective.
Cross-faction aggregation is therefore an arena. Arena interpreters may emit at
most `min(4, 1 + ceil(log2(constituent_count)))` actions, each attributed to an
exact constituent. The kernel validates that subject's knowledge, information
channel, resources, authority, relationships, location, and reachable
destinations. Secrets are never unioned.
## Strategic outcome authority
A valid attributed attempt is not yet a world consequence. The Interpreter
owns the narrow claim that a constituent chose an admissible attempt; it does
not decide whether the attempt worked, what opposition did, or which durable
state changed. Treating the attempt event as a sufficient result made the
world busy in prose while its resources, relationships, knowledge, and
pressures mostly stood still.
One wave-level strategic outcome resolver owns that missing decision. It runs
after priority and incompatibility selection but before the command enters the
kernel:
1. Ghostlight content-addresses every selected `gestalt_activity`,
`actor_activity`, and `member_activity` proposal. The digest binds exact
source, intent, intended effect, targets, locations, state references,
channels, and typed activity.
2. A local context compiler supplies only those selected attempts and their
legal consequence handles: source capabilities and holdings, exact target
state, active incident relations, current pressures, eligible named-member
deltas, and source-discoverable canonical facts.
3. One cheap structured model call resolves the batch. It returns exactly one
success, mixed, or failure result for every digest and chooses one bounded
typed effect, including an explicit `no_material_change` when the attempt
cannot honestly alter canonical state.
4. Local validation recomputes every permission from the unchanged campaign
snapshot. It rejects invented owners, resources, relations, facts, pressure
resolutions, player mutation, missing outcomes, duplicate outcomes, and
conflicting effect targets. One same-snapshot correction is permitted.
5. `WorldKernel` requires the outcome-stage receipt bound to the complete
ordered digest set, then applies the plan and all outcomes to one private
campaign copy. A late invalid effect aborts the wave; no clock, activity,
outcome, detail debt, or world state reaches the committed campaign.
The public `strategic_activity_outcome.v1` contract is a typed sum. Its MVP
effects are deliberately small:
- create, spend, or give away a resource owned by the acting population or
named member;
- add or resolve pressure on the acting population or an exact targeted
population;
- shift one exact active agency relation incident to the source and a supplied
target by a bounded amount;
- add one memory, obligation, or relationship description to an exact named
member who owns that perspective;
- learn one exact canonical fact that was discoverable at the attempt location
or already known by an exact communication target;
- or record an explicit reason why no durable material change occurred.
The provider boundary uses a flat JSON proposal rather than asking the model to
emit the public tagged sum directly. The private flat shape avoids fragile
`oneOf` generation; a local interpreter is the only component allowed to turn
it into the typed public effect. JSON remains a model-provider boundary. The
accepted outcome, its binding, and its receipt are persisted through the
campaign's CultCache path.
Outcome resolution does not grant an arena collective authority. Every result
remains bound to the exact constituent proposal digest. A rival can suffer a
pressure or relation consequence, but cannot be made to speak, spend a
resource, reveal knowledge, or acquire a private relationship as though the
arena had chosen for it. Effects on the player are forbidden; accessible news
may later reveal a committed consequence, but the absent player is never
puppeted or directly harmed.
The resolver is batched once per strategic wave rather than once per cell or
attempt. This keeps the stable rules and schema in one cacheable prompt prefix,
lets it compare simultaneous opposition, and spends completion tokens only on
the handful of attempts that survived selection. Provider concurrency still
controls the preceding Persona-cell wave and does not alter outcome authority.
## Fairness and quiet-world agency
Every active profile carries persistent non-fictional `detail_debt`. Aggregate
representation increases debt; singleton or explicit detail focus clears it.
Each strategic wave reserves focus for the highest-debt subject. At budget 1,
the root arena retains the global situation while explicitly appraising that
subject. Deterministic clocks and obligations advance regardless of focus, and
every cell must emit explicit action or inaction.
With N subjects, deterministic debt rotation gives every subject direct
resolution attention within at most N strategic waves, absent a mandatory
foreground override.
## Persona-cell wave
Each selected cell runs one Ghostlight-owned
Projector → Persona → Interpreter membrane:
- cohesive Projectors expose genuinely shared lived state plus attributed
exceptions;
- arena Projectors expose attributed constituent slices and no synthetic actor;
- Personas receive only the private lived narrative stream;
- Interpreters emit a typed appraisal and exact constituent effects;
- malformed or semantically invalid output gets one same-snapshot correction.
An appraisal has two attributed lanes: `actions[]` for proposed transitions and
`inactions[]` for exact subjects that deliberately hold, wait, or continue an
already committed course. The old cell-wide inaction string is gone. This
matters in a mixed arena: one institution can act while two rivals hold without
the Interpreter manufacturing repeated posture transitions merely to preserve
their separately voiced decisions. Inaction IDs use the same constituent or
selected-member permission set, carry a bounded reason, cannot duplicate, and
cannot also appear in the action lane. They are audit/fairness evidence and
never mutate the world. A cell with no actions must still contain at least one
attributed inaction.
An arena may contain simultaneous views from distant locations. The Projector
names each subject's actual location and keeps those views as separate scenes;
a graph relation or activity target grants permission to attempt contact, not
fictional co-presence. The Persona preserves those spatial boundaries unless
the lived stream explicitly establishes an exact shared location or
communication channel. Compression therefore cannot turn a regional arena
into one impossible meeting room.
Perspective attribution is runtime-owned. The learned Projector emits bounded
`subject_id → narrative` segments, where the subject ID must be one exact
constituent or selected member exception from the permitted slice. Ghostlight
then lowers those private structured segments into one natural lived stream,
adding exact names and locations plus a deterministic scene-boundary block. The
Persona receives only that lowered prose. It never sees IDs or schemas.
- **Owner:** the permitted cell slice owns which subjects may have an internal
perspective; the Projector owns only the lived language inside one bound
segment.
- **Inputs:** exact cell constituents, selected member exceptions, perceived
events, locations, private state slices, and the shared snapshot binding.
- **Outputs:** one to the cell action limit unique, non-empty narrative
segments, lowered in stable order with runtime-owned perspective labels.
- **Derived state:** headings, scene labels, and the combined lived stream are
disposable projections and own no canonical fact or decision.
- **Forbidden writers:** Projector prose cannot add a perspective owner by
inventing a heading, voicing a mentioned outsider, or speaking as an arena.
- **Shared paths:** cohesive and arena cells use the same segment binding;
their mode changes narration guidance, never perspective authority.
- **Cut line:** raw freeform cell Projector output is no longer appended
directly to the Persona stream. Only runtime-bound segments cross the
membrane.
The deterministic block contains names and locations, not schemas or state
fields. A poetic omission therefore cannot erase geometry, and an invented
Markdown section cannot grant a subject agency before the Persona turn.
Perspective ownership is equally strict. Only the cell's supplied constituents
and selected member exceptions may receive an internal viewpoint or make a
choice. A named person mentioned by a perceived event but owned by another cell
remains an external observation. Personas may seek an unknown role or office,
but cannot conjure it into available contact; target-requiring effects need one
of the exact supplied subject IDs.
Physical provider concurrency batches these pipelines behind a separate
operator limit, default 8. Changing that limit increments only
`provider_configuration_epoch`; it preserves the selected cover, resolution
epoch, world revision, clocks, and fictional time.
All cell results must bind the same campaign revision and resolution epoch.
The kernel requires a Projector, Persona, and Interpreter receipt for every
cell, validates every proposal, resolves incompatibilities deterministically,
caps committed consequences at `min(16, 2 × effective_budget)`, and commits the
entire wave once. One missing appraisal, stale receipt, malformed stage,
invalid cover, or invalid effect aborts the wave without partial mutation.
Interpreter `intent` and `intended_effect` prose is proposal rationale only. It
never becomes canonical event narration. The kernel derives each committed
event summary from the exact admitted posture, pressure markers, route, or
membership transition after applying it. Strategic plans therefore carry no
model-authored summary field that can assert a consequence outside the typed
effect.
Recent events are projected per constituent. A cell receives an event only for
subjects that participated in it, occupy one of its locations, or possess one
of its public channels; arena streams retain those viewer attributions. There
is no global recent-event recap that can quietly teach every population the
same fact.
Interpreter prompts use a compact stable output contract plus an exact
per-subject permission table. The table names the only allowed effect kind,
state references, public channels, and destinations for each constituent or
member exception. Local validation repeats every slice-expressible constraint
before accepting the terminal appraisal, so a malformed pressure marker or
wrong subject/effect pairing receives the same-snapshot correction while the
kernel still performs the authoritative canonical check. Named-member
knowledge is never treated as an information channel; while dematerialized,
the member can publish only through channels owned by their current leaf
gestalt's agency profile.
`subject_state_references` is the single derivation path for canonical
constituent references used by both prompt slicing and wave admission. For a
Gestalt it includes reachable destinations produced by active migration
relations, along with the destination population and location references. The
scheduler does not append a second opinion after the fact, so a reference
shown to the Interpreter cannot become invalid merely by reaching the wave
resolver.
`subject_id` is the sole model-facing owner attribution. Nested effect owner
IDs are absent from the model contract: Ghostlight derives `institution_id`,
`gestalt_id`, `actor_id`, or the raw member ID during binding. The canonical
`CellActionProposal` still carries those IDs for downstream validation and
receipts, but a model cannot make two copies disagree. This also removes
repeated tokens and one class of correction calls from every actionful cell.
Institution rows distinguish `current_posture` from unresolved `pressures`.
The committed posture is projected as already true and is the exact baseline
against which a new institutional commitment is validated. It is never hidden
in a generic pressure list, where a Projector could mistake a completed choice
for a dilemma still awaiting action.
Institution posture proposals are bounded to 240 characters in both the stable
Interpreter contract and local JSON Schema, matching the canonical validator.
The model sees the limit before generation instead of spending a semantic retry
on an otherwise valid but overlong commitment.
A committed strategic effect must produce an exact canonical consequence.
Institutions cannot re-adopt their current posture. Population pressure effects
carry an explicit lifecycle: `pressure_resolutions` must name exact current
pressure markers, and `pressure_additions` must introduce genuinely new
unresolved constraints, threats, obligations, or conditions. Completed action
prose is not a pressure.
Populations may instead propose a bounded `gestalt_activity`: prepare,
coordinate, investigate, recruit, obstruct, trade, or communicate. Its source
is an exact active gestalt leaf; its targets must be adjacent canonical agency
subjects through an explicit active relation or exact shared location. A
dormant named person is addressable as `member:<id>` without becoming gestalt
state. Its locations and channels must belong to the source's permitted slice.
The effect commits only an attributed attempted-activity event. It does
not claim successful discovery, recruitment, persuasion, delivery, exchange,
obstruction, preparation, or target response. Event records carry exact
gestalt participant IDs so later demand and perception can attribute the
attempt without treating an arena as an actor. A later resolved outcome needs
its own admitted effect.
Two local attempts may omit a canonical target while retaining the source's
exact current location. Targetless `investigate` seeks facts from the
environment or an unnamed ordinary role. Targetless `communicate` records the
source speaking, offering, requesting permission, or notifying such a role.
Neither form creates a listener, reply, acceptance, discovery, or target
agency. All other target-bearing activity uses exact canonical subject IDs.
Activities may include incidental motion around unnamed local features while
remaining inside the source's canonical location. This does not create
topology or establish arrival. Only a clear commitment to a different supplied
canonical location or population destination requires a movement effect.
The shared validators run in the Interpreter membrane, wave resolver, and
kernel. One gestalt can contribute at most one selected pressure transition or
activity per tick; deterministic priority resolution cannot let two effect
forms bypass that limit.
Named member exceptions use the same activity vocabulary through
`member_activity`. The event is attributed to `member:<id>` and the current
leaf; the member's delta, the source Gestalt, and any target remain unchanged.
An arena cannot translate the person's attempt into a collective action, and
the destination population cannot claim the person's voice. `member_activity`
and `member_migration` resolve under one exact member key, so only the
higher-priority compatible choice can commit in a wave.
Canonical non-player actors use `actor_activity` for preparation,
coordination, investigation, recruitment, obstruction, trade, and
communication. The action is bound to the actor's exact current location,
state references, information channels, and graph-adjacent or co-located
targets. `actor_move` and `actor_activity` share one actor action key, so a
cell cannot make the same person travel and act again in the same wave.
Human-controlled actors are inadmissible in both paths. An arena may carry the
actor's attributed proposal but cannot translate it into population speech,
member activity, or collective authority.
The Interpreter maps natural attempts narrowly: speech, offers, requests, and
notices are communication; coordination requires an actual attempt to arrange
joint work; preparation is the source's own concrete work; investigation seeks
information; recruitment invites participation; trade offers exchange; and
obstruction attempts interference. `intended_effect` repeats the attempted act,
not the hoped-for outcome. This keeps the semantic verifier focused on agency
and consequence rather than guessing what a broad activity label meant.
Semantic correction feedback names the exact failed boundary and current
value. A repeated institution posture receives its present posture; an invalid
population transition receives its exact current pressure set and the failed
transition reason. The correction model is not expected to reconstruct a
validator from a generic rejection.
The WorldKernel resolves every strategic batch against one immutable campaign
snapshot. It applies validated transitions to a private working copy and swaps
that copy into the campaign only after the whole batch succeeds. Earlier
actions in the batch therefore cannot rewrite the spatial, relationship, or
knowledge permissions used by later actions. This matters when, for example, a
camp population signals refugees during the same strategic horizon in which
those refugees depart: both choices are judged from the shared starting world,
while the committed result still contains the migration. A late invalid action
discards the working copy and leaves the campaign untouched.
An actionful cell has a fourth private stage after the Interpreter: the
`cell_effect_verifier`. This cheap structured reader compares the natural
Persona decision with each attributed typed effect. It rejects reversals,
subject swaps, omissions, wishful outcomes, or lossy mappings—for example,
mapping “Mira gives her berth to somebody else” into a migration of Mira
herself. The rejected appraisal and concise rationale return to one
same-snapshot Interpreter correction. Inaction cells skip the call. The kernel
requires a valid verifier receipt for every actionful cell; an invalid or
missing verifier cannot satisfy the wave receipt gate. The verifier receipt's
snapshot binding includes a MessagePack content hash of the ordered action
bundle. The kernel recomputes it, so a verdict for another proposal at the same
world revision and cell cannot be replayed as authority.
Verification is per action, not a batch opinion. The model returns exactly one
ordered `action_index` and discriminated result for every candidate: `match`,
or `mismatch` with one enumerated mismatch class.
Ghostlight constrains the verdict count and index range in the stage schema,
rejects omissions, duplicates, reordering, and incoherent discriminators, and derives
the rejected set locally. This keeps one bad arena action from contaminating
independent constituents and prevents a global pass/fail field from
contradicting its own rejected-index list.
## Public state and controls
The schema catalog and CultCache stores publish:
- `agency_profile.v1`, `agency_relation.v1`, `gestalt_lineage.v1`;
- `resolution_policy.v1`, `resolution_pin.v1`, `resolution_demand.v1`;
- `simulation_cell.v1`, `resolution_cover.v1`,
`resolution_plan_receipt.v1`, and `resolution_control_receipt.v1`;
- `cell_appraisal.v1`, `cell_action_proposal.v1`, and the internal atomic
`resolution_wave_commit.v1` bundle;
- `strategic_activity_outcome.v1`, persisted individually for inspection and
also bound inside the atomic resolution wave;
- `gestalt_migration.v1` and `member_migration.v1` as distinct collective and
individual movement contracts inside strategic plans;
- `gestalt_fission_preview.v1` and approval-gated `FissionGestalt`.
The authenticated campaign laboratory exposes budget, effective overage, pins,
fission preview/approval, and a separate operator provider-parallelism control.
The Eve operator projection carries the agency graph, active cover, cell modes,
loss components, debt focus, pins, plan receipts, cell appraisals, and control
receipts. Browser and Eve surfaces use a composite interface version so
resolution-only and provider-only changes refresh without impersonating a world
revision.
## Verification
Property and golden tests cover budgets 1/4/8/32, complete unique connected
coverage, deterministic ties, mandatory overage, contradictory pins, fairness,
arena isolation, fission identity preservation, stale epochs, atomic waves, and
the 1,000-subject target. Pressure goldens preserve geography under blockade,
ideology under schism, workplace/economic role under strike, species/body and
transport under epidemic, and information boundaries under espionage.
The live Gestalt-dynamics harness has two modes. Its golden mode traverses
budgets 1 → 4 → 8 → 4 and proves independent population/member migration plus
automatic rematerialization of the same durable person. Its fairness mode keeps
the whole 24-subject setting in one arena for 24 total strategic ticks. It
requires every runtime-selected detail focus within the bound, rejects inactive
lineage parents as simulation subjects, and repeats the identity/callback check
after sustained compression. Both migration endpoints must be active leaves at
lineage depth two or greater.
Both modes distinguish selected attempts from resolved material consequences.
The harness counts direct typed transitions and non-`no_material_change`
activity outcomes separately, rejects a sustained run that never changes the
background world materially, and records every outcome in the private
preflight. A named member's dormant delta may now evolve offscreen, but any
change to memory, obligation, relationship, equipment, or knowledge must be
accounted for by an exact member-bound outcome before the return callback is
accepted.
Fairness acceptance treats a rejected provider wave the same way the daemon's
scheduler does: the failed pulse records its private trace and commits nothing;
a later pulse may try the same still-current revision again. The harness permits
a bounded, operator-selected number of these fresh pulses per desired committed
tick and reports every rejection in the final result. This does not expand the
single stage's one-correction rule or rebase stale output.
The exact-build `d044951` budget-8/provider-8 profile is the first complete live
proof of the attributed-inaction and immutable-batch machinery together. It ran
23 sustained waves over 24 active subjects in 742.435 seconds. Every subject
received direct resolution attention; 22 background subjects acted, while the
two without a committed action were represented by exact attributed inaction.
The wave plans selected 175 strategic effects: 85 population activities, 66
institution postures, 22 named-member activities, one population migration,
and one population-pressure transition. The appraisals also retained 179
attributed inactions, including 31 cells where some constituents acted while
others held. Ninety arena cells ran without an arena acquiring an actor ID or
collective voice. The eight-cell cover used only two partitions and preserved
the same boundaries across 21 of 22 wave transitions; its largest arena held
13 rivals.
The same run preserved Mira Venn's effective capabilities, knowledge,
equipment, conditions, obligations, memories, and relationship after her
durable delta crossed the hierarchy. The presence planner later selected her
from `harbor-neighbors` without the player naming her, and the kernel
materialized the same person beside the player. The player remained unchanged.
The private witness is
`F:\GameCult\GhostlightDungeon\acceptance\gestalt-lease-b8p8-d044951-1`.
The profile also names the next pressure point instead of laundering it into a
victory lap. Observed traffic used 1,988,812 prompt and 233,146 completion
tokens at a 66.64% prompt-cache hit rate. Nineteen rejected pulses consumed
807,304 prompt tokens—40.59% of observed prompt work. Eleven were caught by the
action-bound semantic verifier, three by Interpreter semantic validation, two
by JSON Schema, and three by transport failure. In that build, most population
effects were still attributed attempts recorded in event history rather than
resolved changes to resources, obligations, relationships, knowledge, or
pressures. The current candidate adds the separate strategic outcome resolver
described above without promoting the Interpreter into asserting success. A
revised live result is still required; the `d044951` profile is evidence for
cover, fairness, attribution, and atomic batch behavior, not evidence for
material activity outcomes.
The callback and fairness claims are independently falsifiable. Strict live
mode requires Mira's Persona to choose resettlement and proves the later
callback without forcing that choice. Fairness mode may instead branch from the
exact committed campaign inside that strict golden's result, then spend all 24
new provider-driven ticks on budget-one debt rotation. The baseline path and
SHA-256 are recorded in the result; no migration command or Persona decision is
fabricated during fairness setup.
A deterministic diaspora test drives two durable people from the same
depth-two refugee leaf to two different depth-two destination branches in one
strategic plan. It proves exact effective capabilities, knowledge, goals,
memories, relationships, possessions, conditions, and obligations survive the
rebases; destination populations receive none of that private state. Only the
source and destination population version counters change. Each person can then
materialize from their new local baseline with the same name and private delta.
### Live pressure results
The 24-tick budget-one fairness run `gestalt-dynamics-fairness-a4ea28b-1`
covered all 24 canonical subjects with 24 distinct debt-selected foci. Its
accepted appraisals contained 77 attributed proposals: one population
migration, nine institution posture changes, 15 named-member activities, and 52
Gestalt activities. Consequence capping committed 45 actions from 15 background
subjects. Four
bad scheduler pulses were rejected without revision movement; fresh pulses at
the same current state recovered. Mira retained her exact private and effective
state and was automatically rematerialized at the end. The run took 641.578
seconds. Its committed baseline is bound by SHA-256 in the result.
The 24-faction scale matrix produced:
| Configured cells | Effective cells | Arenas | Committed actions | Seconds | Prompt tokens/action |
| ---: | ---: | ---: | ---: | ---: | ---: |
| 1 | 1 | 1 | 2 | 18.913 | 11,734.5 |
| 4 | 4 | 3 | 8 | 15.619 | 4,682.1 |
| 8 | 8 | 7 | 16 | 26.300 | 3,546.6 |
| 32 | 24 | 0 | 16 | 55.906 | 7,410.5 |
Budget 4 is the latency sweet spot in this fixture. Budget 8 reaches the global
16-consequence cap with the best cognitive efficiency. Resolving every faction
individually doubles prompt cost and wall time relative to budget 8, adds no
committed consequences under the cap, and increases whole-wave tail-failure
risk. Budget 1 remains a valid emergency compression mode, not an efficient
default.
Repeated fairness prompts reached a 47.07% aggregate provider cache-hit ratio;
Projector reached 75.45% and Persona 61.57%. Interpreter remained the dominant
cost at 247,578 prompt tokens and only 34.54% cache hits. Prompt/context work
should therefore target Interpreter permissions and stable-prefix layout before
shrinking the Persona stream.
Resolution control is edge-triggered. `active_cell_budget` owns
`resolution_epoch`; provider parallelism owns only
`provider_configuration_epoch`. An unchanged control value is rejected without
persistence, epoch movement, or cover invalidation. Acceptance harnesses issue
either control only when its value actually differs, so repeated strategic
ticks preserve the previous cover, leases, and churn history. Stress runs may
select cell budget and provider parallelism independently.
A lease preserves a cut, not invalid topology. Before reusing a previous cover,
the partitioner revalidates every multi-subject cell against the current agency
graph. Migration, relation changes, fission, or activation can therefore break
a leased cell and force replanning immediately. Cover validation reports empty,
duplicate, disconnected, content-ID, and false-cohesion failures separately.
Fairness accounting uses the same definition as canonical detail debt: an
explicit aggregate `detail_focus_subject_id` and every singleton cell both
receive direct resolution attention. A named focus is not required for a
subject already receiving its own complete Persona pipeline.
The lease-preserving budget-4 profile committed all 23 requested sustained
waves and recovered from 18 rejected provider pulses without partial mutation.
It then failed its separate callback assertion because the presence planner
returned an empty cast. This isolated a player-experience fault from the agency
invariants: Mira remained the same dormant person in the correct destination,
but continuity was not surfaced. The focused `gestalt-callback-6253f9f-1` run
then passed: after one fresh 24-subject arena wave, the planner promoted
`mira-venn` from `harbor-neighbors` without the player naming her, and the
kernel rematerialized the same private and effective state. The full run took
18.465 seconds. Its strategic membrane used 18,662 prompt and 1,096 completion
tokens; the additional presence decision used 2,504 prompt and 78 completion
tokens.
Institution posture is projected as `already_committed_posture`. It is durable
state already in force, not a new pressure or decision. Projector, Persona, and
Interpreter contracts all treat continuing or restating it as explicit
inaction; only a materially different commitment may emit an institution
posture transition. Local validation remains strict rather than silently
dropping a no-op.
The 31-wave budget-8 / provider-parallelism-4 profile
`gestalt-lease-b8p4-6253f9f-1` passed after 1,211.592 seconds. All 24 subjects
received direct attention. Its sustained appraisals made 199 accepted proposals
from 22 distinct subjects; consequence resolution committed 51 institution
postures, 110 Gestalt activities, one population migration, and 31 named-member
activities, plus the initial arena consequence. Fifteen rejected pulses left
the world unchanged and later recovered. The same Mira callback passed after
the full run.
The profile used 1,468,039 strategic prompt tokens and 165,725 completion
tokens, reached a 60.69% cache-hit ratio, and spent 7,567 prompt tokens per
committed consequence. Interpreter remained the largest stage at 737,148
prompt tokens but reached 67.18% cache hits; Projector reached 70.03%. The
presence decision added 2,606 prompt and 78 completion tokens. Live traces
showed that the remaining repeated-posture failures occurred when several
constituents in one arena held while others acted, motivating the attributed
inaction lane rather than another prompt-only prohibition.
The first attributed-inaction/provider-parallelism-8 comparison committed nine
waves, then exhausted its bounded retries at wave 10. It proved the new lane in
mixed appraisals—across the first nine waves, 72 cells emitted 63 actions and 93
attributed inactions, including 18 action-plus-inaction appraisals—but also
exposed an overprojection failure. The Interpreter invented roster-completion
inactions for subjects absent from the Persona turn, overflowing the
four-perspective limit, and once wrote a reason beyond the 240-character schema
bound. The contract now admits inaction only for a subject that explicitly
holds or waits in the Persona turn, forbids absence-as-inaction, and asks for a
reason of at most 160 characters while retaining the 240-character hard bound.
The strategic outcome resolver closes the attempt/result authority gap.
Institution postures, population migration, clocks, and history remain kernel
state; selected Gestalt activities are digest-bound attempts. One batched
resolver receives only those attempts and deterministic admissible consequence
handles, then returns one typed result per attempt. The Interpreter cannot
declare its own hoped-for outcome.
Local validation owns ordinary bounded preparations, pressure changes, and
discoverable knowledge. Effects with easy-to-hide private authority mistakes—
resource consumption or transfer, agency-relation shifts, and named-member
memory, obligation, or relationship deltas—also pass through an independent
same-snapshot semantic verifier. Its verdict is advisory to the resolver retry,
never a writer. Applying that verifier to every harmless preparation was both
expensive and over-strict, so the risk boundary is deliberately selective.
The current provider-backed golden completed four waves across budgets
`1 -> 4 -> 8 -> 4` in 81.28 seconds in a debug build. It produced ten durable
consequences and seven typed material activity outcomes, with three explicit
no-material-change results, zero rejected pulses, exact member attribution,
the player unchanged, and the same Mira returning with her persistent delta.
It used 126,826 prompt tokens, 14,477 completion tokens, and a 58.34% prompt
cache-hit ratio. This proves the ordinary material-outcome path live. The
selective high-risk verifier was first covered locally, then forced through the
provider boundary below.
That witness now exists at
`F:\GameCult\GhostlightDungeon\acceptance\forced-high-risk-outcome-contract-fixed-20260818`.
Dock Labor Guild explicitly transferred its exact `west winch rerouting plan`
to the colocated Harbor Neighbors. The Flash resolver emitted the exact custody
transition, the independent verifier accepted it against the same snapshot,
and the canonical input remained byte-identical. The first witness also caught
a prompt/schema naming mismatch: prose said `resource_recipient_id` while the
schema requires `other_subject_id`. Naming the input-to-output mapping directly
removed the correction call; the accepted rerun used exactly two first-attempt-
valid provider calls in 2.95 seconds.
The Starfire deployment smoke ran a live provider-backed wave over three
eligible singleton cells, committed three offscreen events and three
channel-accessible news items in 19.55 seconds, and preserved the absent player.
That fixture proves deployed cell isolation and atomic admission. The
20-plus-faction budget matrix, not the singleton smoke, proves aggregation.

View file

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

@ -0,0 +1,616 @@
# Ghostlight World Transition Algebra
Status: teardown evidence. This describes the pre-rebuild vocabulary — string
`SubjectRef` identifiers, two-phase `AdmitEntity`, flat permitted-mutation
batches, strategic cells, Gestalt fission, and compiler publication — all of
which are on the deletion line in
`docs/architecture/ghostlight-dungeon-mvp.md`. The current closed vocabulary is
`docs/architecture/ghostlight-world-ontology.md`. Read this file for why the old
shape failed, not for what to build.
The one principle carried forward: different admission lanes share one mutation
vocabulary. A player and an institution transferring the same medicine use the
same custody mutation.
## Objective
Ghostlight needs one physical world. A player action, an NPC reaction, a
strategic institution, and a low-resolution Gestalt cell may use different
admission and resolution procedures, but a successful outcome must change the
same canonical components through the same kernel-owned commit primitive.
The transition algebra exists so that:
- freeform action remains possible without model-authored structural writes;
- impossible actions are absent from the available operation space;
- every committed change has exact subjects, authority, provenance, and
version bindings;
- foreground and background simulation cannot acquire asymmetric powers;
- new play scenarios compose existing semantic mutations instead of growing a
bespoke effect enum for every story beat.
This design adapts the useful core of action-language and planning literature:
states are typed assignments, actions have explicit applicability conditions,
and transitions have bounded effects. It does not adopt PDDL, STRIPS, or a
general logic-programming runtime as Ghostlight's public ontology. BC+ provides
the causal-law distinction, STRIPS and PDDL provide the precondition/effect
discipline, and REBA provides the important reminder that coarse and fine
simulation must refine the same transition system rather than inventing two
worlds. See [BC+](https://academic.oup.com/logcom/article/30/4/899/2917837),
[PDDL2.1](https://arxiv.org/abs/1106.4561), and
[REBA](https://arxiv.org/abs/1508.03891).
## Authority map
### Owner
The per-campaign `WorldKernel` owns one `WorldMutationBatch` validation and
commit primitive. It is the only subsystem allowed to change canonical world
components or advance fictional time.
### Inputs
The owner may read:
- the exact canonical campaign revision;
- the exact resolution epoch when a strategic wave is involved;
- typed subjects and their component versions;
- an accepted action, reaction, lifecycle, governance, or strategic-outcome
receipt;
- a digest-bound `MutationAuthorityEnvelope` compiled from current state;
- a proposed batch containing only semantic mutations named by that envelope;
- exact evidence, permission, roll, and model-stage receipts required by the
originating procedure.
### Outputs
One successful commit produces, atomically:
- the next canonical component state;
- append-only world events describing the observable means and outcome;
- a private exact `world_mutation_receipt.v1`;
- the command or strategic receipt that caused the transition;
- any knowledge-channel-aware news inputs derivable from committed events.
Eve, CultMesh, story, and news projections refresh only after the commit.
### Derived and demoted state
- `WorldEffectDelta`, `ActorStateDelta`, `StrategicCellEffect`, and
`StrategicOutcomeEffect` are requirements evidence and migration inputs. They
are no longer allowed to own a production write after their resolver moves.
- `StrategicActivityKind` describes proposed means. It is not a mutation.
- An `ActionIntent` and its intended effect are proposals. They are not facts
and do not become true because a model narrated them.
- Simulation cells, covers, leases, salience, and detail debt choose inference
resolution. They are not fictional world components.
- The transcript contains exact committed speech and outcome prose. Eve lowers
it chronologically without a generative rewrite; display text cannot repair
or create state.
- Actor equipment lists, institution resource lists, and Gestalt resource sets
become projections over resource subjects plus custody. A resource does not
change ontology when its owner changes.
- A dormant named Gestalt member and its materialized foreground form are one
actor subject. Materialization changes simulation resolution, not identity or
physical existence.
### Forbidden writers
The migration is not complete while any of these can decide canonical state:
- `apply_world_effect`;
- `apply_strategic_outcome_effect`;
- the direct private-delta loop in `ResolveReactionWave`;
- bespoke actor, Gestalt, or member movement and migration writers;
- any Gestalt folding code that unions private knowledge, possessions, or
pressure into a population;
- model output, browser payloads, story rendering, news generation, retrieval, or a
derived simulation cell.
Bounded region expansion and in-place locality elaboration lower to the same
batch primitive through typed place, proposition, population, and institution
admission plus exact topology and political-relation changes. Locality
elaboration owns only additions beneath one exact canonical place; it cannot
replace that place or bypass approval. Either path may possess a broader
administrative authority envelope, but not another state-writing mechanism.
Approval-gated population fission likewise lowers to entity
admission, lineage, custody, and membership mutations; the fission projector
owns only the derived resolution profiles and cover epoch. Initial compiler
publication is not a transition over an existing world; its separate creation
authority is bounded below.
### Shared paths
All of these lower to `WorldMutationBatch` before state changes:
- player `Attempt` confirmation;
- NPC initiative resolution;
- actor reactions and private appraisal consequences;
- strategic individual, institution, cohesive Gestalt, and arena-constituent
outcomes;
- travel, waits, and strategic time advancement;
- resource production, use, damage, repair, and exchange;
- named-person joining, leaving, and migrating between populations;
- population migration and approval-gated fission;
- contract amendments that are permitted to change forward-looking world
state;
- compiler-admitted entities added after publication, bounded region expansion,
and in-place locality elaboration.
### Cut line
The new validator applies a whole batch to an isolated transactional overlay,
checks local and cross-mutation invariants, and returns either one complete next
state or an error. Resolvers then emit the new mutation vocabulary directly.
When the last production caller moves, the old committing functions and their
model schemas are deleted. Persisted legacy receipts remain readable history;
they never re-enter a commit path.
No repair loop, converter with independent policy, or post-commit reconciler is
allowed to preserve a second opinion about the result.
## Transactions outside the mutation algebra
Two operations change canonical storage without claiming to be fictional
world effects. They remain narrow because forcing them into `WorldMutation`
would lie about what happened.
### Initial campaign publication
There is no prior campaign state to mutate at revision zero. The
`CampaignRegistry` therefore owns discoverability of an approved seed, while
`CampaignStore` owns its atomic installation:
- input is one validated `approved_campaign_brief.v1`, its exact evidence and
model receipts, membership, governance, DM state, and approval digest;
- every row is written to a fresh `.creating-<campaign>-<nonce>` store through
one empty-store compare-and-swap batch;
- the staging directory becomes discoverable only through one atomic rename;
- retry is idempotent by campaign ID plus approved seed digest;
- a nonempty store, conflicting digest, failed row write, or failed rename
publishes nothing.
The seed installer may establish initial subjects and components because no
world exists yet. It cannot target a published campaign, advance fictional
time, or serve imports, reloads, expansion, or later compiler output. The
legacy internal `CreateCampaign` message is initialization plumbing, not a
runtime action authority; public player ingress rejects it. Once the directory
is published, every fictional change belongs to the normal kernel path.
### Named-person materialisation and folding
A dormant `GestaltMemberDelta` and its foreground `ActorState` are two
resolutions of one persistent person. `WorldKernel` owns the resolution
transaction at a safe command boundary:
- materialisation derives the foreground actor projection from the Gestalt
baseline plus the exact member delta;
- folding writes changed individual capabilities, knowledge, equipment,
conditions, obligations, relationships, goals, memories, and location back
to that member delta, then retires only the foreground projection;
- the transaction advances world revision so stale actor-bound commands and
surfaces fail, advances `resolution_epoch`, and clears the old derived cover;
- it does not advance fictional time, relocate the person, transfer custody,
communicate knowledge, change population pressure, or alter the Gestalt
baseline;
- one atomic world receipt plus materialisation receipt binds the member,
projection actor, baseline/member versions, world revision, and resolution
epoch.
The removed `GestaltAggregateDelta` was an obsolete writer: direct demotion
could previously smuggle private knowledge, resources, and pressures into the
population while automatic demotion rejected the same payload. Those changes
now require explicit semantic mutations under their own authority. Presence
resolution has no vocabulary with which to perform them.
## Canonical state model
### Subjects
Every durable entity has a stable typed `SubjectRef`:
| Subject kind | Examples | Important rule |
| --- | --- | --- |
| `actor` | player, NPC, named refugee | Identity survives foreground materialization and Gestalt folding. |
| `population` | villagers, refugee convoy, corporation workforce | May own a cohesive Gestalt Persona baseline; never owns member secrets by implication. |
| `institution` | Zhestokost command, a clinic, a guild | Collective authority is explicit, not inferred from proximity. |
| `place` | room, settlement, route node, region | Persists independently of current observation. |
| `resource` | a key, medicine lot, ship, credits tranche | Has exact custody and resource state; it is not a string copied between owner lists. |
| `pressure` | blockade clock, debt deadline, epidemic load | Carries bounded progress and consequence semantics. |
| `proposition` | a source-grounded fact, a witnessed claim, a branch-local finding | Knowledge points to the proposition; prose duplication is not knowledge authority. |
| `channel` | courier route, broadcast, private conversation | Governs who can receive information without making it public. |
Campaign time is a component of the campaign root. Relationships and topology
edges are keyed component records between subjects, not synthetic actors.
IDs are opaque and stable. Prefixes may remain a debugging convention, but no
validator infers subject kind or authority from string shape.
### Components
The target store is component-oriented even when a compatibility migration
temporarily reads old aggregate rows:
- `identity`: canonical identity plus adopted handles and their disclosure
state;
- `occupancy`: exact place anchors and travel state;
- `custody`: exact custodian of a resource or controlled subject;
- `resource_state`: type, quantity, integrity, qualities, and lifecycle;
- `capability`: supported abilities and their limits;
- `condition`: bodily, material, social, or environmental state;
- `knowledge`: proposition, epistemic status, source, channel, and revision;
- `memory`: private episodic record and provenance;
- `relationship`: directed subject-to-subject state;
- `commitment`: goals and obligations, including creditor or beneficiary;
- `pressure`: bounded progress, threshold, consequence, and status;
- `posture`: an agentive subject's current strategic stance;
- `population_membership`: exact member, population, role, and interval;
- `population_lineage`: refinement, fission, merge, and remainder ancestry;
- `topology`: containment and route edges with travel cost and availability;
- `lifecycle`: admitted, active, transformed, consumed, retired;
- `world_time`: the campaign's monotonic fictional coordinate.
Components are sparse. A place does not need knowledge; a proposition does not
need occupancy. Component applicability is determined by subject kind and the
specific admitted entity, not by a universal bag of optional fields.
Route keys are scoped to their owning origin location. Canonical route identity
is therefore the pair `(origin_location_id, local_route_id)`, never the bare
map key. The transition overlay encodes that pair into a collision-free edge
identity before validation and maps accepted edges back to the original local
keys. Surface projections use each route's exact `destination_id`; they do not
reinterpret a local route key as a destination or global edge ID.
## The mutation vocabulary
`WorldMutation` is a closed tagged union. Each variant owns one semantic
component family and names the invariants that family can enforce. There is no
`SetField`, arbitrary path, embedded patch, or model-authored component name.
| Mutation | Canonical effect | Essential validation |
| --- | --- | --- |
| `Relocate` | Changes occupancy for an exact subject. | Origin matches, route/reach exists, mover has authority, group custody and travel approval hold. |
| `TransferCustody` | Moves an exact resource between custodians. | Source has custody, recipient exists and can receive it, quantity/identity is conserved. |
| `MutateResource` | Creates, transforms, consumes, damages, repairs, splits, or combines exact resources. | Recipe/capability, custody, conservation, lifecycle, quantity, and effect ceiling hold. |
| `ChangeCapability` | Grants, alters, suspends, or retires a supported capability. | Source of capability and duration are admitted; no narration-only expertise. |
| `ChangeCondition` | Applies, alters, or clears a condition. | Target is reachable, condition is compatible, opposition and outcome band permit it. |
| `ChangeCommitment` | Creates, alters, fulfills, defaults, or retires a goal or obligation. | Exact owner, counterparty, consent or coercive authority, and lifecycle hold. |
| `ChangeRelationship` | Creates, alters, or retires a directed relationship component. | Exact endpoints exist; one actor cannot author another's private appraisal without its own reaction authority. |
| `ChangePressure` | Creates, advances, reduces, resolves, or retires a bounded pressure. | Owner, bounds, current value, threshold, and consequence are exact. |
| `ChangeKnowledge` | Acquires, communicates, conceals, corrects, or invalidates a proposition for exact knowers. | Source knows or can observe it, channel and recipients are exact, concealment does not erase another mind. |
| `ChangeMemory` | Records, revises, or retires an exact private episodic memory. | Witness or authorized private appraisal exists; another actor cannot write it. |
| `ChangePosture` | Alters the posture of an exact agentive subject. | The acting authority can speak or decide for that subject; arenas cannot use it collectively. |
| `ChangePopulationMembership` | Joins, leaves, or transfers an exact actor between populations. | Member identity is stable, source membership exists, destination is compatible, no duplicate coverage. |
| `ChangePopulationLineage` | Records an admitted split, merge, or remainder relation. | Fission preview and approval exist; leaves remain non-overlapping and complete. |
| `ChangeIdentity` | Adopts, discloses, restricts, or retires an exact identity handle. | Self-authority or explicit custody exists; disclosure is separate from renaming. |
| `ChangeTopology` | Adds, alters, opens, closes, or retires a containment or route edge. | Evidence/branch admission, endpoint existence, reciprocal rules, and geometry invariants hold. |
| `CivicSystemSet` | Publishes the complete versioned civic apparatus for one jurisdiction. | The jurisdiction, institutions, resident populations, public civic propositions, political relations, version, and exact semantic-verifier receipt all exist in the accepted component overlay. |
| `AdmitEntity` | Creates an explicitly permitted non-resource subject and initial components. | Admission class, evidence or branch-local authority, collision freedom, and required components hold. |
| `RetireEntity` | Retires an exact subject without erasing history. | No dangling custody, occupancy, membership, topology, or active obligation remains. |
| `AdvanceWorldTime` | Advances campaign time by an exact duration. | Monotonicity, governance, no-puppeting, tick budget, and scheduled obligations hold. |
`NoMaterialChange` is not a mutation. It is an outcome with an empty mutation
batch and an explicit explanation. Speech is an event in the means record;
persuasion, obligation, relationship, and knowledge consequences are separate
mutations.
Governed time, travel, and resolution-budget proposals persist approvals before
their unanimous finalization. A fully approved but uncommitted proposal is a
retryable commit request: any already-approved active member may invoke its
advertised approval operation again, and the kernel revalidates the exact world
and governance boundary before attempting the same atomic finalization. A
duplicate vote on a proposal that is not unanimous is still rejected, and a
committed or stale proposal can never replay.
Complex effects are batches, not new verbs. Giving half a medicine lot to a
clinic composes resource split, custody transfer, and perhaps an obligation.
Evacuating a named refugee composes relocation, population-membership transfer,
and accessible knowledge. Population fission composes entity admission,
lineage, exact member transfers, and one custody transfer per scarce resource
while preserving an `other/unknown` remainder. Capabilities, shared knowledge,
goals, and active pressures inherit through the lineage reducer. Resources do
not: every parent resource must be assigned to exactly one child in the
approved preview, so aggregation cannot mint a granary for every ideological
subgroup.
### Within-horizon course ordering
A strategic choice is one atomic endpoint transaction, but its observable
means may straddle one admitted relocation. `WorldKernel` derives the only
within-horizon ordering from exact occupancy and effect locations:
1. activities at the subject's snapshot location occur before relocation;
2. the admitted relocation or population migration occurs next;
3. activities at the exact admitted destination occur after arrival.
Distinct activity kinds inside one location phase remain an unordered atomic
set. Activities spanning several locations without one unambiguous phase also
remain simultaneous at the strategic resolution of that subject. The model
does not emit phase labels, sequence numbers, or prose-authored timing; effect
array and object field order are not authority. An unrelated activity location
still fails exact spatial validation.
This derived order owns chronological event lowering only. The mutation batch
still owns one indivisible final state, and every permit is compiled from the
same pre-wave snapshot. A failed phase, outcome, or mutation therefore aborts
the complete wave rather than leaving a subject halfway through its course.
The old blanket assumption that every movement precedes every activity is not
an owner and must not survive in Projector, Interpreter, verifier, or kernel
guidance.
Foreground assessment uses the same authority-shaped contract principle. Its
model schema contains only mutation lanes available in the current snapshot:
no route means no movement lane, no accessible undisclosed proposition means
no existing-fact acquisition lane, and absent clocks or institutions remove
their lanes. A separate observation lane is always structurally available to
the acting actor but is semantically admitted only when the exact local means
can directly perceive, measure, inspect, or test the proposed result. It lowers
to one branch-local proposition admission followed by exact knowledge
acquisition in the same batch. The effect ceiling, semantic verifier, current
location, acting actor, assessment receipt, and means digest bind that finding;
the lane cannot author remote events, hidden motives, unsupported identities,
or omniscient conclusions. Empty or unused maps may be omitted and deserialize
to no mutation. The closed dynamic schema rejects an unavailable lane even when
the model tries to emit it as `null`; provider formatting cannot manufacture
authority.
## Means, intended effect, and committed mutation
### Means
`ActionMeans` records what is attempted and may become observable:
- exact acting subject;
- bounded natural-language method;
- exact targets, instruments, resources, places, routes, and channels cited
from the actor's permitted slice;
- explicit speech, if any;
- state and evidence references supporting the method.
The means taxonomy is intentionally open at the prose level. Ghostlight does
not need a rulebook verb for every clever action. The exact typed references
make reach, custody, knowledge, and capability testable.
### Intended effect
`MutationIntent` names desired component families, targets, and qualitative
direction. It is useful for assessment and cognitive focus, but it is not an
assertion that the change occurred. A player may intend to persuade a guard,
destroy a reactor, learn a route, or move a crowd without receiving any of
those mutations.
### Committed mutation
The admitted outcome procedure emits a concrete `WorldMutationBatch` bounded
by its authority envelope. The batch is the only one of the three objects that
can become state. A roll changes which digest-bound outcome plan is eligible;
it does not allow the resolver to invent a stronger mutation afterward.
For Sable's self-presentation:
- means: the refugee speaks an exact self-presentation to present listeners;
- intended effect: those listeners may address this person using `Sable`;
- committed mutations: `ChangeIdentity::AdoptHandle` only if the speaker is
choosing a new handle, followed by `ChangeIdentity::DiscloseHandle` or
`ChangeKnowledge::Communicate` to exact listeners;
- derived projection: each listener's Eve surface displays the best identity
handle that listener actually knows.
The speech never authorizes a global actor rename, and a display label never
becomes identity authority.
## Mutation authority envelopes
An admission policy compiles `mutation_authority_envelope.v1` from the current
snapshot. It contains exact permits, not broad roles:
- campaign revision and relevant component versions;
- originating subject and resolution procedure;
- permitted mutation kind and operation;
- exact subjects, routes, resources, propositions, channels, and relationships
available to that operation;
- numeric and cardinality bounds;
- extraordinary-permission or approval receipts;
- effect ceiling and outcome-band binding;
- expiry and digest.
The envelope is capability-like but not transferable. It can authorize one
assessment outcome, one reaction appraisal, one strategic action, or one
administrative command. It cannot be rebased onto a new revision.
### Action-specific model schemas
The model never receives the complete world mutation schema. Ghostlight first
selects the component families relevant to the intended effect, then generates
a compact schema from the exact permits:
- if no resource is transferable, no custody-transfer branch exists;
- if the actor cannot reach a target, that target does not appear;
- if the actor knows no communicable proposition, communication is absent;
- if an arena has no collective actor, collective posture and speech are
absent;
- if the outcome cannot create an entity, admission is absent;
- scalar fields carry local limits rather than universal ranges.
Each generated branch carries an opaque permit ID. The kernel resolves that ID
back to the exact permit and revalidates it against the snapshot. Stable
instructions and mutation definitions precede the dynamic permit table so
provider caching remains useful; the dynamic prompt contains only the state
needed for this decision.
Schema generation improves model ergonomics. It does not replace local
validation, and schema presence is not authority by itself.
## Validation and atomic commit
The commit primitive performs this sequence:
1. Verify campaign revision, resolution epoch when present, originating receipt,
envelope digest, expiry, and idempotency.
2. Resolve every subject and component against the same snapshot.
3. Verify every mutation has a matching unused permit and remains inside its
exact IDs, operations, bounds, and effect ceiling.
4. Validate local semantics: reach, custody, knowledge provenance, authority,
component applicability, and lifecycle.
5. Validate batch semantics: no contradictory writes, no resource duplication,
no dangling entity retirement, no duplicate population membership, and no
hidden cross-actor private writes.
6. Apply the entire batch to an isolated in-memory overlay.
7. Re-run global topology, custody, population coverage, player-control,
knowledge, and temporal invariants on the overlay.
8. Persist component rows, events, mutation receipt, command receipt, and
required private proof records in one CultCache transaction.
9. Publish refreshed Eve/CultMesh projections after the transaction commits.
Any error returns the original state byte-for-byte. A malformed model output
gets one same-snapshot correction and then aborts. No valid sibling mutation
survives an invalid batch member.
## Admission policies are separate from physics
The shared algebra does not make every actor equally powerful. Each lane has a
different authority compiler:
- foreground fiction-first d20 assessment considers declared means, local
affordances, opposition, permissions, stakes, and effect ceiling;
- reaction appraisal can write only the reacting subject's private components
plus exact speech/action proposals;
- NPC initiative uses the same foreground assessment after initiative chooses
an eligible proposal;
- strategic cells use horizon, graph reach, institutional authority, resources,
and resolution-cell attribution;
- cohesive Gestalts may receive collective permits only from real collective
authority;
- arena cells receive constituent permits and cannot emit one arena-owned
mutation;
- compiler and approval commands may admit entities or topology only through
explicit evidence and branch-local gap policy.
Different admission does not imply different mutations. A player and an
institution transferring the same medicine use the same custody mutation.
## Migration from the empirical vocabularies
The accumulated variants are test requirements, not discarded knowledge:
| Existing behavior | Algebraic lowering |
| --- | --- |
| actor move / member migration / Gestalt migration | `Relocate` plus membership change when population affiliation changes |
| actor equipment / institution resources / Gestalt resources / member equipment | resource subjects plus `TransferCustody` and `MutateResource` |
| actor or member condition | `ChangeCondition` |
| actor goal / member obligation | `ChangeCommitment` |
| actor or member relationship | `ChangeRelationship` |
| world clock and Gestalt pressure | pressure subjects plus `ChangePressure` |
| actor knowledge / Gestalt shared knowledge / member knowledge | `ChangeKnowledge` with exact proposition and knower |
| member memory / reaction memory | `ChangeMemory` |
| institution posture / strategic posture | `ChangePosture` |
| Gestalt promotion and folding | resolution change over one stable actor plus explicit component mutations only |
Migration order:
1. Publish types, component applicability, permits, batch validation, receipts,
and property tests without changing live state.
2. Lower foreground assessment and NPC resolution directly to mutation batches;
delete `apply_world_effect`.
3. Lower reaction Interpreters to actor-private permits and mutation batches;
delete direct `ActorStateDelta` writes.
4. Lower strategic outcome generation to the same operations; delete
`apply_strategic_outcome_effect` and string-resource helpers.
5. Classify named-person materialisation as a resolution transaction, remove
aggregate mutation from folding, and invalidate the active cover. Complete.
Normalizing remaining legacy member fields into component rows remains.
6. Lower travel, waits, fission, and bounded expansion. Classify initial
publication as an empty-store creation transaction rather than a world
mutation. Complete.
7. Migrate persisted campaign rows transactionally, quarantine ambiguous legacy
resources or identities, and make the old campaign fields projection-only.
8. Remove legacy schemas from model boundaries. Retain schema readers solely
for historical receipt inspection.
At every stage, the old and new writers cannot both be active for the migrated
lane.
## Agency corpus
Ghostlight does not declare the algebra complete from enum aesthetics. It owns
an inspectable `agency_attempt_case.v1` corpus compiled into a MessagePack
CultCache store. JSON Schema publishes the record shape; authored source may be
reviewed Markdown, but runtime and test fixtures load `.cc`.
Each case records:
- domain and scenario;
- minimal world fixture and exact subjects;
- means and intended effect;
- expected admission: admissible, impossible, or bargain-required;
- required mutation composition by outcome band;
- forbidden mutations and invariant witnesses;
- expected bargains for missing permission or affordance;
- whether foreground, NPC, strategic, and Gestalt resolutions should be
physically equivalent;
- reviewer status and any genuinely missing primitive.
The initial target is at least 300 reviewed attempts: 25 each across social,
physical, investigative, economic, political, technological, extraordinary or
magical, collective, identity and privacy, population and migration,
infrastructure and logistics, and bodily or medical play. Cases include both
ordinary and adversarial attempts, partial successes, indirect means, consent
boundaries, rival actors inside arenas, and low-resolution background outcomes.
Completeness gates:
- every case maps compositionally, yields a comprehensible bargain, or names a
genuinely missing primitive;
- no case falls through to a generic patch or arbitrary component write;
- the same physical change uses the same mutation in foreground, NPC, member,
population, institution, and arena-constituent contexts;
- property tests prove atomicity, deterministic permit/schema generation,
resource conservation, stable identity, exact knowledge ownership, population
coverage, topology integrity, and no puppeting;
- the final 100 newly reviewed admissible cases require no new primitive. This
is a stability signal, not a mathematical proof of completeness.
The Sable self-presentation and later rematerialization is a mandatory first
case. It must prove observer-relative identity projection without a global
rename or loss across Gestalt folding.
## Public contracts
Add:
- `typed_subject.v1`
- `world_component_ref.v1`
- `action_means.v1`
- `mutation_intent.v1`
- `mutation_permit.v1`
- `mutation_authority_envelope.v1`
- `world_mutation.v1`
- `world_mutation_batch.v1`
- `world_mutation_receipt.v1`
- `agency_attempt_case.v1`
JSON Schema is the catalog boundary. Canonical component rows, envelopes,
batches, receipts, and corpus fixtures use MessagePack-backed CultCache `.cc`.
JSON remains acceptable only at browser, MCP, and model-provider boundaries.
## Acceptance
The algebra cut is complete only when:
- no production path calls a legacy effect writer;
- every effectful command persists an exact mutation receipt;
- malformed, stale, over-broad, contradictory, or partially valid batches leave
state, fictional time, and projections unchanged;
- background and foreground versions of the same action pass the same component
invariants;
- arena rivals cannot speak or mutate as one subject and cannot acquire one
another's knowledge;
- resource custody and quantity are conserved across transfers and failures;
- named people remain the same subjects through Gestalt merge, split, migration,
folding, and rematerialization;
- Sable's disclosed handle appears only to entitled observers and survives the
complete round trip;
- action-specific schemas omit impossible operations before inference;
- the agency corpus meets its coverage and stability gates;
- adversarial play through the typed Eve surface still proves the homepage
promises after migration.

View file

@ -16,7 +16,7 @@ profile-side binding).
## Authority map
- **Admission owner:** `require_patch_author` (`crates/ghostlight/src/lib.rs`) decides which
- **Admission owner:** `require_patch_author` (`world/mod.rs`) decides which
caller may submit `AdmitPatch` and returns that caller's `PatchGround`;
`confine_to_ground` decides what a confined ground may write. Both run in
`reduce`'s `AdmitPatch` arm and again in `apply_effect`'s `PatchAdmitted`
@ -28,14 +28,14 @@ profile-side binding).
read from committed state at decision time. A consumer cannot name its own
scope on the wire, and revoking a binding in a later commit shrinks its
authority with no ingress change.
- **Caller minting:** `WorldMailbox::submit_consumer` (`crates/ghostlight/src/mailbox.rs`) is
- **Caller minting:** `WorldMailbox::submit_consumer` (`world/mailbox.rs`) is
the only constructor of `AuthenticatedCaller::verified_system(SystemCapability::Consumer { consumer })`.
- **Ingress owner:** `crates/ghostlight/src/consumer.rs` owns decoding a document, bounding
- **Ingress owner:** `world/consumer.rs` owns decoding a document, bounding
it, authenticating the consumer, one port call, and one receipt
projection. It owns no world truth, holds no opinion about a patch's
content, and does not pre-validate — a second reducer here could disagree
with the kernel's, so structure is decided once, by `resolve_patch`.
- **Port:** `ConsumerPort` (`crates/ghostlight/src/mailbox.rs`) has one method,
- **Port:** `ConsumerPort` (`world/mailbox.rs`) has one method,
`submit_consumer`. It cannot read the world: it does not select an answer,
does not pre-validate, and returns a receipt rather than state.
@ -63,16 +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
body to `ghostlight::admit_document`; everything past those two gates is
`consumer.rs`'s to decide. The body limit is
body to `world::consumer::admit_document`; everything past those two gates is
`world/consumer.rs`'s to decide. The body limit is
`CONSUMER_BODY_LIMIT = patch::MAX_PATCH_BYTES + CONSUMER_ENVELOPE_SLACK` (an
8 KiB envelope allowance over the 256 KiB patch cap), so the transport guard
cannot become a second opinion about how big a patch may be.
@ -148,12 +143,8 @@ those subjects (`confine_to_ground`, the `PatchGround::Consumer` arm):
place, never the acting subject, and a consumer's ground names no place.
A consumer tells what its mirror knows through `AcquireKnowledge` on its own
bound subject; it cannot cause a place-wide witnessed event;
- `Retire`, `GrantAffordance`, `RevokeAffordance`, and `Mint` are always
refused (`ResolvedOp`s that are authority over standing, not structure over
ground);
- a `Claimed { by }` fact must name a bound subject; a `Ruled` fact is refused
outright, and so is a `Canonical` fact
declaration — a consumer's evidence buys an `Admit`
- a `Claimed { by }` fact must name a bound subject; a `Canonical` fact
declaration is refused outright — a consumer's evidence buys an `Admit`
into its own custody and a `Claimed` fact, never a canonization;
- a channel's reach and controller must all be bound subjects;
- every operation's ground subjects must be bound, and its place and route
@ -189,9 +180,9 @@ enters a log, a receipt, or the journal.
The consumer capability's tag is part of the commit digest and the
externally controlled assignment is part of the state shape, so both bump the
schema. World lens weights later entered the state and `SetLensWeights`
the commit, so the live schemas are state `ghostlight.world_state.consumer.v6`
the commit, so the live schemas are state `ghostlight.world_state.consumer.v5`
(state-schema generation `world-v3`) and commit
`ghostlight.world_commit.consumer.v6`. A store written under an earlier schema
`ghostlight.world_commit.consumer.v5`. A store written under an earlier schema
is refused, not migrated.
## Not in this pass

View file

@ -9,9 +9,10 @@ it owns who may write, what the prime invariants are, and where the boundaries
sit. This document owns what the vocabulary *is* and what the reducer checks.
It may not introduce a writer, a semantic gate, or a second commit path.
The pre-rebuild transition-algebra and multiresolution-agency designs were torn
down, and their pages are gone. Their surviving ideas are named where they are
used: one mutation vocabulary across every admission
`docs/architecture/ghostlight-transition-algebra.md` and
`docs/architecture/ghostlight-multiresolution-agency.md` describe the
pre-rebuild machine and are teardown evidence. Their surviving ideas are named
where they are used: one mutation vocabulary across every admission
lane; compact mutation drafts with a complete deterministic mismatch set; and a
resumable per-jurisdiction elaborator session checkpointed against admitted
commit ancestry.
@ -49,17 +50,16 @@ partition of world-authored entries; `authority`, `selection`, and
`commitments`, `pressures`, and `last_opportunity_at` partitions; and the
scalars `now` and `scale_intent`. There is no relation yet.
`crates/ghostlight/src/lib.rs` defines the typed `SubjectId`/`EntityId`/`EdgeId`/
`AffordanceId` namespaces; `crates/ghostlight/src/patch.rs` owns `Ref<Id>` with `DraftHandle` (adjacently tagged `RefKind`), and a
`world/patch.rs` owns typed `SubjectId`/`EntityId`/`EdgeId`/`AffordanceId`
namespaces, `Ref<Id>` with `DraftHandle` (adjacently tagged `RefKind`), and a
closed `resolve_patch` that resolves declarations, then operations, and
returns the complete `Vec<Mismatch>` before any canonical ID allocates;
`derive_id` is deterministic over world, command, and handle.
`Declaration::Affordance` admits a catalog entry with preconditions drawn
from the nine `Precondition` variants (`Present`, `Reachable`, `Holds`,
`Authorized`, `HasStanding`, `Knows`, `CanBroadcast`, `CanReach`,
`Committed`), effect ceilings, and weighted outcome bands. `ComponentOp` has
34 variants (listed under "Component operations"), `Transform` is one-to-one
and `Admit` takes same-patch evidence; `apply_operations` is
only from `{ Present, Reachable, Holds }`, effect ceilings, and weighted
outcome bands. The ten inhabited operations are `Relocate`, `OpenRoute`,
`CloseRoute`, `AlterCost`, `Transfer`, `Transform` (one-to-one), `Consume`,
`Admit` (same-patch evidence), `Bind`, and `Release`; `apply_operations` is
the one owner of operation application and conservation, and
`patch::check_ledger` is the single named conservation check.
`CommandBody::AdmitPatch` emits `WorldEffect::PatchAdmitted` through the one
@ -71,14 +71,14 @@ kernel itself bounds a patch nowhere yet. `EvidenceRef::new` is `pub(super)`
and its production use is bounded by `filter_evidence`.
Character action is a precondition-effect transition. Grants are
`BTreeMap<DecisionScope, BTreeSet<AffordanceId>>`. `crates/ghostlight/src/action.rs` owns
`BTreeMap<DecisionScope, BTreeSet<AffordanceId>>`. `world/action.rs` owns
`exercise()`, called from `reduce` and `apply_effect`: it checks the grant,
evaluates each precondition against the actor's own components at the scope
digest, selects an outcome band from
`BandPreimage { world_id, revision, command_id, affordance, band_count }`
through `digest()` with no RNG, bounds the proposed effects by the band's
ceilings, and appends `DecisionEvent { band, effects }`. Rejections are the
complete `ActionMismatch` set (26 variants) under
complete `ActionMismatch` set (17 variants) under
`KernelError::ActionRejected`. `Speak` is a kernel-built entry with zero
preconditions and an empty band that carries speech, synthesized only at
genesis. A controller's tools, signatures, and permissions are derived from
@ -101,7 +101,7 @@ petitions from subjects with standing. Authority-writing slots (`grant`,
target, otherwise `ActionMismatch::DelegationNotMonotone`.
A `Fact` is an entity with a `Statement` and a `FactStanding`: `Canonical`
with evidence, `Claimed { by }`, or `Ruled` (stated by the play table). `Knowledge` is subject-keyed per fact with
with evidence, or `Claimed { by }`. `Knowledge` is subject-keyed per fact with
a `Confidence` and a `KnowledgeSource { Witnessed, Told { by, via }, Seen { by },
Evidenced }`; a telling or a showing never overwrites a holder. `Witnessed` is minted by an authored
patch's `AcquireKnowledge` and, with no author at all, by `Witness` (below). A
@ -163,9 +163,9 @@ regional or global event interrupt whoever was mid-thought under that place
(the interruption mechanism of pass 9).
Time is `now: FictionalMinutes`, moved only by `CommandBody::AdvanceTime`
from `CallerId::System(SystemCapability::Clock)`; the caller submits
an explicit `TickMinutes` (Dungeon's `world.advance_time` payload), never a measured
duration. `crates/ghostlight/src/clock.rs` owns `derive_motion`, pure over state and tick: routines re-arm by exactly one
from `CallerId::System(SystemCapability::Clock)`; the runtime tick submits
the constant `CLOCK_TICK_MINUTES`, never a measured duration. `world/clock.rs`
owns `derive_motion`, pure over state and tick: routines re-arm by exactly one
`period`, past-due obligations and goals write `Pressure` on their subject by
the `step` table, and an unavailable dependency does the same. A
`Commitment` is `(kind: Routine | Obligation | Goal, counterparty, due,
@ -199,8 +199,8 @@ decided by `operation_ground`, which is total over `ResolvedOp`). Placeless
referents (resources, catalog entries, facts) are not confined. The model's
tool surface is `PATCH_TOOLS`: one tool per non-genesis `Declaration` and
`ComponentOp` variant plus two session tools, emitted through
`crates/ghostlight/src/tool_schema.rs` from one schema spelling per type.
`crates/ghostlight/src/elaboration.rs` owns the repair loop: a session keyed by a
`world/tool_schema.rs` from one schema spelling per type.
`world/elaboration.rs` owns the repair loop: a session keyed by a
deterministic command id (sha256 over world, jurisdiction, answer digest)
submits a draft, persists the resolver's complete mismatch set under that id
as a `Refusal` placed after the rounds that earned it, and continues the same
@ -212,9 +212,7 @@ fold, bounded by `ELABORATION_ROUND_BUDGET` and the
`EvidenceSource` receipts (`filter_evidence`); with `NullEvidenceSource` no
canonical fact and no `Admit` can land from the elaborator lane.
`crates/ghostlight/src/cover.rs` owns the budgeted connected cover. Since the play
agent's Cut 1 (`d69e9d4`) nothing in Dungeon drives the cover; the library keeps
it for offline simulation. `derive_cover(world, now,
`world/cover.rs` owns the budgeted connected cover. `derive_cover(world, now,
tick, opportunities, agency_graph, budget)` is pure: it reads the attention
order that `order_opportunities` owns, reserves the urgency slots for its
head, rotates the remaining subjects through singleton cells by debt, and
@ -231,7 +229,7 @@ Cell and constituent command ids are sha256-derived.
`AdmitPatch` has a third author: `CallerId::System(SystemCapability::Consumer
{ consumer })`, minted only by `WorldMailbox::submit_consumer` after the
consumer ingress (`crates/ghostlight/src/consumer.rs`) has authenticated a document against a
consumer ingress (`world/consumer.rs`) has authenticated a document against a
configured secret digest. `require_patch_author` and `confine_to_ground`
widen from jurisdiction-only to `PatchGround { Jurisdiction(JurisdictionKey),
Consumer(ConsumerId) }`; a consumer's ground is derived from
@ -250,11 +248,10 @@ the one decode bound for both the elaboration lane and the consumer lane,
under four caps — `MAX_PATCH_BYTES`, `MAX_PATCH_DECLARATIONS`,
`MAX_PATCH_OPERATIONS`, `MAX_PATCH_EVIDENCE` — and the consumer document
travels over two wire schema constants, `CONSUMER_PATCH_SCHEMA` and
`CONSUMER_RECEIPT_SCHEMA`, both `.v0`. The state and commit schemas then bumped to
`ghostlight.world_state.consumer.v2` and `ghostlight.world_commit.consumer.v2`;
the live schemas are v6 (`STATE_SCHEMA` and `COMMIT_SCHEMA` in
`crates/ghostlight/src/lib.rs`, state-schema generation `world-v3`). A store
written under an earlier schema is refused, not migrated.
`CONSUMER_RECEIPT_SCHEMA`, both `.v0`. The state and commit schemas bump to
`ghostlight.world_state.consumer.v2` and `ghostlight.world_commit.consumer.v2`
(state-schema generation `world-v3`); a store written under an earlier schema
is refused, not migrated.
`WorldScaleIntent` now arrives at creation, not as a later admission:
`world_create.v4` declares the title, the brief, targets, jurisdiction
@ -277,7 +274,7 @@ model-authored seed patch from a hand-authored one, because the model is the
owner's Hands during Draft and the owner's `ApproveDraft` plus `ActivateWorld`
remain the only path to Active. Evidence for a seed session comes from
`VaultEvidenceSource`, a read-only markdown directory reader in
`crates/ghostlight/src/vault.rs`: the reference it hands back is the note's vault-relative
`world/vault.rs`: the reference it hands back is the note's vault-relative
`.md` path, and its caps (`MAX_VAULT_RECEIPTS`, `MAX_HITS_PER_REFERENT`,
`MAX_LINK_FANOUT`, `MAX_EXCERPT_CHARS`) bound what one referent can retrieve.
One `world.seed` invocation runs one session and commits at most one patch
@ -305,14 +302,13 @@ is the typed component diff against the fresh components plus the
knowledge row, and never another subject's snapshot. The re-lowering's
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.v18`, Persona turn receipt
`ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are
refused. Ghostlight owns a conserved narrative ledger;
request. State schema is `ghostlight.world_state.consumer.v5`, commit schema
`ghostlight.world_commit.consumer.v5`, controller work `controller_work.v16`,
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`).
Step 6 of the plan is complete; the seeded live run is the runbook's business.
Plan step 12 landed the last row of the component table: `PersonaMaterial` (`values`, `voice`,
Plan step 12 landed the thirteenth row: `PersonaMaterial` (`values`, `voice`,
`memories`, `reads` keyed by subject) in its own `persona_material` partition,
set whole by `SetPersonaMaterial` with `NoncanonicalText` for any padded text
or doubled read and `NoOperationEffect` for identical material; it is read by
@ -410,14 +406,11 @@ resolver, or reconciler is needed.
Three namespaces, three newtypes, issued only by the reducer.
```text
SubjectId person, institution, population — SubjectKind, can decide
EntityId place, resource, fact, channel — EntityKind, cannot decide
EdgeId route — EdgeKind, connects places
SubjectId person, institution, population — can decide
EntityId place, resource, fact, channel — cannot decide
EdgeId route, relation, commitment, pressure — connects the above
```
Commitments and pressures are keyed by subject, not by `EdgeId`, and there is
no relation edge kind: `EdgeKind` has one variant, `Route`.
Display names are labels. They never resolve a reference or bind structure.
An externally controlled mirror is an ordinary subject of one of these three
kinds; what is external is its `ControllerAssignment`
@ -425,7 +418,7 @@ kinds; what is external is its `ControllerAssignment`
## Components
Twelve rows, each earning its place by constraining a decision. Some attach to one
Thirteen, each earning its place by constraining a decision. Some attach to one
referent; some are edge-shaped and attach to a pair. The reducer does not care
which noun a component is; it cares what it constrains.
@ -444,9 +437,8 @@ which noun a component is; it cares what it constrains.
| `Pressure` | source → target: magnitude | the causal boundary trigger |
| `PersonaMaterial` | subject: values, voice, memories, reads | lived meaning; never authority |
A `Fact` carries a `standing` (`FactStanding`, three variants): `Canonical`
(admitted with evidence), `Claimed { by: SubjectId }` (asserted in speech), or
`Ruled` (stated by the play table; only `Play` may write it). Knowledge of a claimed fact is
A `Fact` carries a `standing`: `Canonical` (admitted with evidence) or
`Claimed { by: SubjectId }` (asserted in speech). Knowledge of a claimed fact is
belief. Deception is a subject asserting a claim while holding a contradicting
canonical fact; misread is a listener acquiring a claim the speaker did not
intend. Both are ordinary typed states, not narrator judgments.
@ -458,34 +450,32 @@ problem in another without anyone deciding that it should be.
## Component operations
This is the closed operation set. Every mutation the kernel applies is one of
these, and every model-facing tool is a projection of one of these. The type is
`ComponentOp` in `crates/ghostlight/src/patch.rs`: 34 variants, named here by
the component they act on. Declarations are the six `Declaration` variants.
This is the closed operation set. Every mutation the kernel will ever apply is
one of these, and every model-facing tool is a projection of one of these.
```text
Declaration Subject, Entity (place or resource), Route, Affordance, Fact,
Channel (draft handles only)
Position Relocate
Route OpenRoute, CloseRoute, AlterCost
Custody Transfer, Transform, Consume, Admit (with evidence),
Mint (no receipt; only Play may write it)
Dependency Bind, Release
Authority GrantAuthority, RevokeAuthority
Selection OpenOffice, CloseOffice, InstallIncumbent, VacateOffice
Redress OpenForum, CloseForum
Knowledge AcquireKnowledge, Communicate, Witness, Forget
Channel SetReach, SetController
Commitment CreateCommitment, DischargeCommitment
Pressure AdvancePressure, ReducePressure, ResolvePressure
PersonaMaterial SetPersonaMaterial
Retirement Retire
Affordance GrantAffordance, RevokeAffordance
Declaration declare_subject, declare_place, declare_resource, declare_fact,
declare_channel, declare_route (draft handles only)
Position relocate(subject, via: Route)
Route open, close, alter_cost
Custody transfer(from, to, resource, qty), transform(resource, into, qty),
consume(holder, resource, qty), admit(holder, resource, qty, evidence)
Dependency bind, release
Authority grant(subject, scope, kind), revoke
Selection open_office, close_office, install(office, incumbent), vacate
Redress open_forum(kind, forum, standing), close_forum
Knowledge acquire(subject, fact, source, confidence), communicate(speaker, fact,
channel), witness(fact, place, confidence), forget
Channel set_reach, set_controller
Commitment create(subject, counterparty, kind, due, period, checks, statement), discharge
Pressure advance, reduce, resolve
PersonaMaterial set(subject, values, voice, memories, reads)
Retirement retire(referent, reason)
Time advance(minutes)
```
Time is not a `ComponentOp`: the clock moves only by `CommandBody::AdvanceTime`.
Adding an operation is a design change to this document and a code change to
the reducer; it is never a runtime patch.
Thirty operations. Adding one is a design change to this document and a
code change to the reducer; it is never a runtime patch.
## Patch construction
@ -539,7 +529,7 @@ counts do not appear here and may not be added.
The kernel owns component operations. A **world** owns its affordance catalog.
This is the generality lever: a setting with spellcraft, insurance claims,
oath-rhythms, or memetic sovereignty authors affordances from the same
component operations, and the kernel never learns a genre.
thirty operations, and the kernel never learns a genre.
```text
Affordance
@ -611,9 +601,7 @@ different mutations.
Layers of collective agency are relations, not special subjects:
- **Containment** nests places and jurisdictions.
- **Membership** binds persons and slices to institutions and populations;
design intent, with no record shape in the kernel yet (`EdgeKind` is `Route`
only).
- **Membership** binds persons and slices to institutions and populations.
- **Jurisdiction** is an `Authority` component whose scope is a subject set or
a place subtree.
- **Representation** is `Selection`: an institution acts through an office
@ -642,10 +630,10 @@ authored seed request opens a scope. Both are answered by the same patch.
```text
CausalBoundary
UnelaboratedDestination { route, place, scope }
PolityInCausalRange { subject, scope }
IndividuationRequired { population, scope }
MissingStructure { subject, key, scope } // a commitment has no authority,
UnelaboratedDestination { route, place }
PolityInCausalRange { relation, subject }
IndividuationRequired { population, slice, pressure }
MissingStructure { scope } // a commitment or pressure has no authority,
// channel, or redress path that could resolve it
SeedRequest { jurisdiction: DraftHandle, brief: EvidenceRef } // draft phase only;
@ -656,13 +644,9 @@ Boundaries are derived from a revision exactly as opportunities are, and each
carries the digest of the components that derive it. One command answers one:
```text
CommandBody::AdmitPatch { answers: Option<PatchAnswer>, patch: WorldPatch }
PatchAnswer = Boundary(CausalBoundary) | Deficit(JurisdictionKey)
CommandBody::AdmitPatch { answers: Boundary | SeedRequest, patch: WorldPatch }
```
`SeedRequest` above is design intent: no type of that name exists in
`crates/`, and Draft answers nothing.
A commit clears exactly the boundary it answers. Nothing else clears one. A
patch answering a boundary the kernel no longer derives is rejected.
@ -740,9 +724,9 @@ Adopted 2026-09-15. Plan step 15 pass L1 implemented the Lenses and Stock lens
set bullets and the concurrency half of the Sessions bullet. Per-world evidence
binding (L2), detail rules, rule-driven demand and sessions in Draft (L3), and
individuation (L4) are not implemented. These are library capabilities, neutral
to every consumer. A consumer supplies the policy, and no
consumer's concept enters this vocabulary. The Session Zero design
(`ghostlight-session-zero.md`) was to be a consumer of them; it is unbuilt, and nothing in `crates/` implements it.
to every consumer. A consumer supplies the policy: Ghostlight Dungeon's
Session Zero (`ghostlight-session-zero.md`) is one consumer of them, and no
consumer's concept enters this vocabulary.
- **Lenses.** A world carries weights over the library's lenses as world
data. Its creator states them in `CreateWorld` (the library holds no
@ -957,8 +941,7 @@ Deleted before replacement behavior is added, with no compatibility path:
- 5 qualification and verification types plus 1 outcome resolver → 0;
- titled quotas and the titled verifier, semantic qualification, and round
budgets → 4 derived
boundaries (`CausalBoundary`) and 1 structurally counted scale deficit (a seed
request is design intent, unbuilt);
boundaries, 1 seed request, and 1 structurally counted scale deficit;
- 2 seed admission paths → 1;
- N model-facing tool schemas hand-written per stage → 1 derived catalog.
@ -970,7 +953,7 @@ invariants did not ask for.
## Build budget
No new crate, binary, dependency, or service. Types and reduction inside
`crates/ghostlight/src/`, the `patch` and `action` modules,
`crates/ghostlight/src/`, one new `patch` module, one new `affordance` module,
focused `ghostlight` tests. No workspace-wide or
release build is admitted for this stage.

View file

@ -0,0 +1,85 @@
# Illustrated IF Visual Pipeline
Illustrated interactive-fiction fixtures can use image generation for mood,
presentation, and website replay. They should not treat image generation as the
source of spatial truth.
## Role
The visual pipeline has four separate jobs:
1. Describe the playable visual beat in the Ink and `.visual.json` artifact.
2. Preserve the intended environment, character, and branch state as structured
visual data.
3. Provide image models with enough concrete visible detail to render the beat.
4. Keep generated art useful as presentation collateral without letting it
rewrite scene geometry.
Visual artifacts are auxiliary replay and website value. They are not core
training targets for responder, appraiser, mutator, relationship, or social
state models.
## Geometry Authority
For scenes where spatial continuity matters across angles, branches, or later
callbacks, the authoritative visual source is a `scene_set` artifact, not a
generated image.
A `scene_set` may be a hand-built 3D blockout, a procedural scene file, a
layout diagram, or another durable geometry asset. It should define:
- Environment layout and scale.
- Named zones, routes, entrances, exits, and occluders.
- Fixed cameras or camera-slot names for replay beats.
- Character staging slots and allowed movement regions.
- Important props, access points, interfaces, and branch-visible state anchors.
- Which parts of the set are visible from each camera.
Generated environment concept art can inspire the set, but it is not a reliable
layout contract. Image models routinely invent impossible joins, inconsistent
occlusion, false loops, and locally plausible machinery that cannot be staged
from a second angle.
Agent-assisted set construction may live in a separate toolchain such as
VibeGeometry. Ghostlight only requires the resulting durable geometry input and
the metadata needed to connect it to fixture scenes.
## Prompt Assembly
Prompt assembly should use the geometry source appropriate to the fixture:
- Mood concept only: text prompt may be enough.
- Single isolated illustration: a complete standalone prompt may be enough.
- Multi-scene illustrated IF: use `.visual.json` scene ids, character refs, and
branch modifiers.
- Multi-angle or branch-consistent illustrated IF: use a `scene_set` render or
blockout for each camera angle, then apply the scene prompt as painterly
direction.
For the multi-angle path, the image model acts as a painter and material
interpreter. It does not decide the room shape.
## Base Images And Modifiers
Base prompts describe a complete visible frame. Modification prompts describe
only the branch or state delta, but the edit call must receive the base image or
the camera-specific blockout as visual input.
Do not rely on phrases such as "same room" or "as before" unless the edit input
actually supplies the same room. If the image model only receives text, restate
the visible geometry. If it receives a blockout, the prompt should still name
the visible anchors it must preserve.
## Reviewer Expectations
The visual reviewer should ask whether the current fixture needs a `scene_set`.
It should require one when:
- The story returns to the same room from multiple angles.
- Branches move characters around a shared environment.
- Sightlines, occlusion, route access, or body affordances affect choices.
- The same space needs website imagery across several beats.
- A generated concept image is being used as if it were a durable floor plan.
When a `scene_set` is required but missing, the fixture can still be accepted as
prose or IF scaffold, but not as replay-ready illustrated IF.

View file

@ -0,0 +1,498 @@
# Ink Branching Scenes
Ink is the branching scene format.
Ghostlight should not invent a parallel dialogue-tree language unless Ink
fails a concrete requirement. Ink already gives us readable interactive fiction,
choices, knots, stitches, variables, compiled JSON, and a format writers
can inspect without needing to wear a compiler helmet indoors.
Ghostlight's job is the machinery around Ink:
- build actor-local awareness from agent state and scene state
- project plausible speech and non-speech choices
- save generation receipts when a model proposes branches
- annotate why those choices make sense
- preserve listener interpretation as separate from actor intent
- record consequence surfaces for later state mutation
- keep fuzzy social updates reviewed until there is training data
- compile Ink into game-consumable JSON
Ink owns the playable branch structure. Ghostlight owns the psychological,
cultural, memory, relationship, and consequence rationale.
## Runtime Shape
The artifact flow is:
```text
agent state + scene state + lore grounding
-> local awareness
-> projected branch choices for the acting character
-> selected branch as observable action
-> event resolution from selected observable action
-> participant appraisals and reviewed state mutation
-> next actor local awareness from updated state
-> next actor action
-> Ink scene
-> compiled Ink JSON
-> reviewed training annotations
-> selected branch consequences
-> state, memory, belief, and relationship updates
```
The Ink file should be readable as Aetheria content on its own. The sidecar
training annotation should explain which Ghostlight state produced each branch
and which consequences are automatic, reviewed, or deferred.
Each character turn must be generated from that character's local awareness.
The protagonist-choice generator should not receive the responder's private
state. The responder generator should not receive the protagonist's private
intent except as an observable action, spoken text, gesture, object use,
silence, or other perceivable cue. A listener is allowed to misunderstand.
Participant appraisal is symmetrical. If an action hurts, threatens, reassures,
humiliates, obligates, or overloads anyone present, that change is consolidated
for the affected character before the next actor is selected. The next actor may
reply, walk away, comply, escalate, or end the interaction. A branch artifact can
still display this as `choice -> reaction`, but Ghostlight should model it as
turns over updated state, not as a mandatory paired response.
One-shot generation of both sides is allowed only as bootstrap scaffolding, and
must be marked as such in the capture review. It is not the target runtime
shape.
The current target path is source-grounded coordinator state, projected local
context, reviewed character turns, branch compiler materialization, and IF
artifact review.
## Ink Comments
Use Ink comments for lightweight traceability in writer-facing drafts:
- `ghostlight.scene`
- `ghostlight.fixture`
- `ghostlight.branch`
- `ghostlight.action`
- `ghostlight.intent`
- `ghostlight.npc_response`
- `ghostlight.consequence`
- `ghostlight.training_hook`
- `aetheria.flashpoint`
Use `//`, not Ink tags (`#`), for this metadata in drafts. Inky exposes tags in
preview, which makes Ghostlight state leak into the readable scene. The
sidecar remains the machine-readable receipt; comments are only local handles
for humans and simple tools.
## Sidecar Annotations
Each Ink scene should have a `.training.json` sidecar with:
- source fixture reference
- protagonist and NPC ids
- actor-local awareness summary
- projection controls
- branch ids and Ink knot paths
- action type and actor intent
- state basis
- reviewed consequences
- training hooks
- mutation policy
This sidecar is intentionally not a new branching format. It is a receipt. Ink
remains the scene tree; the sidecar says why the tree grew that way and which
state changes a reviewer accepted.
Visual planning should live beside the sidecar, not inside it. Use a separate
`.visual.json` artifact for illustrated replay data: style cue, visual scene
anchors, image prompts, character refs, prompt assembly rules, and visual
modifiers. The training annotation may reference that file with
`visual_plan_ref`, but it should remain focused on branch semantics and
mutation surfaces.
## Mutation Policy
Ink variables can record local playthrough outcomes. They should not silently
become canonical Ghostlight state.
Automatic:
- Ink compilation
- branch id and knot validation
- local variables for the current playthrough
- mechanical comment handles and training hook checks
Manual reviewed until trained:
- relationship deltas
- belief updates
- memory writes
- listener misread classification
- activation changes
- promotion from branch outcome into durable Aetheria lore
The current accepted-as-draft scaffold lives in
`examples/ink/pallas-species-strikes.branch-and-fold.v0.ink`.
## Cross-Scene Consequence Carryover
Ghostlight branching scenes should not behave like disconnected dialogue samples.
Interactive fiction needs consequences that survive the scene boundary. A branch
choice should write durable effects into later context: relationship stance,
perceived motives, resource costs, authority flags, object custody, active
constraints, unresolved hooks, and available future options.
For training data, every material branch should preserve:
- the player/NPC-visible choice or action
- the immediate responder output
- accepted world-state, resource, memory, and relationship deltas
- branch flags that later scenes must read
- deferred consequences that should surface later rather than immediately
- scene text showing how the consequence changes the feel of the next beat
The coordinator owns this continuity for now. Later, the trainable coordinator
model should learn to carry these consequences forward without flattening them
into cosmetic callbacks. If a choice does not change later affordances, trust,
resources, risk, or interpretation, it is probably not a meaningful branch.
A complete interactive fixture needs an ordinary-world opening, consequential
branches before the crisis peak, and a resolution that visibly reflects earlier
choices. A single tense exchange can prove a seam; it is not enough to prove the
story machine.
## Branch And Fold
Do not model every choice as an indefinitely isolated authored subtree. Strict
worldline splitting creates a combinatoric writing burden after a handful of
choices, which interactive fiction authors have been discovering the painful
way since approximately the first time someone let a player open a second door.
Ghostlight should branch locally and fold structurally:
```text
choice
-> consequence packet
-> normalized state deltas
-> shared later scene reads compact variables
-> conditional options, callbacks, imagery, and relationship texture
-> major route split only when convergence would be false
```
Most branches should converge onto shared later scenes. The consequences should
survive as compact variables, bands, and flags rather than bespoke scenes for
every exact history.
Good consequence state looks like this:
- `sella_trust_maer: wary`
- `ledger_debt_level: high`
- `clinic_staff_exhaustion: true`
- `veyr_evidence_disclosed: partial`
- `isdra_reads_maer_as: procedurally_dangerous`
- `route_authority_pressure: escalated`
Those variables should alter later scenes by changing available options,
relationship posture, timing pressure, resource costs, callback prose, visual
state modifiers, and participant appraisals. They should not usually require a
fully separate scene route.
Use coarse bands instead of fake precision. `blocked`, `wary`, `conditional`,
`cooperative`, and `personally_invested` are more useful than pretending the
story can meaningfully maintain 37 decimals of trust. The same applies to heat
debt, legal exposure, institutional pressure, staff exhaustion, reputation, and
social suspicion.
Major route splits are allowed when a consequence changes the playable world so
much that folding would lie. Examples:
- a character dies, leaves, defects, or becomes unavailable
- a location is destroyed, locked, opened, or politically inaccessible
- a faction becomes hostile, allied, exposed, or legally empowered
- a key object is destroyed, stolen, transferred, or transformed
- the protagonist loses or gains institutional authority
- a quest route, rescue path, market, technology, or branch-local future opens
or closes entirely
The coordinator owns the fold decision for now. Every material branch should
record whether it folds into an existing later scene, opens a conditional
variant inside that scene, or creates a justified route split. If a route split
is created, the artifact should name the reason and the expected future writing
debt. Trees are cheap to grow and expensive to prune. The machine should not
act surprised by botany.
## Branch Compiler
The Branch Compiler is the organ that turns Ghostlight scene intent into a
playable Ink artifact. It is not the character agent, the responder, the
appraiser, or the final reviewer. It assembles branch structure from already
available scene state, projected actor affordances, proposed actions, accepted
consequence packets, and coordinator constraints.
The Branch Compiler receives:
- fixture brief and scene spine
- source-grounded lore digest
- current world, scene, resource, relationship, and branch state
- actor-local branch candidates, including speech and non-speech actions
- responder outputs or coordinator-authored placeholder reactions
- consequence packets from reviewed turns
- branch-and-fold plan
- visual continuity requirements
- schema and Ink formatting constraints
The Branch Compiler emits:
- readable `.ink` scene content
- Ink variables, knots, stitches, choices, gathers, and conditionals
- a `.training.json` sidecar tying each branch to state basis, action intent,
consequence surfaces, and later callbacks
- compiler notes for fold decisions, route-split decisions, and known review
risks
- image prompt anchors and branch/state modification handles when the fixture is
meant for illustrated output
The Branch Compiler may propose structure. It must not invent hidden character
truth, private motivations, or durable social mutations as if compilation made
them real. Character behavior comes from projected local context and responder
or coordinator-authored action proposals. Social interpretation and memory
changes come from appraiser/mutator review. The compiler's job is to make the
branching artifact playable, legible, and traceable.
The compiler should prefer compact state variables, bands, and flags over
runaway branch trees. It should create major route splits only when the
coordinator or reviewed consequence packet says folding would falsify the
world. When it folds branches, it must preserve the variables and callbacks that
make the fold honest.
The Branch Compiler output is draft until the IF Artifact Reviewer accepts it.
That separation matters. A compiler can make a scene valid Ink while still
producing cosmetic choices, fake folds, unused variables, or endings that ignore
the whole moral machinery. Valid syntax is not wisdom. It is barely hygiene.
## IF Artifact Review
Branching fixtures need an independent review pass before acceptance. The
coordinator should not be the only thing grading its own Ink, sidecar, and
branch logic. That is how a scene ends up proudly tracking variables that do
nothing except sit there in a tiny uniform.
The reviewer may start as a prompted specialist sub-agent. Later it should
become evaluator/classifier training data and eventually a trainable review
organ. Its job is not prose polish. Its job is to catch false consequence.
The reviewer receives:
- the Ink file
- the `.training.json` sidecar
- the coordinator artifact
- the intended branch plan, when available
- the declared state variables, flags, bands, resources, risks, and visual
modifiers
- any compiled Ink or validation output available for the fixture
The reviewer emits:
- `accepted`, `needs_revision`, or `rejected`
- severity-ranked findings
- a fake-variable audit
- a choice-consequence audit
- a fold/callback audit
- a state-gated prose audit
- a visual-continuity audit
- major-route-split justification notes
- specific required fixes
Acceptance requires every material choice to change at least one later surface:
- available options
- scene route or justified route split
- relationship, trust, appraisal, or social standing
- resource cost, time pressure, legal exposure, authority pressure, heat burden,
exhaustion, or other risk
- object custody, evidence quality, access, or technology state
- later callback prose implemented as an actual conditional
- ending texture or outcome band
- visual state modifier tied to a real branch flag or state variable
Every declared variable must either be read later or be marked as telemetry-only
in the sidecar. Ominous variables need threshold behavior. If a variable named
`authority_pressure` rises and nobody with authority ever reacts, the artifact
fails review. If `heat_time` counts down and no route, ending, resource cost, or
staff condition changes, the artifact fails review. The name is allowed to be
dramatic only if the machinery has teeth.
Any prose that says "if X happened" must be implemented as an actual conditional
or moved into reviewer/coordinator notes. Writer-facing text should not name a
branch possibility while dodging the state check that proves which version of
history occurred.
Common failure labels:
- `potemkin_state`: a tracked variable never materially changes the artifact
- `cosmetic_choice`: a choice changes wording but not affordance, cost, risk,
appraisal, callback, visual state, or outcome
- `missing_state_read`: a set variable is never read by later Ink, sidecar, or
coordinator consequence logic
- `fake_fold`: branches claim to fold, but the folded scene ignores their state
- `unearned_convergence`: branches converge despite consequences that should
make convergence false
- `unjustified_route_split`: a separate route exists without enough world or
story consequence to justify the writing debt
- `state_named_instead_of_checked`: prose mentions alternate branch states
instead of checking variables
- `visual_callback_missing`: image modifiers or visual prose ignore branch
consequences that should be visible
- `authority_without_consequence`: institutional pressure rises but never bites
- `resource_without_cost`: ledgers, heat, staff time, supplies, or access are
invoked without affecting later choices or outcomes
- `ending_ignores_major_state`: the ending does not respond to major variables
established earlier
Early branch-and-fold review exposed this exact class of failure: variables can
look important while doing nothing. Review must force important variables to
alter gates, callbacks, visuals, appraisals, or outcomes before a fixture enters
the accepted corpus.
## Scene Imagery
Ghostlight scenes are also website content. A complete fixture should emit a
visual plan artifact alongside prose and training receipts so story branches can
be illustrated without a later model guessing what the room looked like.
Interactive fiction replay also needs screen rhythm. A narrative scene may move
through several visual sections: establishing shot, workstation, break table,
object closeup, alarm state, crowd line, supervisor arrival, aftermath. These
should be segmented so an InkJS replay can click through the scene at the pace
the story needs. A pivotal beat may be a single sentence on its own screen when
the point is to let the image and silence do the work.
Each distinct visual section should preserve an art-direction block with:
- global style cue: the fixture-level visual language when the art direction
depends on a specific non-default look
- establishing shot: location, scale, camera distance, and dominant geometry
- light and color: practical light sources, palette, exposure, haze, glare,
reflection, water, vacuum, screen glow, or other atmosphere
- bodies and interfaces: how each relevant body occupies the space, including
nonhuman access, assistive infrastructure, suits, avatars, wet/dry boundaries,
displays, tools, and machinery
- material evidence: objects, damage, records, ledgers, cargo, alarms, clothing,
stains, frost, heat shimmer, or other concrete traces of what has happened
- branch marks: visible changes caused by earlier choices, such as a sealed
hatch, an exhausted staffer, a live debt ledger, missing equipment, guarded
posture, or a route marker left blinking
- base image prompt: one stable prompt for the unbranched scene, suitable for
generating the reusable background or canonical key frame
- branch modification prompts: additive edit prompts for visible consequences,
changed character positions, opened or sealed routes, damaged objects,
lighting changes, emotional posture, crowd pressure, or other scene states
Named characters need stable visual references. An image model does not know
what Lio Vale looks like because the prose named Lio Vale. Each illustrated
fixture should define `visual_character_refs` for recurring named characters:
body type, apparent age range if relevant, face/hair/skin or equivalent visual
identity, clothing and equipment, silhouette, posture language, and
`do_not_change` continuity details. Scene prompts may then reference those
character refs instead of repeating the whole description every time.
Prompts must be imagegen-ready. Do not rely on unexplained Aetheria jargon as
if the image model knows the lore. If the prompt says "wet-service cradle" or
"dry-operation harness," it should also describe the visible thing according to
the faction, location, and labor regime. A Navigator comfort habitat and an AU
industrial yard should not quietly become the same room. If a scene uses a
biodrone limiter interface, cavity seal assembly, or life-support bypass kit,
describe the shape, scale, materials, lights, cables, seals, or displays that
make it drawable.
The same rule applies to environments. "Manifold line," "cavity yard,"
"supervisor glass," and "anchor rail" are not images by themselves. A base
prompt should describe the physical space a general image model can draw:
corridor shape, wall/ceiling/floor geometry, pipe runs, cable bundles, panels,
screens, rails, doors, windows, gauges, warning lights, materials, scale, and
where bodies stand inside that geometry.
Image prompts should be affirmative. Describe the desired geometry, material,
composition, lighting, bodies, tools, and state. Negative prompt constructions
such as "do not draw X," "without X," or "rather than X" tend to feed the image
model the wrong object and then hope it obeys the minus sign. That is weak
magic. Put the target in the prompt, and keep forbidden assumptions in reviewer
notes or validation errors.
For Aetheria Bloom habitats, prompt the engineered object directly: rotating
cavity-shell infrastructure, consolidated asteroid rubble/debris, TCS
substrate, shell plating, ribbed supports, seals, hub/spoke interfaces,
manifolds, and service systems. Source-specific exceptions should be represented
as their own positive visible description.
Every visual section should identify:
- `visual_scene_id`: stable handle for the screen or key frame
- `ink_anchor`: knot, stitch, or comment handle where the visual applies
- `base_image_prompt`: durable scene prompt for the unmodified frame
- `visible_characters`: characters allowed in the frame and their default
position or stance, plus a `visual_ref` when the character is named
- `state_image_modifiers`: additive prompts tied to explicit variables,
thresholds, or route flags
- `branch_image_modifiers`: additive prompts tied to specific branch ids
- `visual_continuity_notes`: facts that must remain stable across edits
Prompt assembly for illustrated replay should combine:
```text
global style cue
+ visual_character_refs for named characters actually visible in this rendered frame
+ visual section base_image_prompt
+ triggered state or branch image modifiers
```
The base image prompt is allowed to focus on the room and staging only if the
assembled prompt includes the relevant character refs. If a tool sends the base
prompt alone to imagegen, the base prompt must carry enough character detail by
itself. The same applies to visual style: if a fixture depends on a specific
look, the assembled prompt must carry that style cue every time. Names are
handles, not faces.
Keep `visual_continuity_notes` out of assembled image prompts. They are tooling
and review constraints for keeping repeated generations coherent. If a
continuity fact needs to reach the image model, rewrite it as affirmative,
concrete visible prompt text in the base prompt, a character ref, or a
branch/state modifier.
The image generator only sees the prompt you send it. It does not remember the
previous scene unless a real image-editing workflow supplies that image as
input.
The coordinator owns this surface for now. The responder should not receive
omniscient visual art direction unless the character can observe it. Character
packets may receive the local visible subset: what this actor can see, hear,
touch, smell, monitor, or infer from their interface.
Visual continuity is part of consequence carryover. If an earlier branch cost
heat, trust, time, equipment, or social standing, the later scene should show
that cost somewhere on the page or in the frame. Otherwise the choice may be
mechanically recorded while still feeling fake, which is the stupid little
trapdoor interactive fiction keeps setting for itself.
When continuity matters, use one durable base visual prompt and branch/state
modification prompts that describe only what changed. The base prompt owns the
room, palette, dominant geometry, key props, bodies, and interface layout. A
modification prompt owns the delta: Sella moves from the dryside console to the
decon sleeve, the route marker flips from amber to white, a wet access hatch
seals, an exhausted staffer appears behind the glass, Maer's ledger panel gains
a live debt line.
Keep these prompts separate from canonical prose:
- `base_image_prompt`: stable scene anchor
- `state_image_modifiers`: reusable prompts for common scene states
- `branch_image_modifiers`: prompts tied to specific branch ids
- `visual_continuity_notes`: non-prompt reminders about what must remain stable
Image edits are allowed to stylize, compose, and beautify. They are not allowed
to silently change canonical facts. If an edit introduces a new visible object,
body affordance, damage mark, or faction symbol that was not in the scene state,
the coordinator must either reject it, mark it as noncanonical illustration
noise, or promote it through the normal reviewed lore/state path. The picture
does not get to smuggle a gun onto the mantel just because it had a strong
personal brand that morning.

View file

@ -0,0 +1,264 @@
# Lore Grounding Digest Format
The lore grounding digest is the bridge between authored Aetheria history and
Ghostlight projection training.
It distills a specific historical flashpoint into the cultural, institutional,
material, and factional pressures needed to build agent-state fixtures and
speaker-local projection prompts.
It is not a lore replacement. It is a working slice. Do not ask every prompt to
carry the whole vault in its teeth like an exhausted dog.
## Files
The schema lives at:
- `schemas/lore-grounding-digest.schema.json`
The starter template lives at:
- `examples/lore-grounding/historical-flashpoint.template.json`
Use the template for the first authored historical flashpoint, then save real
digests under `examples/lore-grounding/` with names tied to the flashpoint.
## Purpose
A digest should answer:
- What authored source material grounds this flashpoint?
- What history, institutions, factions, classes, roles, and material pressures
shape the scene?
- What cultural priors should affect how characters appraise events?
- What does each faction or class reward, shame, taboo, admire, and misread?
- What should each speaker know, believe, misread, or not know?
- What pressure should appear in `prompt_text`, and what should remain
author-only?
## Main Sections
### Source Refs
Use `source_refs` to cite the lore archive slices that constrain the digest.
References should be specific enough that a later reviewer can find the source
without spelunking through the entire vault with a dim headlamp and a worsening
personality.
### Historical Context
This section describes the time, place, public facts, and ambient pressures of
the flashpoint.
Good context is small and hard-edged:
- a clinic policy dispute
- a contract negotiation
- an upload continuity hearing
- an unsafe FTL signoff
- a workplace discipline meeting
- a factional aid intervention
Do not start with "the whole setting." Start with one room where history has
hands.
### Groups
`groups` describes factions, institutions, classes, professions, movements, or
subcultures active in the scene.
The group profile is where broad lore becomes usable social pressure:
- `institutional_logic`: what the group exists to preserve, control, repair, or
extract
- `material_incentives`: what the system rewards the group for doing
- `internal_virtues`: what insiders admire
- `internal_shames`: what insiders experience as disgrace
- `prestige_markers`: what reads as competence, status, holiness, care, or
discipline
- `taboo_triggers`: what provokes disgust, sanction, fear, or exclusion
- `emotional_display_norms`: what feelings can be shown, hidden, ritualized, or
punished
- `trust_defaults`: who the group tends to trust, distrust, pity, envy, use, or
fear
- `speech_register_cues`: how role and culture shape language
This is where "Dominion types read as cold" becomes something more useful than
a hat with a sneer glued to it.
### Cultural Pressure Fields
`cultural_pressure_fields` are reusable pressure atoms derived from lore.
They should not say:
```text
Dominion people are cold.
```
They should say something closer to:
```text
In this Dominion-coded role, visible personal need reads as loss of command.
Under status threat, the speaker is likely to compress emotion into procedure.
```
Pressure fields can represent:
- virtue
- shame
- prestige
- taboo
- threat model
- trust default
- speech norm
- status trigger
- material incentive
- outsider stereotype
### Factional Misread Matrix
The misread matrix models how groups translate each other's virtues into
insults.
Example shape:
```text
Observer: Dominion-coded institution
Target: Aya-coded care network
Observer read: sentimental, porous, slow to impose hard necessity
Target self-read: durable care infrastructure, consent discipline, refusal to
turn people into convenient losses
Projection use: Under conflict, Dominion-coded speakers may frame Aya restraint
as weakness; Aya-coded speakers may frame Dominion decisiveness as moral
amputation.
```
The point is not to canonize every stereotype as true. The point is to make
misperception available as social fuel.
### Role Pressure Fields
Roles are not the same as factions. A Dominion auditor, a Dominion heir, and a
Dominion maintenance officer may share cultural priors while facing different
obligations and failure modes.
Role fields should describe:
- obligations
- available masks
- failure modes
- what authority looks like
- what disgrace looks like
- what compromises the role can justify
### Speaker Lore Boundaries
This section controls leakage.
For each speaker, define:
- known context
- beliefs
- misreads
- must-not-know facts
If a speaker would not know a fact, it does not enter their prompt text. If a
speaker suspects something, encode it as a belief or misread with source
grounding. No little omniscient narrator gremlin in the vents.
### Author-Only Boundaries
Author-only boundaries constrain the fixture or future continuity but do not
enter speaker prompts.
Examples:
- future outcomes
- hidden institutional motives
- another speaker's private state
- facts known only to the author or simulation supervisor
- Elysium branch outcomes not yet promoted
### Open Questions
If the lore source is too thin, say so. Mark whether the question blocks
training.
Do not generate training data to cover a hole that should be filled by lore
elaboration. That is not creativity; that is drywall over a load-bearing ghost.
## Dominion And Aya Pressure Examples
These are examples of digest structure, not final faction doctrine.
### Dominion-Coded Pressure
Likely ingredients to verify against source:
- administrative continuity
- hierarchy
- AGI governance
- industrial planning
- duty under collapse
- controlled emotional display
- legitimacy through competence and command
Projection shape:
```text
The speaker is Dominion-coded and under status threat. They are likely to turn
distress into procedural language because visible need reads as command leakage.
They may experience compassion as legitimate only when subordinated to duty,
continuity, or strategic necessity.
```
Outsider misreads:
- cold
- arrogant
- authoritarian
- morally vacant
- inhumanly procedural
### Aya-Coded Pressure
Likely ingredients to verify against source:
- care as material politics
- sanctuary
- maintenance
- consent discipline
- expansive personhood
- refusal to solve suffering by writing people out of the ledger
Projection shape:
```text
The speaker is Aya-coded and facing coercive efficiency. They may treat care as
infrastructure rather than sentiment. They are likely to resist decisions that
make personhood conditional on convenience, even when outsiders frame that
resistance as softness.
```
Outsider misreads:
- weak-hearted
- sentimental
- indecisive
- inefficient
- unable to do what reality demands
## Promotion Rule
A digest becomes usable for grounded training when:
- source refs are specific
- major cultural pressure fields are filled
- factional misreads are explicit
- speaker knowledge boundaries are clear
- open questions do not block the scene
- a reviewer agrees the digest reflects authored lore rather than attractive
fog
Only then should it feed an agent-state fixture or projection example.

View file

@ -0,0 +1,64 @@
# Persistence Spine
Ghostlight's machine-managed state is moving behind a small CultCache-style
persistence layer instead of letting every tool paw raw files directly.
The Python port is `cultcache-py`, published by CultLib to PyPI and pinned in
`tools/requirements.txt`. It used to be vendored as the `vendor/cultcache-py`
git submodule against the now-retired standalone `GameCult/cultcache-py`
repository; PyPI distribution replaced that fork.
## Current Store
- Cache store: `state/ghostlight-state.cultcache.jsonl`
- Ghostlight seam: `tools/ghostlight_state_store.py`
- Library dependency: pinned `cultcache-py==0.2.0`, declared in
`tools/requirements.txt` and installed by `npm run state:bootstrap`
(an idempotent `pip install -r tools/requirements.txt`, wired as a `pre*`
hook ahead of `state:status` and `state:prepare-compaction`)
- Readable exports: `state/branches.json`, `state/evidence.jsonl`
The JSONL store is deliberate bootstrap plumbing. The package also exposes a
MessagePack store for environments that install the optional `msgpack`
dependency, but Ghostlight's state CLI must run on the bundled Python runtime
without global package assumptions.
For cross-runtime `DatabaseEntry` payloads, use `cultcache-py`'s
`define_database_entry_type(...)` formatter. It writes MessagePack arrays by
explicit slot key, preserves nil gaps for deleted/reserved fields, and uses
defaults for missing older slots. That is the wire-compatibility contract shared
with `cultcache-rs` and the updated CultLib MessagePack generator.
## Live Rule
New machine-managed state should go through registered document definitions in
`tools/ghostlight_state_store.py` or a successor module. Direct writes to
`state/branches.json` and `state/evidence.jsonl` are compatibility debt.
Human-readable exports remain useful because git diffs, handoff review, and
emergency recovery should not require decoding a cache file while the house is
on fire.
## Migrated Documents
- `ghostlight.branches.v0`
- global document
- exported to `state/branches.json`
- `ghostlight.evidence_record.v0`
- one record per evidence entry
- indexed by `type`, `status`, and `branch`
- exported to `state/evidence.jsonl`
## Expansion Path
Move compact control-plane state next:
- corpus coverage ledger metadata
- active fixture run manifests
- persistent reviewer receipt indexes
- compaction scratch registration
- branch and initiative run ledgers
Keep large corpora, generated artifacts, images, and full story payloads outside
the single state store unless they are represented by compact refs. A cache
file is not a warehouse just because it has pockets.

View file

@ -0,0 +1,163 @@
# Projected Local Context
The projected local context is the missing seam between canonical Ghostlight
state and a character agent.
Canonical state is storage. It can contain means, plasticity, activation,
relationship stance, memories, culture, goals, and author-facing source paths.
A character response model should not have to interpret that machinery. It
should receive a compact operating context: what this character knows, wants,
misreads, can do, sounds like, and must not resolve.
## Contract
Pipeline:
```text
canonical state + scene + reviewed annotation
-> projector/state interpreter
-> ghostlight.projected_local_context.v0
-> response-model prompt
-> generated action proposal
```
The projected context may retain source references for audit. The rendered
prompt text must not expose raw state internals such as `current_activation`,
`plasticity`, means, or selected numeric dimensions.
## Artifact Shape
Schema: `schemas/projected-local-context.schema.json`
Validator: `tools/validate_projected_contexts.py`
Renderer: future projector/state-interpreter implementations should emit this
schema before any character responder sees the scene. Historical prototype
renderers have been removed from the live workspace after their lessons were
folded into this contract.
The context contains:
- speaker identity and setting
- known facts and speaker beliefs
- current stakes
- embodiment and interface affordances
- active inner pressures rendered as ordinary language
- relationship read and known biases
- tensions
- runtime retrieval requirements
- latent pressure requirements
- action affordances
- projection controls
- voice surface
- likely response moves
- do-not-invent boundaries
- rendered prompt text
## Retrieval And Latent Pressure
The projector now emits two explicit requirement lists before the responder
packet is built.
`runtime_retrieval_requirements` are compact lore facts the runtime should
include when their trigger is scene-relevant. They are not a license for a
responder to browse the lore archive. They are coordinator or retriever outputs
with source refs and a prompt role. A Pallas labor scene might require AU
claimshare hierarchy, uplift liability framing, species affordances, Bloom
service-ring geometry, and strike-law pressure before a responder can act
without drifting into generic sci-fi wallpaper.
`latent_pressure_requirements` are character-local history or emotional pressure
that must remain visible to the responder because it can shape behavior. They
are not dialogue requirements. Sella's memory of a sanctuary overpromising
safety should bias her toward capacity suspicion, condition-setting, and hard
triage boundaries. It should not make her announce the memory in the middle of
a mission-critical exchange unless the scene genuinely asks for that.
The rendered prompt includes these as:
- `Retrieved Lore To Keep In View`
- `Latent Pressure Handling`
The responder packet also copies source-excerpt retrieval requirements into its
`source_excerpts`, so packet-only tests can evaluate the same bounded context a
runtime responder would receive.
## Runtime Boundary
The character model receives local operating context, not omniscient truth. If
Maer acts, Sella's next pass receives only the observable event and Sella's own
local context. Maer's private intent is not included.
The sequential runtime still owns:
- tool contracts
- action enums
- event proposal capture
- participant appraisal
- reviewed mutation
- branch compiler / Ink materialization
The projector owns the translation from Ghostlight's internal state language to
character-local operating prose.
## Character Cognitive Ceiling
The response model may be very capable. The character should not inherit that
capability by default.
Projection must carry a character-local cognitive ceiling: how quickly the
character infers, how much social indirection they notice, how well they
manipulate attention, how abstractly they reason under stress, what education or
culture has trained them to see, and where their blind spots live. A Lucent
crisis professional may operate with alarming metacognitive precision because
that is part of the institution's ecology. A tired dock worker, sheltered child,
panic-struck survivor, credulous fan, or blunt military specialist should not
suddenly perform the same level of social chess just because the underlying
model can.
The projector should therefore render:
- strategic acuity and social-reading ability
- education, professional training, and cultural scripts
- stress effects on reasoning
- blind spots, taboos, and habitual misreads
- whether the character can explain their own tactic, only feel toward it, or
act it out without understanding it
This is separate from narrator intelligence. A coordinator or narrator can help
the reader understand a maneuver after it happens. That explanatory prose must
not be smuggled into the character's private state or future action capacity
unless reviewed mutation says the character actually learned it.
## Current Limit
The first projector is deterministic and deliberately simple. It uses threshold
language like "dominant", "strong", "present", and "background" rather than
numeric scores. This is enough to remove raw state soup from responder prompts and
create inspectable training artifacts.
Later versions can become smarter, but they should still preserve this boundary:
response models act from projected local context; they do not become ad hoc
interpreters of the canonical schema.
## Embodiment Boundary
Embodiment is part of the prompt surface, not flavor garnish. If a character is
a cetacean Navigator, the response model must know what kind of body is acting
and what infrastructure surrounds it.
For Aetheria factions, project faction-specific affordances only after checking
the lore source. The current Navigator projection is grounded in:
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Factions\Powers\Major\Cetacean Navigators.md`
- `E:\Projects\AetheriaLore\Aetheria\Worldbuilding\Pre-Elysium\Technology\Uplift.md`
Those sources support fluid architecture, acoustic signaling, soft navigation
light, layered water motifs, communal orientation chambers, mixed-species
translation/coexistence, specialized environments for cetacean physiology, and
mixed wet/dry interface ergonomics for Navigator-adjacent habitats.
Do not let the model default to hands, pockets, standing, walking, leaning, or
doorway blocking unless the fixture explicitly establishes that visible
interface. Do not patch over missing lore with confident furniture.

View file

@ -0,0 +1,230 @@
# Projection Distillation Plan
Ghostlight should treat prompt projection as a specialist skill. A large teacher
model can produce reviewed projection artifacts from structured state; a smaller
student model can later learn that projection task once the artifact format is
stable.
This is a staged plan, not an immediate fine-tune order.
This plan covers the projector specifically. The broader rule for soft
judgments lives in `docs/architecture/soft-model-training-artifacts.md`.
Projection is one student organ, not the whole animal.
## Goal
Train or adapt a smaller projection model that can turn structured Ghostlight
state into compact character-local pressure artifacts.
Input:
- agent-state fixture
- scene id
- speaker id
- listener ids
- optional projection mode
Output:
- selected speaker-local facts
- active pressures
- tensions
- response affordances
- physical or action affordances when relevant
- voice surface
- rendered prompt text
- audit notes or trace linking outputs back to state
The student projector should not generate final prose. Its job is to build the
pressure model that a response model can write from. The response may be
dialogue, action, silence, withdrawal, violence, or a mixed beat.
## Aetheria Training Boundary
For Aetheria, grounded projection training should come primarily from authored
historical lore rather than from Elysium gameplay-era scenes.
Elysium gameplay content is intentionally malleable: the games discover and
generate what happens there. Projection examples set inside Elysium should be
marked as procedural branch material unless their branch facts have been
explicitly promoted.
Use `docs/architecture/aetheria-lore-grounding-architecture.md` for the
grounding plan. In short:
- train from historical flashpoints with strong lore grounding
- use Elysium fixtures like Cat/Oz to test projection mechanics and procedural
branch generation
- preserve Elysium branch lineage so conditional futures do not get flattened
into global facts shared by sibling histories
## Roles
### Teacher Projector
A frontier model produces first-pass projection artifacts from the schema and
scene context. It should explain why each pressure, tension, or affordance is
active.
Teacher outputs are not trusted automatically. They enter review.
### Reviewer
A human or separate model checks whether the projection:
- uses only speaker-local context
- cites state interactions instead of unsupported vibes
- preserves the distinction between canonical state, perceived state, and prompt
projection
- derives target emergent behaviors from state intersections
- avoids hard-coding defensive behaviors as instructions when pressure prose is
enough
- stays compact enough to be useful as a runtime prompt surface
Rejected examples are useful as negative cases if the failure mode is clear.
### Student Projector
A smaller model learns the reviewed projection task. It receives structured
state and emits the projection artifact. It should be optimized for consistency,
latency, and cost, not for final prose quality.
### Aetheria-Tuned Agent Model
The character agent or responder is a separate training target. It may be
adapted on a corpus of strong source-grounded Aetheria responses and reviewed
Ghostlight action/dialogue receipts spread throughout the timeline. The point is
for setting priors, tone, factional preconceptions, institutional assumptions,
species affordances, and recurring social failure patterns to live in the
responder model rather than in a giant prompt sack.
That model still receives only character-local context at runtime. Fine-tuning
should teach it Aetheria's pressure ecology, not grant it author vision,
mutation authority, or access to private state.
## Dataset Shape
The first schema lives in:
- `schemas/projection-example.schema.json`
The first reviewed examples live in:
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.jsonl`
- `examples/projections/call-of-the-void.scene-broken-taxi-oz.pretty.json`
Use a JSONL dataset with one projection example per speaker-scene turn.
The JSONL file is the machine artifact. The pretty JSON file is the readable
mirror for inspection, review, and discussion.
```json
{
"example_id": "call-void.scene-broken-taxi-oz.cat.v1",
"schema_version": "ghostlight.projection_example.v0",
"input": {
"fixture_ref": "examples/agent-state.call-of-the-void.json",
"scene_id": "scene-broken-taxi-oz",
"speaker_agent_id": "cat_marrigan",
"listener_ids": ["oz_operator"],
"projection_mode": "response_turn"
},
"output": {
"known_facts": [],
"active_pressures": [],
"tensions": [],
"response_affordances": [],
"voice_surface": [],
"prompt_text": "..."
},
"audit": {
"teacher_model": "frontier-teacher",
"review_status": "accepted",
"review_notes": [],
"target_emergent_behaviors": []
}
}
```
Store training examples separately from the canonical fixture. The fixture is
world state. The projection dataset is supervised behavior for a projector.
## Data Generation Loop
1. Choose a scene and speaker from a fixture.
2. Build the speaker-local input slice deterministically.
3. Ask the teacher model to produce the projection artifact.
4. Ask the teacher or a second model to self-audit against the contract.
5. Review or edit the artifact.
6. Save accepted artifacts as positive training examples.
7. Save rejected artifacts with failure labels when the rejection teaches a
useful boundary.
8. Periodically run a small evaluation set before adding more examples.
The Cat/Oz Call of the Void fixture remains useful as the first projection
mechanics fixture because it already has reciprocal relationship stance,
perceived overlays, hidden stakes, and speaker-local dialogue packs. It should
not be treated as the first grounded Aetheria lore-training fixture.
The first grounded training loop should use a historical Aetheria flashpoint
from the authored lore archive.
## Evaluation Gates
Do not fine-tune until these gates are met:
- projection artifact schema is stable enough for at least one complete fixture
- at least one deterministic input-slice builder exists
- examples include both Cat and Oz directions
- examples include secrets known by the speaker and secrets absent from the
speaker context
- examples include at least the target emergent behaviors:
- sarcastic deflection
- sincerity evasion
- emotional evasion
- banter-as-boundary
- verbal aggression
- evaluation checks can identify whether a projected behavior was justified by
state interactions
- rejected examples have clear failure labels
## Evaluation Questions
For each projection, check:
- Did it include only facts available to the speaker?
- Did it select the highest-impact pressures instead of dumping the whole
schema?
- Did it name tensions that follow from goals, relationships, memories, and
scene pressure?
- Did response affordances emerge from state intersections?
- Did voice surface stay separate from defensive behavior?
- Did the projection allow nonverbal or physical action when the state and scene
made speech less likely than movement?
- Did known secrets become guarded pressure rather than forbidden context?
- Did the rendered prompt give the response model enough slope to produce the
target behavior without commanding the exact behavior label?
## Fine-Tune Readiness
A student projector becomes worth training when the reviewed dataset is large
enough that mistakes are repetitive rather than mysterious. Before that, use the
teacher model directly and save artifacts.
Minimum useful milestone:
- 50 accepted projection examples across multiple speakers and scenes
- 20 rejected examples with labeled failure modes
- stable projection output schema
- deterministic input-slice builder
- evaluator prompt or script that catches the known failure modes
## Boundaries
The student projector is not the character. It is not the dialogue writer. It is
not the action choreographer. It is not the evaluator. It is a compiler from
structured social state to compact character-local pressure. Keep those
responsibilities separate.
Likewise, an Aetheria-tuned character model is not the resolver, not the state
store, and not the canon oracle. It proposes local action from local context.
Code and reviewed mutation authority decide what becomes world state.

View file

@ -0,0 +1,297 @@
# Prompt Projection Contract
Ghostlight should not prompt a response model with the full schema. The schema is
state storage. A prompt is a temporary scene instrument.
Projection is not the whole Ghostlight runtime. It is the local lens used by a
sequential agent loop. The larger machine gives a character awareness of its
surroundings, options, goals, and constraints; lets it act; resolves the action;
mutates state; and then moves to the next agent.
The projection layer turns durable state into a compact character-local pressure
model, then into prompt prose. The prose should describe what the character
knows, wants, fears, protects, misreads, can physically do, and sounds like right
now. It should not recite taxonomy labels unless a debugging mode explicitly asks
for them.
For the sequential runtime target, see
`docs/architecture/sequential-agent-runtime.md`.
## Projection Pipeline
### 1. Select Speaker-Local Inputs
For one acting character in one scene, gather only the state that can affect this
turn:
- character identity and public role
- character canonical state
- character goals and protected values
- character memories selected by scene, relationship, and active pressure
- character perceived overlay of the listener or target
- character-to-listener relationship stance
- listener-to-character relationship stance only if the character has reason to infer it
- scene public facts
- scene hidden facts known to the character
- active public and private stakes
- culture, faction, or institution priors relevant to the scene
- physical affordances, constraints, distance, tools, weapons, bodies, access,
and social permissions relevant to possible action
Unknown or author-only material is omitted. Known secrets are included only as
character-local pressure: what the character is guarding, why it matters, and
what would happen if it leaked.
### 2. Compute Active Pressure
For each candidate variable, compute an effective scene activation from:
- canonical `current_activation`
- active scene pressures
- relationship stance toward the listener
- perceived state overlays
- salient memories
- active goals and protected values
- cultural or institutional scripts
The first implementation can use simple weighted rules. The important contract
is not the exact formula. The important contract is that the projection explains
why a variable matters in this scene.
Example:
```text
Cat has high shame sensitivity, mask rigidity, distance seeking, suspicion,
ironic distance, and acute shame. Oz is useful but opaque. The scene pressures
Cat to accept help while not looking dependent.
```
That intermediate pressure statement is more useful than a dump of numbers.
### 3. Detect Tensions
A good dialogue prompt needs contradictions, not just traits.
Derive scene tensions such as:
- wants help but resents needing it
- wants closeness but treats closeness as leverage against them
- wants to protect a secret while needing cooperation
- wants status while feeling exposed
- wants to be honest but expects honesty to be punished
- wants to soothe the listener without becoming available for inspection
Tensions are where verbal behavior starts to move.
### 4. Derive Response Affordances
Do not store defensive speech moves or action moves as canonical traits. Derive
likely response affordances from state intersections.
Responses can be:
- speech
- action
- silence
- withdrawal
- violence
- mixed speech and action
The renderer must not assume a line of dialogue is the right output. Sometimes
the right next move is leaving the room, taking someone's wrist off a console,
opening the airlock interlock, refusing to answer, or slapping someone in the
face because the relationship, status threat, bodily proximity, and inhibition
profile finally made language too slow.
Examples:
| Target behavior | State interaction that can produce it |
| --- | --- |
| Sarcastic deflection | Shame sensitivity + ironic distance + suspicion + exposure pressure + dry or pointed voice. |
| Sincerity evasion | Mask rigidity + low authenticity tolerance + strategic opacity + low emotional explicitness. |
| Emotional evasion | Distance seeking + withdrawal + acute shame or control pressure + low self-disclosure. |
| Banter as boundary | Humor + ironic distance + attachment pressure + distance seeking. |
| Verbal aggression | Hostility + grievance activation + perceived status threat + abrasive boundary. |
| Physical withdrawal | Threat sensitivity + distance seeking + low trust + available exit + low obligation to stay. |
| Coercive interruption | Control pressure + urgency + authority role + high stakes + reachable control surface. |
| Slap or shove | Acute shame or moral disgust + proximity + low inhibition + perceived violation + limited verbal trust. |
These are test targets, not prompt commands. The rendered prompt should describe
the pressure in ordinary language:
```text
If Oz presses too close to the real fear, Cat is likely to answer the useful
part of the question while making the emotional part sound ridiculous.
```
That gives the model a behavioral slope without saying `use sarcastic_deflection`.
For action, use the same rule:
```text
If Veyr calmly describes a possible person as a diagnostic residue while Sella
is close enough to touch him, Sella may stop negotiating and physically break
the rhythm of the room. This should be rare, costly, and grounded in her care
ethic under overload, not random spice.
```
### 5. Compile Projection Controls
Some failures are not fixed by louder prompt scolding. If the response model
keeps adopting the wrong moral frame, inverting who holds an object, giving a
character authority they do not have, or resolving a hidden fact too early, the
projection artifact is missing control structure.
Before rendering prompt prose, compile explicit controls for:
- `frame_controls`: whose language and moral frame the speaker should or should
not adopt
- `authority_boundaries`: what the speaker can order, request, propose, refuse,
or only witness
- `object_custody`: who currently holds the flag, weapon, terminal, document,
body, door, feed, or other consequential surface
- `required_semantics`: compromise meanings that must survive compression, such
as "hold remains but review starts now"
- `forbidden_resolutions`: unresolved facts or moral categories the turn must
not settle
These controls are still speaker-local. They are not author omniscience handed
to the response model; they are the projection layer preserving what the
speaker's current state, scene position, and institutional role allow.
Prototype story labs exposed the need for this layer. Projection controls catch
cases where a responder adopts the wrong moral frame, assumes authority it does
not hold, moves an object it cannot access, or resolves a disputed category too
early. In the current Pallas fixture, that means controls for who can certify a
strike, who can touch Kappa service ports, what evidence counts as sentience
rather than mere coordination, and which AU liability phrases are character
speech versus institutional pressure.
### 6. Render Prompt Sections
A response prompt should be compact and scene-bound.
Recommended sections:
- `Speaker`: who they are in this scene
- `Setting`: where and when the scene is happening, including only historical
or cultural context the speaker should have
- `Background`: speaker-local origin, role, class, culture, or embodiment
context needed to write the turn
- `Known Facts`: only speaker-local facts
- `Current Stakes`: what changes if this goes well or badly
- `Active Inner Pressures`: the strongest motives, fears, wounds, and needs
- `Relationship Read`: how the speaker currently interprets the listener
- `Tensions`: contradictions the speaker is managing
- `Action Affordances`: what the speaker can physically do and what it would
cost
- `Projection Controls`: frame, authority, object-custody, required semantic,
and forbidden-resolution constraints that must survive rendering
- `Voice Surface`: baseline speech texture for this moment
- `Likely Response Moves`: ordinary-language pressure outcomes, not schema labels
- `Do Not Invent`: boundaries about facts absent from the pack
- `Task`: the narrow generation request
`Do Not Invent` is not a replacement for positive context. It only tells the
model not to create new facts beyond the pack. It should not list author-only
secrets. If the dialogue model needs setting, historical, cultural,
socioeconomic, or embodiment context to write the line, put that context in the
positive prompt text instead of hoping the model infers it from source refs it
will not receive.
### 7. Generate, Then Evaluate
Generation and evaluation are separate passes.
The response model receives the rendered prompt and writes from the character's
local pressure model. A later evaluator can check whether target emergent
behaviors appeared when the state predicted them. The output may be dialogue,
action, silence, or a beat combining them.
Evaluation questions can include:
- Did the line protect the speaker's known secret without naming unknown facts?
- Did the line reflect the active relationship stance?
- Did the line preserve the voice surface without flattening into a generic bit?
- Did the spoken text sound like something the character would actually say,
rather than the prompt contract reciting itself?
- Did operational, legal, and safety constraints stay mostly in action/state
fields unless the character has an in-scene reason to say them aloud?
- If the output used action, was the action justified by physical affordances,
relationship pressure, inhibition, and stakes?
- If the output avoided speech, was silence or movement more plausible than
another line?
- Did a target defensive behavior appear when the pressure profile called for it?
- Did the response avoid a target behavior when the pressure profile did not call for it?
The evaluator should cite the state interactions that justify its read.
## Example Projection: Cat To Oz
Input pressures:
- Cat needs Oz's help with the taxi.
- Cat suspects Oz is a mask for a person.
- Cat has high suspicion, distance seeking, shame sensitivity, mask rigidity,
abrasive boundary, ironic distance, dry voice, pointedness, and profanity.
- Cat's relationship stance toward Oz includes low trust, moderate respect,
high fascination, and active expectation of betrayal.
- The scene makes Cat look dependent if she accepts help too openly.
Rendered pressure prose:
```text
Cat wants the repair, but accepting help makes her feel handled. Oz is useful,
which makes the secrecy more irritating, not less. Cat should probe for the
person behind the drone while pretending she is only annoyed about the repair.
If Oz gets too close to her fear of dependency, Cat is likely to answer the
practical issue and make the emotional issue sound stupid.
Voice for this turn: dry, quick, pointed, plainspoken, low verbal warmth, low
emotional explicitness, some profanity. Humor can appear as a boundary, not as
friendliness.
```
Possible generated behavior:
```text
"Sure, let the mystery box fix my relay. That's never how the paperwork starts."
```
That line is produced from the interaction between need, suspicion, shame
pressure, and dry pointed voice in the same scene.
## Example Projection: Oz To Cat
Input pressures:
- Oz wants to help without exposing the operator.
- Oz reads Cat as suspicious, dangerous, and potentially protective.
- Oz has high mask rigidity, strategic opacity, withdrawal, hedging, coded
politeness, register switching, listening responsiveness, and low profanity.
- Oz's relationship stance toward Cat includes respect, fear, fascination,
moderate expectation of care, and moderate expectation of betrayal.
- The scene rewards usefulness but punishes exposure.
Rendered pressure prose:
```text
Oz wants Cat to accept the repair and stop inspecting the mask. Cat's suspicion
is dangerous because it is accurate enough to matter. Oz should stay useful,
slightly warm, and evasive. If Cat asks directly about the operator, Oz should
redirect toward the immediate repair or answer in a way that preserves the mask
without making the evasion look like panic.
Voice for this turn: controlled, concise, technically precise, gently polite,
low emotional explicitness, careful verbal warmth, frequent hedging when the
subject approaches the operator.
```
Possible generated behavior:
```text
"The relay first. Biography after we stop your meter from eating itself, maybe."
```
The line withholds the secret because Oz's goals, mask rigidity, relationship
fear, and scene stakes converge.

View file

@ -0,0 +1,209 @@
# Sandboxed Responder Packets
Responder packets are the handoff surface between the omniscient coordinator and
a character-local responder model or one-turn worker.
The coordinator is allowed to know the scene, the branch, hidden state, source
material, pending hooks, and author intent. The responder is not. The responder
gets one packet, acts as one character, and returns a visible action plus any
private interpretation it can justify from that packet.
No clairvoyance. No undeclared repo spelunking. No "I accidentally remembered
the whole chat" aristocratic nonsense.
## Generation Lanes
Responder examples have two valid lanes.
`packet_only` is the runtime-parity lane. The responder sees the packet,
including any curated source excerpts the coordinator or lore retriever placed
there. It does not search the lore archive. Use this lane to test whether a
future game-runtime responder can act from bounded scene context.
`retrieval_augmented` is the lore-absorption lane. The coordinator or a
dedicated retrieval worker searches a declared AetheriaLore scope, then builds a
visible packet from the selected refs. The responder still answers only from the
packet it receives. Use this lane to generate stronger Aetheria-native final
responses and to bake setting assumptions, faction pressures, species
affordances, and tone into later fine-tuning data without pretending the
character model did autonomous scholarship in the walls.
There are two retrieval-augmented modes:
- `coordinator_scoped_retrieval`: the coordinator or retriever consults lore,
then hands the responder a bounded packet. The responder has no repo access.
- `responder_scoped_repository_search`: the responder itself is explicitly
allowed to inspect the declared lore scope before answering. This is the
research-enabled lane. The packet must include a `Required Lore Research`
section, `allowed_scope`, and `research_instructions`.
In `responder_scoped_repository_search`, the responder must consult the seed
docs before answering and may follow relevant links inside the traversal policy.
It should use retrieved lore to constrain behavior, affordances, institutions,
vocabulary, material stakes, territory, social movements, and local assumptions.
It must not use research access to infer hidden state, author-only answers,
future branches, or private motives outside the packet. The output capture must
preserve consulted seed refs, followed refs, a research trace, and a brief
research summary. No consulted refs means the research-enabled response failed
at the first hurdle, tragic but at least easy to grade.
`research_trace` is the receipt surface. Each entry records the query or lookup,
source ref, source path, chunk index, line range, extracted constraint, and how
that constraint shaped the response. Trace status matters:
- `runner_captured`: the runner or retrieval worker captured the lookup as it happened. This is required for accepted gold research-enabled captures.
- `coordinator_reconstructed`: the coordinator rebuilt the trace after the fact from source search and review. Useful draft data, not proof of the responder's actual research path.
- `responder_reported`: the responder claimed the trace directly. This is texture, not audit.
Training-time research scope should be materially wider than runtime context.
The point is not to feed every responder a lore pantry forever. The point is to
let high-quality training samples absorb the world: PSC risk language, Jovian
territorial texture, resident factions, linked social movements, technology
constraints, and the odd little institutional grudges that make the universe
smell lived in. Runtime can later feed compact retrieval. Fine-tuning should
have already eaten its vegetables.
Do not mix these silently. Retrieval-augmented output can be excellent training
material for an Aetheria-tuned responder, but it does not prove packet-only
runtime competence. Packet-only failures are also useful because they reveal
which lore facts the retriever or projector needs to feed at runtime.
## Current Artifact
The packet seam is:
- `schemas/responder-packet.schema.json`
- `tools/validate_responder_packets.py`
- `npm run responder-packets:validate`
- `schemas/responder-output.schema.json`
- `tools/validate_responder_outputs.py`
- `npm run responder-outputs:validate`
Current live fixtures may not include responder packet examples at all times.
That is acceptable. The contract remains live; obsolete prototype captures do
not need to stay in the working tree once their lessons are distilled.
## Packet Contents
A packet contains:
- `generation_lane`: `packet_only` or `retrieval_augmented`
- `lore_access`: curated excerpts, coordinator-scoped retrieval, or
responder-scoped repository search
- `traversal_policy`: for responder-scoped research, the permitted link depth,
allowed repo prefixes, required seed refs, link classes to follow, and stop
conditions
- `local_context_prompt`: the projected character-local context
- `observed_event`: what the character can see or hear from the prior action
- `allowed_action_labels`: concrete action labels, including speech and non-speech moves
- `source_excerpts`: short source-backed facts the responder may use, copied
from active runtime retrieval requirements plus local packet invariants
- `output_contract`: the required JSON response shape and rules
- `hidden_context_audit`: refs and inference classes deliberately omitted
- `isolation_requirements`: how the worker/model must be run
- `packet_prompt_text`: the exact prompt text sent to the responder
The packet prompt must not expose raw state internals such as
`current_activation`, `plasticity`, raw dimension scores, coordinator rationale,
author-only answers, or future branch plans.
## Response Boundary
The responder may output:
- a visible action
- optional spoken text
- private interpretation explicitly framed as interpretation
- candidate state updates
- candidate relationship updates
- unresolved hooks
`spoken_text` is not the place to dump the packet contract. Characters should
not recite every legal, procedural, or safety constraint simply because the
packet made those constraints visible. Keep speech to what the character would
actually say under pressure. Put the full checklist in `visible_action`,
`state_update_candidates`, `relationship_update_candidates`, and the mutation
receipt.
Good future-institutional speech is compressed and situated:
```text
I hold the line shallow. Your bay, your mark; my ledger stays awake for every
cost the tide takes.
```
Bad future-institutional speech is the output contract wearing a mask:
```text
Bounded diagnostic contact only. No full intake, no custody, no personhood
finding, no cargo designation...
```
That second kind of line may be useful as a failure sample. It is not gold
responder behavior unless the scene specifically calls for formal oath,
courtroom language, ritual compliance, or someone deliberately reading terms
aloud.
Those candidates are not canonical. The coordinator, event resolver, appraiser,
state mutator, and reviewer decide what becomes truth.
## Training Use
Gold responder data requires declared isolation. Valid approaches include:
- a one-turn subagent started without inherited context
- an external model call that receives only the packet prompt
- a manual packet-only pass, clearly labeled as manual
Any coordinator rewrite, schema repair, lore correction, or leakage removal must
be preserved as a coordinator intervention. Repaired prose is useful training
data for the coordinator, but it is not raw responder behavior.
Retrieval-augmented responder data requires scoped lore access before the
responder acts. The coordinator, retrieval worker, or explicitly research-enabled
responder may search only the declared source scope, and the output capture must
list consulted refs and trace entries. The responder itself should not receive repo access unless
the artifact uses `responder_scoped_repository_search`, sets `no_repo_access` to
false, and includes visible research instructions in the prompt.
Retrieval can also make the packet worse if it floods the scene with faction or
event context and starves the character's own relevant pressures. Review must
verify that packet-visible history, role strain, and local relationships remain
available as behavioral pressure. The point is not to make a character
trauma-dump during a mission-critical exchange. The point is that the responder
cannot be shaped by a pressure it cannot see.
## Output Captures
A responder output capture preserves:
- the packet ref
- the coordinator artifact ref
- generation lane
- lore access mode and consulted refs
- the isolation method
- the exact visible input ref
- hidden context refs that were withheld
- raw responder output
- parsed responder output
- leakage audit
- coordinator review and intervention labels
The raw output must parse to the reviewed `parsed_output` exactly. If the
coordinator edits prose, repairs schema, removes leakage, or corrects lore, the
capture must say so directly.
For `responder_scoped_repository_search`, the raw responder object may also
include `consulted_refs`, `followed_refs`, `research_trace`, and
`research_summary`. Those fields should be preserved inside `parsed_output`
when the responder itself produced them; consulted refs, followed refs, summary,
trace status, and trace entries should also be mirrored into top-level
`lore_access` so the capture can be indexed without stripping the original
response. If the runner cannot expose a machine-verifiable tool-call or
retrieval transcript, label the sample as draft and say so in review notes.
Trust, but make the trust write receipts like everyone else.
The current packet builder consumes `runtime_retrieval_requirements` from the
projected local context. Packet-only source excerpts should come from the same
retrieval/projector seam that would feed runtime context. Static lore sandwiches
are useful only as rejected-path examples and emergency snacks.

View file

@ -0,0 +1,164 @@
# Future Schema Mechanisms
This note parks the useful pressure from `notes/evaluation.md` without treating
that critique as doctrine. The current v0 schema is still a character-state and
prompt-projection seam. The future runtime needs mechanisms that turn state into
changing social behavior.
## Current Boundary
The v0 agent-state schema should stay focused on:
- canonical character state
- directional relationship stance
- observer-local perceived overlays
- memories, goals, values, and scene-local dialogue packs
- enough event interpretation to support prompt projection
Do not inflate `schemas/agent-state.schema.json` into the whole simulator before
the projection examples prove what the runtime actually needs.
## Mechanisms To Preserve For Later
### Event Appraisal
Each participant needs a private read of the same event. A single exchange can
be betrayal to one agent, exposure risk to another, and flirting-with-knives to
a third. The current schema now allows `private_interpretations` on events as a
lightweight bridge.
Future work should make appraisal reusable enough to drive state updates,
memory retrieval, and action affordances.
### State Transitions And Consequences
`mean`, `plasticity`, and `current_activation` describe state. They do not yet
define how state changes. Future transition machinery needs:
- activation decay
- event-driven deltas
- sensitization or habituation
- effects on related variables
- rules for when `mean` changes versus only `current_activation`
The current `event_effects` field is a projection-facing placeholder, not the
final transition engine.
### Structured Beliefs And Secrets
String beliefs are useful for authoring, but simulation needs structure:
- holder
- subject
- claim type
- confidence
- evidence
- visibility
- emotional charge
- whether the belief is private, shared, public, rumor, false, or contested
Secrets need similar structure: owner, known-by, suspected-by, exposure cost,
protective strategies, and what can leak through action.
### Action Affordances
The system needs to choose among possible moves instead of jumping from state to
dialogue. Future projection examples should expose candidate affordances such
as probing, evading, bargaining, softening, escalating, repairing, threatening,
or walking away.
The important rule: affordances should be justified by goals, appraisals,
capabilities, costs, relationship stance, and scene pressure.
### Conversation State
Dialogue needs explicit continuity:
- active topic
- open questions
- evasions
- accusations
- face-threats
- commitments
- promises, refusals, and ruptures
- who has the floor
This belongs close to projection because it changes turn shape immediately.
### Resources And Capabilities
Cat's debt, taxi condition, mobility, and institutional exposure are not just
backstory. Oz's drone integrity, signal secrecy, anonymity, and physical safety
are not just flavor. Future schemas need concrete resource and capability
fields so desire does not masquerade as ability.
### Dyads And Shared Relationship History
Directional relationship stance should remain. A separate dyadic layer may also
be needed for shared history:
- relationship type
- phase
- shared events
- shared secrets
- unresolved ruptures
- rituals
- dependency structure
- communication pattern
Do not collapse this into one symmetric relationship object. People are not
that tidy unless the author is lying to save RAM.
### Social Knowledge, Reputation, And Institutions
The Aetheria setting needs institutions with teeth: sanctions, authority,
surveillance, reputation effects, access control, role expectations, and public
knowledge. Future work should distinguish private belief, shared belief, public
fact, rumor, common knowledge, and institutional record.
### Memory Retrieval
Memories need more than salience and confidence once the runtime starts moving:
- recency
- emotional charge
- retrieval triggers
- decay
- reconsolidation
- intrusive, avoided, cherished, rehearsed, or distorted status
- schema effects
- whether another agent knows the memory
The current memory bundle is enough for the first projection seam. It is not yet
the memory engine.
### Embodiment And Nonverbal Channels
Oz proves this one immediately. The operator, drone body, observable channels,
leakage risks, and physical affordances are separate facts. Future projection
needs embodiment because social meaning leaks through movement, timing,
silence, distance, posture, and constrained bodies.
The projected-local-context seam now carries a first embodiment/interface
surface, but the canonical schema still lacks a proper reusable embodiment
model. Future work should promote body form, native habitat, movement channels,
assistive or translation infrastructure, and visible nonverbal signal channels
only after more projection examples prove the required shape.
For Aetheria faction work, embodiment projection must check the lore vault
before writing. If the faction page supports only general habitat aesthetics or
social affordances, keep generated action at that level and record the missing
ergonomic detail as a source-lore gap instead of inventing a confident local
interface.
When a blocking gap is found, patch the AetheriaLore source before treating the
detail as available to projection. Ghostlight can discover missing granularity
through scene generation, but the lore vault should become the durable source of
the elaboration.
## Promotion Rule
Promote a future mechanism into the main schema only after it has survived at
least one reviewed projection example or runtime slice where it clearly changes
the output. Until then, document it here, test it in examples, and do not let
the ontology grow antlers for sport.

View file

@ -0,0 +1,489 @@
# Sequential Agent Runtime
Ghostlight's near-term target is not a general-purpose life simulator.
The near-term product target is procedural interactive dialogue in the game
sense: branching scenes where choices can be speech, silence, movement, object
use, refusal, revelation, concealment, violence, withdrawal, trade, or any other
scene-valid action. "Dialogue tree" does not mean a tree made only of spoken
lines. It means an interactive branch structure for a character encounter.
Ink is the branch format for that structure. Ghostlight should not invent its
own dialogue-tree file format while Ink already exists and fits the job. See
`docs/architecture/ink-branching-scenes.md` for the integration contract.
Ghostlight should set up a scene, generate plausible things the protagonist or
NPCs could do, generate responses from the people present, and propagate the
consequences into world state, memory, belief, and social perception.
The long-term target is broader. The same classifiers, appraisers, and
projection models should eventually operate inside a simulated game world with
open-world contexts, iterated decisions, economic behavior, and major-faction
decision-making. Do not train a tiny dialogue-only creature that panics the
first time someone buys medicine, defects from a faction, or reacts to a
shortage. Also do not build the whole economy today. Both cliffs are stupid.
The runtime target for now is scene-local: choose which character is acting,
give that character local awareness, let them act, apply consequences, mutate
the relevant state, and continue until the scene produces a usable branching
scene tree, scene transcript, or branch scaffold.
Single-turn projection remains useful, but it is a microscope, not the animal.
## Runtime Shape
One runtime tick should look like this:
1. Select an acting agent from the initiative schedule.
2. Build that agent's local awareness.
3. Derive available actions and constraints.
4. Let the agent choose a response.
5. Resolve the action into an observable event.
6. Apply every affected participant's appraisal and state mutation for that
event.
7. Append the event record with participant-local interpretations.
8. Update initiative timing, reaction windows, and action recovery.
9. Select the next acting agent from the updated schedule, or end the scene.
The author sets the initial scene, player role if any, and high-level branch
constraints. The author should not need to decide every line, interruption,
withdrawal, refusal, or concession. That is the point of the machine.
Every generated character turn must be local to that character.
For branching scenes, this means protagonist/player options and NPC responses
are separate generation steps:
1. Build the protagonist's local awareness.
2. Generate plausible protagonist choices from only what the protagonist knows,
perceives, wants, can attempt, and can affect.
3. When one choice is selected, reduce it to observable action: words, silence,
gesture, movement, object use, resource spend, or other perceivable change.
4. Resolve the selected choice into an event visible to affected characters.
5. Apply reviewed participant appraisals and state mutations for that event:
hurt, threat, reassurance, obligation, resentment, trust movement, overload,
misread, world/object/resource changes, and scene-ending choices when they
occur.
6. Update initiative timing and select the next acting agent from the updated
schedule. The next agent may answer, walk away, escalate, comply, refuse,
call for help, attack, or end the interaction.
7. Generate that next action from the newly consolidated local state.
8. Store actor intent, listener appraisal, and durable interpretation
separately.
There is no privileged paired exchange inside the runtime. For authoring
convenience a branch artifact may show `player action -> NPC action`, but
Ghostlight should treat that as two turns stitched together, not one fused
response object. If Maer wounds Sella with a ledger threat, Sella's state
changes before she is considered as the next actor; she is not obligated to
produce a line just because the author expected one.
One-shot paired generation, where a model writes both the player's move and the
NPC response from a shared omniscient packet, is acceptable only as bootstrap
scaffolding. It should be reviewed and labeled as such. Do not treat it as the
target runtime behavior.
## Initiative Scheduler
Not every character gets a turn every round. There is no round.
Ghostlight needs a scene-local initiative scheduler: a mechanical organ that
tracks who is ready to act, who is still recovering from a previous move, and
who has a valid interrupt window. It sits after event appraisal/mutation and
before the next local-awareness projection.
The scheduler does not decide what a character wants. It decides when a
character is eligible to act.
Core rule:
```text
observable event
-> participant appraisals and reviewed mutation
-> initiative/reaction update
-> next actor selection
-> next actor projection
```
Every affected participant still appraises every event before the next action
is generated. Initiative controls action opportunity, not perception. A hostage
can be terrified by a negotiator's move immediately; that does not mean the
hostage gets a full action before the hostage-taker, unless the schedule or a
reaction window gives them one.
Each participant carries initiative state:
- `initiative_speed`: how quickly the participant recovers action opportunity
in this scene.
- `next_ready_at`: the scene-clock time when they can take a normal action.
- `reaction_bias`: how likely they are to seize urgent interrupt windows.
- `interrupt_threshold`: how strong a trigger must be before they interrupt.
- `current_load`: stress, injury, cognitive burden, physical constraint, or
interface lag that makes action harder.
- `status`: whether they are active, blocked, withdrawn, incapacitated, or
offscreen.
Actions carry recovery shape:
- `action_scale`: micro, short, standard, major, or committed.
- `base_recovery`: how much scene time the move normally costs.
- `initiative_cost`: how much local opportunity the actor spends.
- `interruptibility`: whether others can break in while the action unfolds.
- `commitment`: how much the actor is locked into the move once begun.
The scheduler first checks reaction windows created by the last event. A
reaction window is an interruptible opportunity, not a free omniscient response.
It must name the triggering event, eligible actors, urgency, allowed action
scales, expiry, and local basis. If no reaction clears threshold, the scheduler
selects the active participant with the lowest `next_ready_at`.
Recommended reaction readiness:
```text
reaction_readiness = (reaction_bias * urgency) - current_load
```
An interrupt can fire when `reaction_readiness >= interrupt_threshold`, the
actor is eligible for the window, and the actor is not blocked. Ties are broken
by earlier `next_ready_at`, higher `initiative_speed`, then stable actor id.
After an accepted action, update the actor:
```text
recovery = base_recovery / max(initiative_speed, minimum_speed)
next_ready_at = max(scene_clock, current next_ready_at) + recovery
```
Major or committed actions should usually create larger recovery, stronger
counterplay windows, clearer visible cues, or more durable consequences. Short
actions let a character stay nimble but should not accomplish everything. A
one-word refusal, a glance to a camera, or stepping behind a marked line can be
fast. A public confession, tackle, system override, or hostage release costs
more initiative because it changes the scene.
The coordinator may override the scheduler only by recording the override
reason. Useful overrides include player-facing pacing, hard scene boundaries,
or a game-engine rule that is not yet represented in the schedule. An
unrecorded override is not training-ready coordinator data.
For Ink, the scheduler can be collapsed into branch structure for readability,
but the sidecar should preserve the initiative receipt when the fixture claims
training value. Player choices may be presented together even if, inside the
runtime, they represent alternative actions available at one scheduled moment.
For game use, the loop should be able to emit:
- a scene transcript
- Ink scenes with candidate player choices, including speech and non-speech
actions
- compiled Ink JSON
- NPC response branches
- state/memory/social-perception deltas for each branch
- unresolved hooks for later scenes
If a feature does not help produce those artifacts, it belongs in the parking
lot until the smaller machine works. If a field or label preserves information
needed for later open-world behavior, consumer choice, faction pressure, or
iterated decisions, keep it visible in the training artifact even if the first
prototype does not simulate it.
## Local Awareness
An acting agent needs more than a dialogue cue.
The local awareness pack should include:
- where the agent is
- who is present
- what the agent can perceive
- what the agent knows, believes, suspects, and misreads
- what the agent wants right now
- what the agent is protecting
- what resources and tools are available
- what social, legal, physical, and emotional constraints apply
- what actions are available
- what each action might cost
- what facts are unresolved
- what the agent is allowed to mutate directly
This is not reader legibility or global story authorship. This is operational
state for scene-local dialogue and action. If the scene itself is abstract and
byzantine, the agent may still behave correctly inside that mess. Do not shove
general prose quality or whole-plot design into this layer.
## Branch Choices And Action Selection
The action model must allow more than speech.
For a protagonist scene, Ghostlight should be able to generate several
plausible choices for what the protagonist can do next. For example, if Cat is
interacting with another character, the generated choices might include:
- say the honest thing badly
- deflect with a joke
- leave before the other person can answer
- hand over evidence
- hide evidence
- accept help while pretending not to need it
- threaten to walk
- spend scarce money
- break a device
- touch someone gently
- slap someone
Those choices should come from Cat's state, scene affordances, resources,
relationship pressure, and goals. They should not be generic verbs glued to a
persona card. Each branch should carry expected consequence surfaces, such as
trust risk, reputation risk, resource cost, information exposure, or future
obligation.
Keep the action vocabulary small and concrete. Do not turn every possible
speech act into a separate tool. `ask`, `refuse`, `offer`, `reveal`, `conceal`,
and `request authority action` are usually communicative intentions carried by
`speak`, `gesture`, `show_object`, `withhold_object`, or `use_interface`.
Initial action primitives:
- `speak`
- `silence`
- `move`
- `gesture`
- `touch_object`
- `block_object`
- `use_object`
- `show_object`
- `withhold_object`
- `transfer_object`
- `spend_resource`
- `attack`
- `wait`
The agent should choose from affordances produced by the interaction of state,
scene, role, resources, relationship, and pressure. The action should not be
commanded by a prompt label. If a character hits someone, it should fall out of
proximity, inhibition, grievance, fear, role permission, and exhausted trust.
An action proposal may include intended function:
- ask
- refuse
- offer
- threaten
- comfort
- reveal
- conceal
- request
- promise
- accuse
- redirect
That intention is not guaranteed truth. A listener can misread it. A character
can claim they were offering help while the listener perceives a threat. A
character can intend restraint and still be read as contempt. The event should
store observable action separately from actor intent and participant-local
interpretation.
Example:
```json
{
"action_type": "speak",
"spoken_text": "Name the cost before you call it caution.",
"actor_intent": "force_cost_acknowledgment",
"observable_cues": ["calm voice", "still posture", "public challenge"]
}
```
Isdra may perceive that as procedural intimidation. Maer may experience it as
stewardship. The classifier/appraiser later learns from those differences.
## Mutation Authority
Characters should only mutate what their action can actually affect.
Examples:
- A character can mutate their own beliefs, memories, current activation, goals,
and presentation pressure.
- A character can create observable events others may perceive.
- A character can change world objects they can physically or institutionally
access.
- A character can affect relationship stance, but the other side's relationship
update is mediated through that other agent's appraisal.
- A character can propose an order they lack authority to issue.
- A character cannot silently rewrite another agent's private state, decide an
unresolved hidden fact, or seize institutional authority they do not possess.
This is where the projection-control layer belongs. Frame ownership, authority
boundaries, object custody, required semantics, and forbidden resolutions are
not decorative prompt warnings. They are mutation gates.
## Event Resolution
Generated action is not state mutation by itself. It becomes an event proposal.
The resolver should decide:
- what observable event occurred
- what world objects changed
- what resources were spent or created
- which agents perceived the event
- how each participant appraised it
- which beliefs were reinforced, challenged, or created
- which memories were written
- which relationship dimensions changed
- which current activations changed
- which options opened or closed
In a branching scene tree, each branch should carry its own consequence packet.
If Cat chooses to lie, accept help, shove someone, spend money, or reveal a
secret, that branch should record what changed and what might matter later.
Consequences can include:
- trust lost or gained
- suspicion raised or lowered
- obligation created
- debt created or paid
- resource spent
- information revealed or concealed
- relationship rupture or repair
- faction or reputation pressure changed
- future scene hook opened or closed
The first implementation can be conservative and partly deterministic. A bad
resolver will build narrative sludge faster than a bad line of dialogue will.
No pressure. Tiny little bear trap.
The fuzzy parts should stay manual at first.
Do not invent a fragile rule-based social psychology engine just to feel
automated. Early runtime passes should manually review or author:
- participant appraisals
- relationship deltas
- belief updates
- memory writes
- activation changes
- ambiguous intent classification
- whether a listener misread or correctly read a move
Those manual decisions are the training data. Save the observed action, actor
intent, listener perception, accepted mutation, rejected mutation, and reviewer
notes. Later, train classifiers and appraisers on that corpus. The prototype
should automate mechanical legality and visibility first, not pretend it has
already learned human interpretation from twelve examples and a strong coffee.
## Scope Boundary
Build interactive branching scene generation first, but shape the data for later
open-world use.
In scope now:
- scene-local agent turns
- player/NPC dialogue and action branches
- local action proposals
- event records
- manual reviewed appraisals
- memory, belief, relationship, and activation deltas
- branch consequences that can be carried into later scenes
- action intent and listener interpretation labels
- resource, price, scarcity, obligation, reputation, and faction-pressure tags
when they affect the decision
- reviewed examples of consumer-like choices and institution/faction pressure,
even when the prototype only records them as scene state
Training-data obligations now:
- Preserve why an agent chose, bought, refused, hoarded, traded, complied,
defected, concealed, punished, or helped.
- Preserve what the agent believed the cost, risk, status effect, and social
consequence would be.
- Preserve faction and institution pressures as perceived by the agent, not
only as objective setting lore.
- Preserve iterated-decision context: promises, debts, prior betrayals,
reputation, memory, scarcity, and expected future contact.
Out of implementation scope for now:
- full autonomous world simulation
- economy simulation loops
- city-scale scheduling
- long-horizon plot invention without author scaffolding
- autonomous factions moving offscreen as full actors
- physical simulation beyond scene affordances
- automatic social appraisal rules pretending to be trained classifiers
The eventual storyline generator, economic behavior model, and faction decision
model should grow out of reliable branching-scene machinery and reviewed
decision data, not the other way around.
## Projection's Narrower Job
Projection still matters.
Its job is to turn structured state into a compact local action context for one
agent at one moment. It should support action choice and response rendering. It
should not be treated as the whole story engine.
Projection artifacts are still useful for:
- debugging whether local state produces plausible action
- training a smaller projector
- preserving reviewed examples
- testing state boundaries and mutation gates
- producing dialogue or action beats inside a larger runtime
But the production path is sequential:
```text
agent state + scene state
-> local awareness
-> action affordances
-> agent response/action
-> event resolution
-> participant appraisals and state mutation
-> initiative/reaction scheduling
-> next actor selection
-> next agent action from updated local state
```
## First Prototype
The first implementation should not try to simulate a city. Calm down, wizard.
Prototype one small Ink-backed branching scene loop, using the current live
fixture or a new source-grounded fixture:
1. Load a fixture agent-state artifact, such as
`examples/agent-state.pallas-species-strikes.v0.json`.
2. Select one scene and one acting agent.
3. Build a local awareness packet from existing state.
4. Compile projection controls.
5. Generate or author branch choices and NPC responses from local context.
6. Materialize the branch plan through the Branch Compiler as Ink, sidecar
annotation, compiler notes, consequence variables, and visual prompt handles.
7. Compile the Ink scene to JSON with `inkjs`.
8. Save a sidecar training annotation that maps Ink branches to Ghostlight
state basis, actor intent, consequence surfaces, and mutation policy.
9. Apply selected branch consequences only through reviewed mutation:
- Ink variables can track local playthrough facts
- canonical state changes require resolver/reviewer approval
10. Run IF artifact review before acceptance:
- every material variable is read later or marked telemetry-only
- every material choice changes affordance, risk, callback, visual state, or
outcome
- folds preserve consequence rather than hiding it
11. Manually review fuzzy participant appraisals and state effects each turn:
- update every affected participant before selecting the next actor
- add one memory or belief update
- adjust one or two `current_activation` values
- update one relationship stance if justified
- record the rationale as training data
12. Validate the resulting fixture.
The goal is not literary quality or autonomous plot generation. The goal is to
prove that a character can drive a scene beat, produce branchable dialogue or
non-dialogue action, trigger plausible responses from other characters, and
leave memory, relationship, social perception, and scene state slightly
different behind them.

View file

@ -0,0 +1,539 @@
# Soft Model Training Artifacts
Ghostlight has two kinds of machinery.
Some parts should be boring code:
- schema validation
- visibility filtering
- action legality
- object custody
- resource accounting
- Ink syntax validation and compilation
- event append
- provenance checks
- deterministic context slicing
- mechanical mutation gates
Other parts are soft. They require judgment, taste, social inference, cultural
pressure, and narrative sense. Do not fake those with brittle rules just because
a function-shaped hole is sitting there looking smug.
Soft parts should emit reviewed training artifacts from the beginning. Manual
review is not a temporary embarrassment. It is the dataset.
## Core Rule
If a decision cannot be made reliably by deterministic code, Ghostlight should
save it as a training example.
That includes accepted outputs, rejected outputs, repaired outputs, and weird
half-successes. Especially weird half-successes. They are where the machine is
showing the fracture line.
A useful soft artifact should preserve:
- the deterministic input slice
- the model or human output
- the reviewer decision
- accepted labels
- rejected labels, if any
- rationale grounded in state, scene, culture, memory, relationship, and lore
- failure notes
- provenance to source lore or fixture state
- whether the artifact is grounded canon, transition-grounded, procedural
branch material, or promoted branch material
## Sandboxed Responder Rule
Responder training data must not be authored from an omniscient view.
The coordinator may know the whole scene, hidden state, future branch plan,
author constraints, and private facts. The responder model we eventually train
will not. If a responder example only works because the authoring agent quietly
used omniscient context, the artifact is poisoned. It teaches the future model to
perform an impossible trick, then looks offended when reality asks for receipts.
For gold responder or character-action data:
- the coordinator prepares the scene and selects the acting character
- the projector emits the exact character-local context
- the responder is run from only that projected packet, visible event, allowed
action space, and any explicitly included source excerpts
- the responder does not see other agents' private state, hidden canonical
facts, author-only plot beats, future branch plans, or coordinator rationale
- any isolated worker or sub-agent used for responder generation should be
started without inherited conversation context when the harness supports it
- repo or lore browsing is disabled for the responder unless the projected
packet explicitly includes the excerpts or references the future model will
also receive
- the coordinator reviews after generation, not during it
Artifact requirements:
- `responder_visible_input`: the exact prompt or structured packet the responder
saw
- `responder_raw_output`: the unedited output
- `reviewed_output`: the accepted or repaired turn
- `review_status`: accepted, accepted-as-draft, useful-needs-revision, rejected,
or superseded
- `coordinator_interventions`: every edit, repair, deletion, or normalization
made after raw generation
- `hidden_context_refs`: ids or paths for context intentionally withheld, not
the hidden content itself
- `leakage_audit`: checks for private-state leakage, future-branch leakage,
author-only leakage, raw-state soup, prompt-constraint parroting, and
impossible body or object knowledge
Coordinator-written replacement text can be valuable, but it is not raw
responder output. Mark it as coordinator repair or frontier-curated final. Do
not smuggle repaired prose into the responder corpus as if the sandbox produced
it. That is how a dataset learns to cheat with a straight face.
## Training-Ready Scene Loop Bundle
A scene loop is the smallest useful sequential training unit before branch
compilation. It is not enough to have a nice transcript. The bundle needs the
machine trail that made the transcript possible.
A training-ready scene-loop bundle contains:
- scene-local digest or lore slice
- initial scene/world state
- current character state refs for every participant
- projected local context per acting turn
- responder packet per acting turn
- raw responder output capture per acting turn
- observable event record per accepted action
- participant appraisal receipt for every affected participant
- reviewed mutation receipt for character state, memory, relationship,
perceived overlays, object state, scene/world state, and unresolved hooks
- coordinator turn receipt carrying next-beat selection, world-state reads,
proposed deltas, glue prose, and review labels
- review summary naming which organs are training-ready, reference-only, or not
training data
Training readiness is per organ. A bundle can be useful for responder,
appraiser, mutator, relationship/perception updater, and coordinator training
while still being useless for branch compiler, IF artifact reviewer, or visual
replay training. Count only the receipts that actually exist.
`pallas-training-loop-v0` is the first pilot of this convention. It rebuilds one
Pallas threshold beat as a separate derivative and leaves the Pallas reference
fixture immutable.
## Soft Organs
These organs should be treated as model-training targets, not permanent
hand-authored rule nests.
### Coordinator Or Story Runtime
Glues scenes together, maintains world state, chooses which machinery runs, and
emits the connective prose around character turns.
In training-shaped loops, the coordinator should be sandboxed as its own
promptable organ. A coordinator worker receives only the scene state, accepted
receipts, unresolved hooks, available machinery, branch affordances, tonal
intent, author constraints, and game-engine-shaped state surfaces it is allowed
to use. It then proposes the next beat, acting character, machinery calls, glue
prose, and candidate state changes.
Codex remains the meta-coordinator while the system is being built: it scopes
the task, prepares the coordinator-visible input, launches the sandboxed
coordinator, reviews the output, labels interventions, and wires structured
state between organs. Codex-authored coordinator artifacts are useful draft
material, but they are not raw coordinator training data unless the coordinator
input/output seam was actually sandboxed.
This is a trainable artifact too. Do not let coordinator judgment evaporate into
chat history just because a large model is currently doing the stage management
with jazz hands and plausible deniability.
Training artifacts:
- scene setup receipts
- exact coordinator-visible input packet
- raw sandboxed coordinator output
- parsed and reviewed coordinator artifact
- coordinator plans
- selected next scene or next beat
- chosen acting agent
- selected tools or soft organs invoked
- world-state refs read
- world-state changes proposed
- unresolved hooks carried forward
- branch merge or branch split decisions
- authored glue prose
- rejected glue prose
- reviewer notes about pacing, legibility, state continuity, lore grounding,
game usefulness, and machinery misuse
The coordinator output should be shaped for a future game engine. Even while the
prototype lives in model imagination and authored prose, it should preserve
structured refs for:
- scene id
- location id
- time or sequence marker
- participating agents
- active objects
- active resources
- public world facts
- unresolved facts
- branch flags
- event log refs
- state fixture refs
- consequences awaiting review
Glue prose is part of the artifact. It is not canonical state, but it is useful
training signal for how scenes are bridged, how pressure is restated without
exposition sludge, and how a procedural branch becomes readable narrative.
Coordinator and narrator intelligence are allowed to exceed character
intelligence. This is how opaque high-context scenes become legible without
flattening the characters. If a Lucent negotiator, influencer, or feed operator
is performing layered reputation warfare, the coordinator may explain the
maneuver in reader-facing prose, but the explanation is not automatically
available to the character who performed or witnessed it. Character cognition
still comes from projected state: intelligence, social acuity, training,
cultural scripts, stress, and blind spots.
The coordinator may propose state changes, scene transitions, and tool calls. It
does not bypass deterministic gates. It does not silently decide hidden facts. It
does not mutate another organ's private output after review. It conducts the
little orchestra; it does not eat the violins.
### Projector
Turns canonical state, perceived state, memory, culture, scene pressure, and
affordances into character-local operating context.
Training artifacts:
- projected local context records
- projection examples
- prompt-rendering audits
- leakage failures
- source-grounding failures
The projector does not write final prose. It prepares the local pressure surface
for an acting model.
### Character Agent Or Responder
Chooses what a character does next from local awareness.
This includes speech and non-speech action. The agent may speak, wait, leave,
hide evidence, reveal evidence, spend a resource, attack, comply, defect,
bargain, comfort, or do nothing useful because people are built badly.
Training artifacts:
- exact responder-visible input packet
- hidden context refs, without hidden content
- raw responder output
- coordinator interventions or repairs
- action-choice receipts
- candidate branch sets
- accepted and rejected character turns
- spoken text when speech occurs
- observable non-speech action
- actor intent
- constraints used
- constraints violated
- reviewer notes about voice, lore, embodiment, agency, and scene fit
This model should be fine-tuned or adapted on a corpus of strong Aetheria
responses spread across the timeline. The goal is not a bolt-on lore module.
The goal is for the responder to internalize the shape of the setting: what
people assume, what institutions feel like, which pressures are normal, which
metaphors appear, what bodies can do, and how the universe tends to hurt people
while insisting it is only doing math.
That reduces lore handholding at runtime. It does not remove character-local
context. The tuned responder still needs the specific room, speaker, listener,
state, memory, affordances, and known facts for the current turn.
### Appraiser
Reads an observable event from one participant's local perspective and proposes
how that participant interprets it. This organ is not only for fictional scene
mutation. It is also one of the core paths by which VoidBot becomes better at
reading people: noticing what a human did or said, forming a grounded opinion
without pretending certainty, and revising that opinion as evidence accumulates.
Training artifacts:
- observed event
- participant-local knowledge
- private interpretation
- misread or correct-read label
- emotional appraisal
- relationship implications
- belief implications
- memory candidate
- human-facing opinion update when applicable: trust, concern, irritation,
respect, caution, attachment, boundary pressure, expected future behavior, and
what evidence would change the read
- reviewer corrections
The appraiser should not know canonical private state except where that
participant knows or believes it. Fundamental attribution bias belongs here. For
VoidBot, this means the model must distinguish an observed human behavior from a
durable claim about who that human is, and should preserve uncertainty, repair
signals, and evidence needed to revise the opinion later.
### State Mutator
Turns reviewed appraisal into bounded state changes.
Training artifacts:
- prior state refs
- event refs
- appraisal refs
- proposed state deltas
- accepted deltas
- rejected deltas
- mutation authority notes
- reviewer rationale
The early system should keep this manual and audited. Later a model can learn
common deltas, but deterministic gates still own schema, bounds, and authority.
### Relationship And Social Perception Classifier
Updates perceived overlays and relationship stance from repeated evidence. This
classifier should serve both Aetheria agents and VoidBot's long-running human
relationships; the same machinery that lets Sella update her read on Maer should
also let VoidBot update its read on a specific user without collapsing into
either sycophancy or a permanent first-impression mugshot.
Training artifacts:
- prior relationship stance
- event and appraisal history
- proposed movement
- confidence movement
- stale or reinforced impressions
- prejudice, stakes, memory, and social-reinforcement drivers
- reviewer notes
This is where confidence belongs. Do not grow a second correction-resistance
organ because the first one got lonely.
### Aetheria Responder Corpus
Training artifacts:
- source-grounded scene examples
- factional speech and pressure examples
- institution decision examples
- embodiment/interface examples
- negative examples where generic sci-fi assumptions were rejected
- reviewer notes tied to AetheriaLore paths
This corpus trains the character agent/responder directly. It is how a chosen
base model learns Aetheria's native assumptions rather than waiting for every
prompt to drag the whole lore vault behind it in a little wagon.
The tuned responder should learn the smell of the setting: Dominion severity,
Aya-coded care pressure, Navigator embodiment, corporate certification culture,
insurer cruelty, upload personhood fights, factional misreads, and the dry
little horrors of systems pretending to be neutral.
It still must receive character-local context at runtime. Fine-tuning gives it
priors, not omniscience.
### Branch Compiler
Turns scene intent, reviewed branch candidates, consequence packets, fold plans,
and visual continuity requirements into playable Ink plus sidecar receipts and,
when illustrated replay is in scope, a separate visual plan artifact.
The Branch Compiler is trainable because good branching structure is not just
syntax. It needs to decide where choices belong, which consequences can fold
into compact variables, which choices deserve route splits, which callbacks must
exist later, and how visual modifiers attach to actual state. Deterministic code
can validate Ink and refs. It cannot, by itself, know whether a branch feels
meaningful or whether convergence is lying.
Training artifacts:
- fixture brief
- source-grounded lore digest refs
- input branch candidates
- reviewed consequence packets
- fold plan
- generated Ink
- generated sidecar
- generated visual plan
- compiler notes
- variable set/read map
- route split or fold rationale
- visual prompt handles
- reviewer findings and repair labels
The compiler does not own hidden character truth, participant appraisal, or
durable social mutation. It materializes structure from already reviewed or
explicitly marked draft material.
### IF Artifact Reviewer
Audits playable branch artifacts before they enter the accepted corpus.
This reviewer is separate from the branch compiler because compilers are very
good at producing valid little lies. The reviewer checks whether tracked state
actually matters, whether choices change future affordances or outcomes, whether
folds preserve callbacks, whether visual modifiers correspond to real branch
state, and whether endings read the variables the story told us were important.
Training artifacts:
- Ink fixture under review
- sidecar under review
- coordinator artifact
- compiler notes
- validation output
- accepted/rejected decision
- severity-ranked findings
- failure labels such as `potemkin_state`, `cosmetic_choice`, `fake_fold`,
`missing_state_read`, `state_named_instead_of_checked`,
`visual_callback_missing`, and `ending_ignores_major_state`
- required fixes
- repaired artifact refs, when available
Early branch-and-fold reviews are seed material for this organ because they
caught decorative variables before they were allowed to become training sludge.
### Institution, Faction, And Consumer Decision Models
Eventually Ghostlight should support economic behavior and major faction
decision-making inside a simulated game world.
Training artifacts should preserve why agents:
- buy
- refuse
- trade
- hoard
- comply
- defect
- conceal
- reveal
- punish
- help
- lobby
- exploit
- delay
- sacrifice
The artifact should preserve perceived cost, scarcity, status pressure,
reputation risk, expected future contact, faction norms, institutional authority,
and moral injury when present.
Do not train a dialogue-only pet if the adult machine needs to decide whether a
clinic buys counterfeit cryo valves under blockade.
## Artifact Status
Soft artifacts should use explicit review status:
- `accepted`
- `accepted_as_draft`
- `useful_needs_revision`
- `rejected`
- `superseded`
Rejected artifacts remain valuable when the failure label is clear. A rejected
Navigator action that makes a cetacean walk across a human hallway is not trash.
It is a negative example with teeth.
## Training Corpus Families
Ghostlight should expect several related corpora rather than one heroic pile of
JSONL wearing a crown.
- projection corpus
- projected-local-context corpus
- coordinator/story-runtime corpus
- character-action corpus
- dialogue/prose response corpus
- participant-appraisal corpus
- state-mutation corpus
- relationship/perception corpus
- lore-grounding digest corpus
- Aetheria responder corpus
- Ink branch annotation corpus
- Branch compiler corpus
- IF artifact review corpus
- institution/faction/consumer decision corpus
- negative-example corpus
Some examples can belong to multiple corpora through references. Do not duplicate
truth; link artifacts by ids and source refs.
## Aetheria-Tuned Agent Model
The character agent itself is a training target.
A sensible long-term stack looks like this:
```text
AetheriaLore source corpus
-> source-grounded digests and scene fixtures
-> coordinator scene plans and glue-prose receipts
-> reviewed Ghostlight action/dialogue/appraisal artifacts
-> Aetheria-tuned responder or agent model
-> specialized coordinator/projector/appraiser/mutator students
-> deterministic runtime gates and validators
```
The Aetheria-tuned agent model should learn setting priors and voice ecology:
what institutions feel like, how factions misread each other, what bodies can do,
what pressure sounds like when nobody in the room has enough authority to fix the
machine.
It should not replace Ghostlight state. It should not decide hidden facts. It
should not mutate world state directly. It acts inside the local context and
affordances Ghostlight provides.
## Deterministic Gates Stay Deterministic
Training models does not remove hard gates.
Keep these as code:
- schema validation
- source provenance checks
- speaker-local visibility
- prompt leakage checks
- action enum validation
- body-affordance validation when source-backed
- mutation authority
- object custody
- resource and world-state legality
- Ink compilation
- artifact review status requirements
A model can propose. The runtime decides what is legal, what is visible, what is
accepted, and what becomes state.
## Near-Term Policy
During prototype work:
1. Keep fuzzy updates manual and reviewed.
2. Emit every model call as a receipt when it teaches something.
3. Store accepted and rejected examples with failure labels.
4. Prefer narrow artifacts over one vague transcript blob.
5. When a prompt fix reveals a reusable failure mode, promote it into validator
logic or artifact schema.
6. When a model repeatedly performs a soft judgment well, mark that organ as a
candidate for distillation or fine-tuning.
7. When it repeatedly fails in a patterned way, save the failures. The beast is
drawing us a map in crayon.
The point is not to automate everything today. The point is to stop losing the
manual intelligence we are already spending.

View file

@ -0,0 +1,646 @@
# Technology And Item Manifest Plan
Ghostlight future exploration should produce more than scenes. It should produce
the technical and economic knowledge Aetheria needs for gameplay: what items can
exist, who can build them, what they are made of, which assemblies can be
swapped, what supply chains they depend on, and which factions start with which
tech bases.
This is not separate from story generation. Major-player decisions, faction
births, branch pressures, and technological discoveries shape the economy. If a
branch discovers a better aetheric emitter, that should eventually change ship
builds, market dependencies, quest hooks, factory politics, salvage value, and
what a desperate mechanic can realistically bolt into a hull without creating a
beautiful obituary.
The economy is bigger than ship parts. Players moving freight should carry the
ordinary manufactured life of colonies too: habitat fittings, clinic equipment,
interface furniture, ritual goods, industrial consumables, food-processing
systems, maintenance packs, education kits, luxury nonsense, and all the other
things a settlement consumes without ever bolting them into a laser. A trade
economy made only of gear components would be a very fancy skeleton.
## Source Premise
Aetheria game design wants ships to be tools, homes, status symbols, and
material compromises. Customization is driven by components with distinct
behavior, manufacturers with priorities and specialties, and vessels tuned for
violence, trade, survival, logistics, or prestige.
Ghostlight should turn worldbuilding exploration into the data needed to support
that design.
## Player Fantasy
The deep gear model is not a loot-affix treadmill. The fantasy is provenance.
A player hunting for the ultimate laser should be able to trace the item through
its parts, makers, technologies, and accidents:
- the emitter assembly came from a short-lived manufacturer whose process was
only stable for nine months
- the beam-control wafer uses a regulatory-buried fabrication step no successor
company wants to admit worked
- the thermal rejection assembly is sturdy but mediocre, quietly strangling the
whole weapon
- a pirate rebuild replaced the targeting interface with a counterfeit part
that improves lock speed while making maintenance ugly
- a factional embargo means the good driver wafers are technically illegal,
practically everywhere, and therefore excellent quest bait
An "ultimate item" is not just the best blueprint. It is the best realized
artifact: the right blueprint, built from the right assemblies, with the right
quality, provenance, manufacturer practice, replacement history, and operating
conditions. If a player wants to rebuild it as the best it can be, the game
should let them hunt the supply chain: salvage yards, old corporate stockpiles,
specialist shops, faction arsenals, black-market refits, inherited ships,
forgotten factories, and small wars over a beam-control wafer some dead
manufacturer only made for nine months before the board got eaten by regulators.
This is why Ghostlight matters. A spreadsheet can name a laser. Ghostlight should
help explain why that laser exists, who can build it, which parts are worth
killing for, why a particular instance is special, and what stories fall out of
trying to make it better.
## Content Refinery
The old bottleneck was not ideas. Ideas are cheap. Volunteer design goblins can
produce a hill of them before lunch and leave the database work sitting there
like a little corpse.
The real bottleneck is converting taste, lore, mechanics, and future-tech
imagination into loadable, reviewable, schema-shaped content. Ghostlight's
technology pipeline should act as a content refinery:
1. Ground the request in Aetheria lore: faction, era, location, tech base,
industrial limits, political pressure, and known source constraints.
2. Decompose the concept into item families, assemblies, subassemblies,
components, colony-consumed manufactured goods, materials, processes,
tooling, facilities, expertise, demand profiles, and supply-chain
dependencies.
3. Map each candidate onto the Aetheria-Economy blueprint model:
`SimpleCommodityData`, `CompoundCommodityData`, `ConsumableItemData`,
`GearData`, or another `EquippableItemData` subclass.
4. Define performance intent through behaviors, `PerformanceStat` fields,
quality sensitivity, failure modes, upgrade surfaces, and operating
constraints.
5. Define realized-instance variation: assembly provenance, manufacturer
branding, quality, repair history, counterfeit substitutions, salvage scars,
and ownership chain.
6. Emit reviewable artifacts that separate source facts, inferred engineering,
branch-local assumptions, engine-schema gaps, rejected ideas, and accepted
candidates.
7. Export or hand off accepted candidates as database-shaped content rather than
another swamp of clever prose someone has to manually translate later.
The goal is not infinite content sludge. The goal is volume with preserved taste:
many plausible blueprints, variants, manufacturers, counterfeit versions,
salvage stories, and supply-chain hooks that still feel like Aetheria.
## Economy Schema Target
Ghostlight should target the existing Aetheria-Economy item model instead of
inventing a parallel item taxonomy.
The concrete source is:
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemData.cs`
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\ItemInstance.cs`
- `E:\Projects\Aetheria-Economy\Assets\Scripts\ServerShared\CultCache\`
The important split:
- `ItemData` and subclasses are CultCache-backed technological blueprint
records: a way of constructing a thing in the game, including the parameters,
behaviors, stat curves, and category fields that make that construction
meaningful.
- `ItemInstance` and subclasses are runtime objects that link to definition
records and represent manufactured artifacts in the world.
- Ghostlight item manifests should normally propose blueprint/data records, not
individual runtime artifacts.
- Runtime instances belong in simulation, inventory, scene state, cargo, loot,
and world-state fixtures. They are where realized quality, supply-chain
provenance, manufacturer branding, and assembly history can make two items
built from the same blueprint differ in practice.
Blueprint classes currently relevant to Ghostlight:
- `SimpleCommodityData`
- basic fungible resources such as steel, hydrogen, volatiles, or feedstock
- maps to `SimpleCommodity` runtime instances with quantity
- `CompoundCommodityData`
- technological blueprints for crafted commodities, assemblies,
subassemblies, processed materials, colony-consumed trade goods, and other
manufactured economic units
- not every compound commodity is an input to higher-level gear; many are
finished manufactured goods consumed by colonies, habitats, institutions,
ships, clinics, farms, sanctuaries, and cultural communities
- maps to `CompoundCommodity` runtime instances whose quality and provenance
can affect downstream items, settlement demand, service quality, comfort,
productivity, legitimacy, maintenance load, or faction-specific living
standards
- `ConsumableItemData`
- usable consumables with behaviors, duration, stackability, icon, and
effectiveness curve
- maps to `ConsumableItem` runtime instances
- `GearData`
- technological blueprints for usable equippable items with hardpoint,
behavior, durability, thermal, audio, schematic, and action-bar fields
- maps through `EquippableItem` runtime instances whose realized quality,
provenance, manufacturer, and installed assembly lineage can alter actual
performance
- `WeaponItemData`, `HullData`, `CargoBayData`, `DockingBayData`, and other
`EquippableItemData` subclasses are specialized top-level usable item
definitions.
Manifest artifacts should therefore use Ghostlight's conceptual decomposition to
produce reviewed candidate technological blueprints for Aetheria-Economy data
classes:
```text
material/feedstock -> SimpleCommodityData
component/subassembly/assembly -> CompoundCommodityData
manufactured colony trade good -> CompoundCommodityData
consumable usable item -> ConsumableItemData
equippable usable item -> GearData or another EquippableItemData subclass
weapon -> WeaponItemData
hull/frame/cargo/docking item -> HullData, CargoBayData, DockingBayData, etc.
runtime cargo, inventory, or freight -> SimpleCommodity, CompoundCommodity,
ConsumableItem, or EquippableItem instances
```
The conceptual layers below are still useful. They describe how Ghostlight
breaks a technology into reviewable engineering, logistics, and gameplay pieces.
They are not a replacement database schema. The output should be able to say
which Aetheria-Economy blueprint class each candidate wants to become, what
fields are source-backed, what fields are inferred, and which fields require
new engine schema before they can be loaded.
`PerformanceStat` is the core bridge from blueprint to realized item behavior.
Blueprint data defines stat ranges and quality sensitivity; runtime instances
and equipped/active contexts determine actual evaluation through quality,
durability, heat, effectiveness, and modifiers. Ghostlight manifests should
therefore preserve the difference between:
- blueprint-level performance intent
- assembly or subassembly contribution to quality and stat behavior
- realized instance provenance and manufacturer branding
- in-world operating conditions that change evaluated performance
If a Ghostlight manifest needs fields Aetheria-Economy does not currently store,
mark them as manifest-side metadata or source gaps. Do not pretend CultCache
already has slots for every nice idea. The future can have organs; today it has
classes.
## Nebulous Technology Elaboration
Aetheria already has plenty of named technologies. Many of them are currently
high-level lore objects rather than actionable game objects. That is fine for a
timeline note. It is not enough for an item blueprint database.
Ghostlight's exploration pipeline should elaborate existing vague technologies
into blueprint candidates.
For each nebulous technology, produce:
- source summary
- inferred operating principle
- gameplay role
- item families enabled
- assemblies and subassemblies implied
- critical components
- materials
- manufacturing processes
- tooling and facility requirements
- required expertise
- quality tiers
- failure modes
- maintenance needs
- salvage value
- counterfeit risk
- substitute parts
- faction access and restrictions
- supply-chain bottlenecks
- prerequisite technologies
- downstream technologies enabled
- branch-local variants
- open lore questions
This is where a phrase like `super-Planckian emitter` stops being a decorative
sci-fi noun and becomes a logistics problem with teeth.
If Ghostlight cannot yet know the exact real physics, it should still propose a
consistent fictional engineering stack: what must be fabricated, stabilized,
aligned, cooled, calibrated, certified, transported, repaired, and monopolized.
The output should be reviewable, revisable, and eventually loadable into a game
database.
Do not hallucinate certainty. Mark inferred engineering as inferred. Mark source
facts as source facts. Mark branch-local innovations as branch-local. The
manifest is allowed to deepen the world; it is not allowed to pretend an
invention arrived without provenance.
## Item Knowledge Goals
Every explored technology or item family should be able to answer:
- what the item does
- what gameplay role it serves
- what systems it plugs into
- what components and subassemblies it requires
- which assemblies are swappable upgrade surfaces
- whether it is a finished colony-consumed trade good rather than an assembly
input
- what population, institution, habitat, ship, or faction demand profile
consumes it
- what service, comfort, legitimacy, safety, productivity, cultural continuity,
or maintenance need it satisfies
- which materials, processes, or specialists are bottlenecks
- which factions or manufacturers can build it
- which factions can maintain it but not manufacture it
- which factions can counterfeit, salvage, or adapt it
- which tech prerequisites unlock it
- which branch conditions or discoveries created it
- which quests, markets, or conflicts it enables
- which blueprint records it should create or update
## Manifest Object Types
Use these object layers when producing tech data. Each layer must also declare
its intended Aetheria-Economy target class or state that it is manifest metadata
only:
- `item_family`
- `item_variant`
- `assembly`
- `subassembly`
- `component`
- `material`
- `process`
- `tooling`
- `expertise`
- `facility`
- `manufacturer`
- `supply_node`
- `upgrade_slot`
- `blueprint`
- `tech_prerequisite`
- `quality_tier`
- `failure_mode`
An `item_family` is a broad class, such as laser weapon, radiator, aetheric
drive, cryo valve, sensor mast, or habitat seal.
An `item_variant` is a concrete build with stats, faction/manufacturer flavor,
quality tier, and branch-specific assumptions.
An `assembly` is a replaceable functional unit. For a laser, emitter assembly,
power conditioning assembly, thermal rejection assembly, beam-control assembly,
targeting interface, and housing are likely assemblies.
A `subassembly` is a nested unit inside an assembly. The emitter assembly may
include gain medium, field lattice, focusing microstructure, alignment actuator,
control wafer, and contamination seal.
A `component` is the smallest useful gameplay/logistics unit worth tracking.
Do not split screws unless screws are the point of the quest. The machine has
standards. Barely.
A `manufactured_trade_good` is a finished compound commodity whose primary role
is economic consumption rather than becoming a higher-level gear assembly. It
may still have components and quality tiers, but the point is that colonies,
ships, habitats, clinics, sanctuaries, temples, schools, or faction enclaves
consume it as part of ordinary life and institutional function.
Example: a Navigator wet-interface cradle would likely be a
`CompoundCommodityData` candidate. It is not a ship weapon or a laser component.
It is a manufactured interface good consumed by Navigator habitats, route
annexes, mixed-species relay stations, sanctuary registrars, and other spaces
where cetacean or aquatic-adapted cognition must participate in shared
procedure. Its demand profile might be driven by Navigator population share,
route-steward institutions, translation infrastructure, acoustic signaling
standards, maintenance water quality, clinic/sanctuary access, and local
prestige around dignified embodiment. Quality might affect comfort, signal
clarity, translation latency, stress load, infection risk, repair frequency, or
institutional legitimacy.
A `blueprint` is the technological construction record represented by the
appropriate Aetheria-Economy `*Data` class. It should be specific enough for a
game system to evaluate whether a faction, factory, ship, or player can build
the item, and how sub-assemblies affect performance through quality-sensitive
stats and behavior parameters.
Ghostlight manifests still need explicit assembly trees, compatibility rules,
supply-chain dependencies, and manufacturing gates. If the current engine schema
does not expose those fields directly, keep them as manifest metadata or
engine-schema gaps attached to the target `*Data` blueprint rather than
pretending they are separate from the blueprint concept.
A `tech_prerequisite` is a required discovery, process, standard, license,
facility, or expertise gate.
A `quality_tier` describes meaningful gameplay variation: crude, standard,
premium, military, experimental, branch-exotic, counterfeit, degraded, salvaged,
or other reviewed tiers.
A `failure_mode` describes what breaks, how it breaks, and what that failure
costs in gameplay, safety, logistics, reputation, or maintenance.
## Example Pattern: Laser Upgrade Surface
A laser design should be represented so a higher-tech emitter assembly can alter
performance without rewriting the whole item.
Example structure:
```text
item_family: laser_weapon
item_variant: sol_pattern_industrial_laser_mk2
assembly: emitter_assembly
subassembly: gain_medium
subassembly: focusing_lattice
subassembly: alignment_actuator
assembly: power_conditioning_assembly
assembly: thermal_rejection_assembly
assembly: beam_control_assembly
assembly: targeting_interface
assembly: housing_and_mount
```
A branch-local future might discover an aether-stabilized emitter assembly. That
should produce:
- a new assembly or subassembly record
- prerequisite discoveries
- materials and tooling
- compatibility rules with older laser families
- performance changes
- heat, stability, cost, legality, maintenance, or supply-chain consequences
- factions able to manufacture or adapt it
- quests or market events unlocked by scarcity
## Example Pattern: Super-Planckian Emitter
When a source names a technology like `super-Planckian emitter`, Ghostlight
should elaborate it into at least a candidate blueprint stack.
Example working decomposition:
```text
item_family: exotic_emitter
item_variant: super_planckian_emitter_core_candidate
assembly: emitter_lattice_assembly
subassembly: boundary_condition_lattice
subassembly: phase-lock microstructure
subassembly: exotic containment frame
assembly: field_drive_assembly
subassembly: pulse shaper
subassembly: high-stability driver wafer
subassembly: feedback quench circuit
assembly: thermal_and_causality_safety_assembly
subassembly: transient heat sink
subassembly: event-spike monitor
subassembly: failsafe isolation gate
assembly: calibration_and_control_assembly
subassembly: metrology package
subassembly: firmware governor
subassembly: certification seal
```
That decomposition is not automatically canon. It is a candidate manifest. A
reviewer can accept, reject, revise, or mark source gaps. The important point is
that the technology now has places for supply chains to attach:
- lattice fabrication
- exotic containment materials
- high-stability driver wafers
- metrology packages
- safety gates
- certification regimes
- specialist calibration expertise
- heat rejection and failure containment
Once those exist, the game can ask useful questions:
- who owns lattice fabrication?
- who can counterfeit the driver wafer?
- what happens if the safety gate is downgraded?
- can a higher-tier emitter assembly fit into an older laser housing?
- which faction can maintain it after three jumps and one bad decision?
- what quest appears when the certification seal supply collapses?
## Pre-Elysium Starting Tech
Pre-Elysium tech is not a monolith. Everyone was shunted into Elysium together,
but not every faction brought the same industrial base, doctrine, standards,
stockpiles, expertise, or manufacturing rights.
Ghostlight should generate starting tech manifests for the single-history Sol
artifact:
- base technologies available before the Rupture
- faction access and restrictions
- manufacturing standards
- dominant manufacturers
- known substitute parts
- logistics bottlenecks
- maintenance dependencies
- doctrine-driven design differences
- legal restrictions and export controls
- black-market or pirate adaptations
- blueprint records for starting equipment, ship systems, weapons, habitats,
medical systems, industrial tooling, logistics infrastructure, colony
furnishings, habitat equipment, institutional interface goods, and finished
manufactured trade goods consumed by settlements
This matters because Elysium branches inherit different practical capabilities
from the same shared Sol history depending on what each faction controls in that
branch.
## Post-Rupture Innovation
Post-Rupture innovation is branch-local canon indexed by lineage.
When exploration discovers a technology, the manifest should record:
- branch id and lineage
- prior conditions
- triggering event or pressure
- involved factions, inventors, institutions, or nonhuman actors
- source-backed post-Elysium concepts used
- generated branch assumptions
- prerequisite tech and discoveries
- item families affected
- assemblies or subassemblies created
- supply chain changes
- market/faction consequences
- quest hooks opened
- sibling branches where this innovation does not exist or differs
- blueprint records created or modified
- compatibility with pre-Elysium assemblies
Aetheric drives, necrotech interfaces, spirit-mediated sensors, temporal memory
archives, pseudospace logistics, and mutable-body support equipment should all
leave this kind of trail when explored.
## Faction Tech Bases
For each faction or manufacturer, track:
- known technologies
- restricted technologies
- lost or inaccessible technologies
- signature design preferences
- manufacturing strengths
- maintenance weaknesses
- doctrine-driven compromises
- supply dependencies
- export controls
- counterfeiting exposure
- salvage behavior
- branch-local innovations
A faction can understand a technology without being able to build it. It can
install a component without being able to maintain it. It can maintain an item
only by depending on a hated supplier. These distinctions are the gameplay.
## Coordinator Obligations
The coordinator/story runtime must emit technology and economy knowledge when a
scene or branch decision changes the material world.
Coordinator artifacts should include item-manifest deltas when they discover or
alter:
- technology availability
- component compatibility
- assembly upgrade paths
- colony-consumed manufactured goods
- demand profiles
- materials and manufacturing processes
- supply bottlenecks
- faction access
- market pressure
- quest hooks
- salvage opportunities
- maintenance constraints
The coordinator is allowed to propose item knowledge. Deterministic validators
and reviewer gates decide whether it becomes a manifest update.
## Training Artifacts
Tech/item exploration should emit reviewed artifacts for future models:
- item manifest records
- component breakdown records
- assembly compatibility records
- colony trade-good records
- demand-profile records
- faction tech-base records
- supply-chain dependency records
- technology discovery records
- branch-local innovation records
- economic consequence records
- rejected overpowered or unsupported item designs
- source-gap records requiring AetheriaLore elaboration
These artifacts train:
- coordinator/story runtime
- institution/faction decision models
- consumer and market behavior models
- item-generation models
- lore/source evaluators
- retrieval models for tech prerequisites and dependencies
## First Schema Target
The next concrete schema after coordinator artifacts should be a manifest seam
that maps directly onto Aetheria-Economy's technological blueprint classes:
- `schemas/item-manifest.schema.json`
- `schemas/item-blueprint-candidate.schema.json`
- `schemas/item-instance-provenance-candidate.schema.json`
- `examples/item-manifest/pre-elysium-starting-tech.template.json`
- `examples/item-manifest/future-branch-innovation.template.json`
- `examples/item-manifest/super-planckian-emitter.candidate.json`
- `tools/validate_item_manifests.py`
Minimum fields:
- manifest id
- fixture lane
- branch lineage, if any
- source refs
- source-backed facts
- inferred engineering
- open lore gaps
- item family or technology id
- object layer
- consumption role: assembly input, finished trade good, consumable, gear, or
metadata-only
- target Aetheria-Economy class
- target blueprint id, when updating an existing record
- candidate blueprint fields
- blueprint candidate ids
- instance provenance fields, when simulating manufactured artifacts
- parent assembly refs
- child component refs
- materials/process/tooling/facility refs
- colony, habitat, institutional, cultural, or factional consumption drivers
- faction/manufacturer access
- prerequisites
- bottlenecks
- compatibility rules
- quality tiers
- failure modes
- gameplay effects
- economic consequences
- quest hooks
- engine-schema gaps
- review status
Candidate blueprint fields should be shaped around existing data classes first:
- shared `ItemData` blueprint fields: `Name`, `Description`, `Manufacturer`,
`Mass`, `Shape`,
`SpecificHeat`, `Conductivity`, `Price`
- `SimpleCommodityData`: `MaxStack`, `Category`
- `CompoundCommodityData`: `DemandProfile`, `Category`
- `ConsumableItemData`: `Behaviors`, `Stackable`, `Duration`, `Icon`,
`Effectiveness`
- `EquippableItemData` and subclasses: `Schematic`, `Behaviors`, `Durability`,
temperature fields, heat performance curve, resilience, icons, sound data,
`HardpointType`, and subclass-specific fields such as weapon range, caliber,
fire type, hull hardpoints, cargo interiors, or docking size
Ghostlight may propose additional manifest fields, but validators should label
them as metadata, engine-gap, or rejected. Do not silently smuggle non-engine
fields into supposed CultCache records.
## Data Generation Policy
During worldbuilding exploration:
1. If a scene implies a new item, component, supply bottleneck, or technology
dependency, emit a manifest candidate.
2. If a scene implies a manufactured good that a colony, habitat, ship,
institution, species community, or faction consumes, emit a
`CompoundCommodityData` candidate with a demand profile, even if it is not an
assembly input for gear.
3. If a faction decision changes what can be manufactured, maintained, bought,
salvaged, counterfeited, or upgraded, emit a faction tech-base delta.
4. If an innovation appears in Elysium, label its branch lineage and prior
conditions.
5. If existing lore names a technology but lacks blueprint detail, elaborate it
into a candidate manifest with explicit inferred fields and source-gap notes.
6. If the item relies on unsupported lore, patch AetheriaLore or mark a source
gap before treating it as stable.
7. If an item is cool but breaks logistics, keep it as a rejected artifact with
the failure label. Beautiful nonsense is still training data if tagged before
it bites someone.
Ghostlight should discover the future by exploring it, but every discovery
should leave inventory. The game cannot build a supply chain out of vibes and
one haunted laser.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,151 @@
# VoidBot Routine Adoption Plan
VoidBot now has the best live reference for the agent routine Ghostlight needs:
work or rumination on heartbeat, periodic sleep for consolidation, typed
MessagePack persistence through a CultCache-style contract, and vector-backed
memory resonance. Ghostlight should adopt the routine without inheriting
VoidBot's moderation job.
## Imported Organs
- **Heartbeat scheduler:** chooses work, rumination, or sleep/consolidation from
readiness, load, status, reaction windows, and pending turns.
- **Completion-gated cooldown:** a participant cannot act again until its
previous heartbeat turn finishes; recovery starts at completion.
- **Sleep cycle:** scheduled naps are not absence. They compress memory,
strengthen useful associations, cool saturated themes, refresh dreams, and
keep direct speech rare.
- **CultCache persistence:** canonical state is MessagePack, routed by typed
document definitions, with JSON working projections for inspection/review.
- **Vector memory:** memories carry semantic vector metadata and Qdrant points
so recall, resonance, incubation, and dreams are grounded in retrievable
structure.
- **Thought organs:** analytic lane, associative lane, bridge, memory resonance,
incubation, and candidate intervention/dream queues.
## Ghostlight Translation
Ghostlight's swarm is the cast. The heartbeat initiative system therefore
applies directly to characters taking turns within a scene, not merely to
background maintenance workers.
There are two scopes using the same timing law:
- **scene heartbeat:** selects which in-scene character acts, reacts, waits,
hesitates, refuses, speaks, withholds, moves, or uses an object.
- **maintenance heartbeat:** wakes offstage agents, cast records, coordinator
organs, appraisers, mutators, and fixture builders for rumination, sleep
consolidation, or reviewed maintenance.
The point is one initiative physiology with different arenas, not two unrelated
schedulers wearing each other's hat.
```text
heartbeat initiative
-> choose scene participant or maintenance organ
-> scene turn | reaction | wait | rumination | sleep
-> read typed state from CultCache-compatible store
-> optional Qdrant recall / memory resonance
-> produce visible action, event, appraisal, mutation, dream, or maintenance receipt
-> completion starts cooldown
-> write canonical MessagePack + JSON working projection
```
## Persistence Shape
Ghostlight can start with the CultCacheTS contract directly for Node-side tools,
but the Python-heavy validator/tooling surface needs a compatible file contract:
- canonical store: single-file MessagePack envelope array
- envelope: `key`, `type`, `payload`, `storedAt`
- schema owner: code definitions, not hand-edited loose JSON
- working projection: JSON, human-readable, safe for review, optionally stripped
of bulky vector values
- write discipline: use an external lock or one coordinator writer; the
single-file store is not a database wearing a fake mustache
Initial document types:
- `ghostlight.agent_state`
- `ghostlight.scene_state`
- `ghostlight.initiative_schedule`
- `ghostlight.scene_heartbeat_state`
- `ghostlight.training_loop_bundle`
- `ghostlight.runtime_heartbeat_state`
- `ghostlight.memory_resonance`
- `ghostlight.sleep_cycle`
## Memory Vector Shape
Qdrant becomes the retrieval layer for agent memory, not canonical social truth.
Each memory-like record should keep:
- stable memory id
- kind: episodic, semantic, relationship, goal, value, musing, dream,
scene-memory, appraisal, mutation, or lore-grounding
- summary text used for embedding
- source hash
- embedding backend/model/dimensions
- Qdrant point id and collection
- salience/confidence/update time
- source refs into fixtures, lore, or reviewed artifacts
Ghostlight's existing training docs already point toward retrieval-backed memory.
This plan makes it operational instead of aspirational, the traditional route
by which a research note stops being furniture.
## Sleep Behavior
During sleep turns:
- do not generate ordinary scene turns unless the character is actually in the
scene and selected by the scene heartbeat
- inspect memory resonance clusters and incubation entries
- merge duplicate memories
- prune stale or low-confidence residue
- create or refresh at least one dream only when it compresses a useful seam
- update `sleep_cycle.activeDreamThemes`, `lastDreamAt`, and
`lastDistillationSummary`
- write a maintenance receipt for review
Dreams are compressed operational memory, not glitter. If a dream survives, the
next waking turn should be more coherent because of it.
## Migration Slices
1. **Contract fixture**
- Add a small CultCache-compatible MessagePack fixture and validator.
- Prove Python can read/write the envelope without losing schema shape.
2. **Runtime heartbeat schema**
- Add `schemas/runtime-heartbeat-state.schema.json`.
- Include pending turn, sleep cycle, rumination, and completion receipts.
3. **Agent state migration**
- Keep current JSON examples as projections.
- Add canonical MessagePack test store for one small agent-state fixture.
4. **Memory resonance**
- Add a local memory-organ script that embeds memory summaries, writes Qdrant
points, builds resonance edges/clusters, and writes incubation state.
5. **Sleep receipt**
- Add a sleep/consolidation artifact schema and validator.
- Require sleep receipts to name pruned/merged/strengthened memories.
6. **Training-loop integration**
- Promote scene initiative schedules into the same heartbeat contract used
by the swarm.
- Let characters take scene turns through heartbeat initiative, then route
appraisal/mutation/coordinator maintenance through the same completion and
cooldown law.
## Boundaries
- Do not make Qdrant canonical truth.
- Do not store raw vector blobs in every human working projection.
- Do not let sleep turns mutate accepted training fixtures without reviewed
maintenance receipts.
- Do not make every Ghostlight character into a background daemon before the
runtime heartbeat store can prove who is awake, asleep, pending, or complete.

View file

@ -0,0 +1,61 @@
# Research note: where a turn's identity lives (verse-run re-map, 2026-10-11)
Imagination `imagination-gl-tick-identity`, session `self-2026-10-11-ag`. A short
prior-art check before settling tick identity for cut `verse-run` r2. Appended to the
map's "Verse run identity" section.
## The question
At Ghostlight `06a8373` a tick is named `now / span` (`cover.rs:46`), the span is not in
the name, and every cell id and command id is re-derived from the opportunities,
attention order, span and cover budget the resuming run holds at that moment
(`run.rs:557-565`). Soul s1 showed a different span or budget on resume yields new ids,
so a subject acts twice in one tick (p1, p2), and a different span reuses a closed
tick's index (p4).
## Prior art
- **Durable workflow engines (Temporal, Azure Durable Functions).** The identity of
each step is assigned when the step is first scheduled and recorded in the
execution's event history. On resume the code is replayed against that history and
every command it produces is compared with the recorded one; a difference is a
non-determinism error, not a second execution
([Temporal: execution failures](https://docs.temporal.io/troubleshooting/execution-failures);
[community thread](https://community.temporal.io/t/non-determinism-error/8279)).
The forum cases are exactly Soul's: a timer duration or a child id computed
differently on replay. The lesson: resume reads the recorded decision; it does not
re-derive it from inputs that may have changed.
- **Deterministic lockstep games.** The turn (frame) number is simulation state,
advanced only by the simulation, and every command is tagged with the turn it runs
in, so all peers and every replay agree on which turn a command belongs to whatever
the wall-clock or turn length
([Unity discussion](https://discussions.unity.com/t/deterministic-rts-game-engine/467205);
[Gaffer On Games](https://gafferongames.com/post/what_every_programmer_needs_to_know_about_game_networking/)).
The turn count, not elapsed time divided by turn length, is the name.
- **Idempotency keys (event-sourced command handlers, payment APIs).** A key answers a
retry with the stored result; the same key with a different request is refused, not
re-executed. Ghostlight's kernel already does this
(`lib.rs:1633-1644`: same caller and body, `AlreadyApplied`; otherwise
`CommandIdConflict`), which is why Soul's p4 clock under another span was refused.
## What this decides for verse-run
1. The tick ordinal is world-held: the number of `TimeAdvanced` commits in the world's
journal when the tick opens. It is span-independent, monotone, injective per world,
restored with a world snapshot (so ruling `replay-restored-snapshot` needs nothing
else for a run that starts on a tick boundary), and consecutive (which is what the
cover's rotation window wants, `cover.rs:340`).
2. What a tick decided when it opened (span, cover budget, the partition and each
cell's id and members) is recorded once, before the first cell runs, in a tick
document owned by the run store, and a resuming run reads it instead of deriving a
cover. This is Temporal's history rule at the granularity the verse needs.
3. Command ids stay derived, but only from that frozen identity (world, ordinal, cell
id, subject), never from live inputs, so there is no second copy of them to drift.
4. The controller answers a persisted row whose command the world committed with its
receipt whatever the caller's current scope digest, because the command id is the
idempotency key; a row still in flight keeps the digest check, so no tokens are spent
on a scope that moved.
Not adopted: a world-state tick counter (a `STATE_SCHEMA` change, and the kernel would
learn ticks exist, which `cover.rs:1-12` forbids); a run-store-only ordinal (a replay
over a restored world with a fresh run store would number ticks from 1 and diverge).

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-06.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-06",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-06.coordinator.decision",
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "sol_vant",
"candidate_next_beats": [
{
"choice_id": "turn-06.selected",
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-06.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-06.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-06.handoff",
"responder_agent_id": "sol_vant",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-06.sol-vant.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-06.sol-vant.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-06.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-06.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-07.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-07",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-07.coordinator.decision",
"selected_next_beat": "Mara Quell responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "mara_quell",
"candidate_next_beats": [
{
"choice_id": "turn-07.selected",
"summary": "Project Mara Quell from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Mara Quell to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-07.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.sol_vant.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-07.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-07.handoff",
"responder_agent_id": "mara_quell",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-07.mara-quell.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-06.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-07.mara-quell.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-07.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-07.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-08.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-08",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-08.coordinator.decision",
"selected_next_beat": "Juno Ark responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "juno_ark",
"candidate_next_beats": [
{
"choice_id": "turn-08.selected",
"summary": "Project Juno Ark from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Juno Ark to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-08.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.sol_vant.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-08.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-08.handoff",
"responder_agent_id": "juno_ark",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-08.juno-ark.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-07.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-08.juno-ark.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-08.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-08.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-09.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-09",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-09.coordinator.decision",
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "sol_vant",
"candidate_next_beats": [
{
"choice_id": "turn-09.selected",
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-09.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-09.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-09.handoff",
"responder_agent_id": "sol_vant",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-09.sol-vant.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-08.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-09.sol-vant.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-09.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-09.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-10.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-10",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-10.coordinator.decision",
"selected_next_beat": "Feed Ops Chorus responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "feed_ops_chorus",
"candidate_next_beats": [
{
"choice_id": "turn-10.selected",
"summary": "Project Feed Ops Chorus from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Feed Ops Chorus to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-10.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-10.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-10.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-10.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.sol_vant.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.appraisal.mara_quell.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-10.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-10.handoff",
"responder_agent_id": "feed_ops_chorus",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-09.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-10.feed-ops-chorus.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-10.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-10.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

View file

@ -0,0 +1,258 @@
{
"schema_version": "ghostlight.coordinator_artifact.v0",
"artifact_id": "lucent-hostage-feed-v0.turn-11.coordinator",
"review_status": "accepted_as_draft",
"fixture_lane": "historical_grounded",
"canon_status": "single_history",
"source_refs": [
{
"ref_id": "lucent-scene-digest",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json",
"purpose": "Grounding facts for Lucent hostage-feed scene."
},
{
"ref_id": "lucent-initial-state",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"purpose": "Scene-local state seed and participant briefs."
}
],
"input_refs": {
"agent_state_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.initial-state.json",
"lore_grounding_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.scene-digest.json"
],
"prior_artifact_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
],
"projected_context_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json"
]
},
"scene_context": {
"scene_id": "lucent-hostage-feed-v0.transfer-atrium-release-loop",
"sequence_marker": "turn-11",
"location_id": "lucent.media-eye.transfer-atrium",
"participant_ids": [
"sol_vant",
"juno_ark",
"mara_quell",
"feed_ops_chorus",
"brant_vee",
"pippa_lux"
],
"active_object_ids": [
"edit-trail-hold",
"custody-graphic-suspended",
"elevator-gate",
"bench-edge",
"crisis-feed"
],
"active_resource_ids": [
"hostage-safety",
"feed-integrity",
"sponsor-risk",
"security-pressure"
],
"public_facts": [
{
"text": "The live feed and room are mutually shaping the negotiation.",
"refs": [
"lucent-scene-digest"
]
},
{
"text": "The edit-trail hold is the active evidence object.",
"refs": [
"lucent-loop-state"
]
}
],
"scene_pressure": [
{
"text": "Each turn must preserve safety while resisting brand-safe replacement of the event.",
"refs": [
"lucent-initial-state"
]
}
]
},
"coordinator_decision": {
"decision_id": "lucent-hostage-feed-v0.turn-11.coordinator.decision",
"selected_next_beat": "Sol Vant responds to the updated safety/evidence geometry.",
"selected_acting_agent_id": "sol_vant",
"candidate_next_beats": [
{
"choice_id": "turn-11.selected",
"summary": "Project Sol Vant from current state and run one packet-only responder turn.",
"status": "selected"
}
],
"rationale": "Selected to test sequential state carryover, local knowledge, physical affordances, feed affordances, and de-escalation without omniscient control.",
"glue_prose": "The feed holds its ugly shape long enough for Sol Vant to decide whether the next inch belongs to safety, spectacle, or panic.",
"glue_prose_status": "accepted_as_narration"
},
"machinery_plan": {
"invocations": [
{
"invocation_id": "turn-11.projector",
"organ": "projector",
"purpose": "Build local projected context for acting character.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json"
],
"output_refs": [
"examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-11.responder",
"organ": "sandboxed_responder",
"purpose": "Generate one character-local action from exact packet only.",
"input_refs": [
"examples/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.json"
],
"output_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-11.event",
"organ": "event_resolver",
"purpose": "Convert accepted responder action into observable event.",
"input_refs": [
"experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-11.appraisal",
"organ": "participant_appraiser",
"purpose": "Create affected participant-local appraisals from current state.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.juno_ark.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.mara_quell.json",
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.appraisal.feed_ops_chorus.json"
],
"status": "accepted_as_draft"
},
{
"invocation_id": "turn-11.mutation",
"organ": "state_mutator",
"purpose": "Apply only reviewed state deltas.",
"input_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json"
],
"output_refs": [
"examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json"
],
"status": "accepted_as_draft"
}
]
},
"sandboxed_responder_handoffs": [
{
"handoff_id": "turn-11.handoff",
"responder_agent_id": "sol_vant",
"projected_context_ref": "examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json",
"visible_input_ref": "examples/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.json",
"hidden_context_refs": [
"other participants private state",
"coordinator future plans",
"conversation history"
],
"isolation_method": "subagent_no_fork",
"raw_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json",
"reviewed_output_ref": "experiments/responder-packets/lucent-hostage-feed-v0.turn-11.sol-vant.packet.capture.json",
"coordinator_interventions": [
{
"intervention_type": "none",
"description": "Raw subagent output parsed as JSON without repair."
}
],
"leakage_audit": {
"private_state_leak": false,
"author_only_leak": false,
"future_branch_leak": false,
"raw_state_soup": false,
"prompt_parroting": false,
"body_or_object_omniscience": false,
"notes": [
"No hidden context markers or raw state variables appeared in the accepted output."
]
}
}
],
"world_state_refs": {
"read_refs": [
{
"ref_id": "prior-state",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-10.mutation.json",
"visibility": "coordinator_only"
},
{
"ref_id": "projected-context",
"ref_type": "agent",
"path": "examples/projected-contexts/lucent-hostage-feed-v0.turn-11.sol-vant.projected-context.json",
"visibility": "character_local"
}
],
"write_candidate_refs": [
{
"ref_id": "event",
"ref_type": "event",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.event.json",
"visibility": "public"
},
{
"ref_id": "mutation",
"ref_type": "world_fact",
"path": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json",
"visibility": "coordinator_only"
}
]
},
"proposed_deltas": [
{
"delta_id": "turn-11.delta.receipt-chain",
"delta_type": "world_fact_update",
"target_ref": "examples/training-loops/lucent-hostage-feed-v0/lucent-hostage-feed-v0.turn-11.mutation.json",
"summary": "Use reviewed mutation receipt as source for next turn state.",
"authority": "manual_review",
"status": "accepted",
"rationale": "This is the explicit carryover tested by the loop."
}
],
"branching": {
"branch_action": "none",
"active_branch_flags": [
"training-loop-linear-release"
],
"event_constraints": []
},
"unresolved_hooks": [
{
"hook_id": "turn-11.hook.aftercare",
"hook_type": "story",
"summary": "Carry unresolved legal/feed/security aftermath without extending the hostage loop indefinitely.",
"owner": "Ghostlight",
"status": "carried"
}
],
"review": {
"reviewer": "Codex coordinator",
"review_notes": [
"Coordinator artifact is training-shaped and paired with exact packet/responder receipts."
],
"accepted_for_training": true,
"failure_labels": []
}
}

Some files were not shown because too many files have changed in this diff Show more