Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c43eb54962 | ||
|
|
2b9143d303 | ||
|
|
f21b5ff1aa | ||
|
|
fec39c2010 | ||
|
|
0e9def0ebc | ||
|
|
ef11427e24 | ||
|
|
f40bbaac13 | ||
|
|
ee62fec98a | ||
|
|
eaefed1e7d | ||
|
|
56496c7108 | ||
|
|
aa56736841 | ||
|
|
f503c85484 | ||
|
|
e0344ff5af | ||
|
|
e2924bbc50 | ||
|
|
73f26a5a32 | ||
|
|
1d75f14578 | ||
|
|
ee9e18a745 | ||
|
|
771a79dde2 | ||
|
|
fca3ec1a3a | ||
|
|
e2c3511cbd | ||
|
|
824a4dea15 | ||
|
|
4741d50898 | ||
|
|
f90d408466 | ||
|
|
9ccc9fd4f2 | ||
|
|
dac9686576 | ||
|
|
d1111f4c4f | ||
|
|
9308c7d043 | ||
|
|
9747957649 | ||
|
|
fe06efcf26 | ||
|
|
b3699908f7 | ||
|
|
6d84c2b57b | ||
|
|
03b04011b7 | ||
|
|
311c4bfaed | ||
|
|
8c6a01f2ad | ||
|
|
c3f0e3789c | ||
|
|
ef7299b673 | ||
|
|
a501009210 | ||
|
|
b5b964c234 | ||
|
|
2dabb85009 | ||
|
|
706d0c3792 | ||
|
|
5f2e629c32 | ||
|
|
d1883e9973 | ||
|
|
92275cbe7c | ||
|
|
2a888da955 | ||
|
|
5cf005eb2c | ||
|
|
b5dc76dd75 | ||
|
|
61042670d1 | ||
|
|
cb1be44f92 | ||
|
|
9bf7932573 | ||
|
|
db12c26b31 | ||
|
|
2b495e1f33 | ||
|
|
adb8cbff2b | ||
|
|
158e8f9f3d | ||
|
|
278033b90e | ||
|
|
be7d9eec75 | ||
|
|
5277973afb | ||
|
|
06a8373fbd | ||
|
|
06e30dc921 | ||
|
|
097bf02f1e | ||
|
|
fc3f9fd668 | ||
|
|
cef6436971 | ||
|
|
2daa80e159 | ||
|
|
4eff650755 | ||
|
|
e854cf3671 | ||
|
|
8f6b552e7a | ||
|
|
9afbdc879e | ||
|
|
42e71d89d5 | ||
|
|
677eb1b3a8 | ||
|
|
3eb59068c7 | ||
|
|
b5edc05b1f | ||
|
|
84b8e3d6d5 | ||
|
|
c3e16a57c2 | ||
|
|
f167683e8d | ||
|
|
308169ac51 | ||
|
|
4126c83eff | ||
|
|
ecff1ac25b | ||
|
|
e651d9e863 | ||
|
|
2c5eb95da3 | ||
|
|
ee174c68ce | ||
|
|
1afd942be3 | ||
|
|
c750e21c2d | ||
|
|
e085286126 | ||
|
|
525666f79b | ||
|
|
b8465e50d3 | ||
|
|
8c9b88b519 | ||
|
|
327cbe3c5e | ||
|
|
bd8dc756d9 | ||
|
|
fbd8bdb8ca | ||
|
|
e5ae6fe7af | ||
|
|
c11e8a16ba | ||
|
|
2e9905f42f | ||
|
|
f74dec6675 | ||
|
|
e28bf3fd55 | ||
|
|
b2447e4f99 | ||
|
|
8d263bb60e | ||
|
|
b0f57a009b | ||
|
|
74f3b7011f | ||
|
|
b3d5843e71 | ||
|
|
aef08ce3a6 | ||
|
|
f178684391 | ||
|
|
831f10458e | ||
|
|
0d28508180 | ||
|
|
dd80e29484 | ||
|
|
7262e72a0d | ||
|
|
8912dde49f | ||
|
|
90c5e3b337 | ||
|
|
1645f4a972 | ||
|
|
f89be06a33 | ||
|
|
efa7d5cff2 | ||
|
|
367e2eb50f | ||
|
|
9f2904ffb8 | ||
|
|
ea8538761b | ||
|
|
4f92840fe7 | ||
|
|
8ca2c5556c | ||
|
|
82a0c1b4f0 | ||
|
|
5b6796d1e6 | ||
|
|
0c0e1c73db | ||
|
|
c47a0785c9 | ||
|
|
d79a96ede4 | ||
|
|
82a500eedb | ||
|
|
b64b61c345 | ||
|
|
189d1d3c2b | ||
|
|
97e39f48fd | ||
|
|
3ee4ab8e88 | ||
|
|
29efabe4d8 | ||
|
|
5a876bf615 | ||
|
|
6db5d0873e | ||
|
|
1e6da32ad3 | ||
|
|
f0adcc61c5 | ||
|
|
e1c2632f03 | ||
|
|
26f533e8f4 | ||
|
|
57b16df286 | ||
|
|
72a98d8468 | ||
|
|
2b6e8c7826 | ||
|
|
1b31bfc399 | ||
|
|
af7e544830 | ||
|
|
c4bc865573 |
74 changed files with 14133 additions and 27164 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -1,5 +1,4 @@
|
|||
node_modules/
|
||||
web/dist/
|
||||
sidecar/claude-sdk/dist/
|
||||
target/
|
||||
|
||||
|
|
|
|||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
|||
[submodule "vendor/eve"]
|
||||
path = vendor/eve
|
||||
url = https://github.com/GameCult/Eve.git
|
||||
28
AGENTS.md
28
AGENTS.md
|
|
@ -5,7 +5,8 @@
|
|||
Ghostlight is a Rust world kernel and the play daemon built on it. Models
|
||||
propose; the kernel admits or refuses. The same kernel is meant to run the
|
||||
simulation behind Aetheria's public story, a persistent cast and storylets, as
|
||||
well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says
|
||||
well as Ghostlight Dungeon, the single-player DM-led client (parked at
|
||||
tag `parked/ghostlight-eve-surface`). `README.md` says
|
||||
what exists today and what does not.
|
||||
|
||||
## Where State Lives
|
||||
|
|
@ -55,28 +56,21 @@ what exists today and what does not.
|
|||
- Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism:
|
||||
`docs/architecture/ghostlight-world-ontology.md`. The consumer contract:
|
||||
`docs/architecture/ghostlight-world-consumer-api.md`.
|
||||
- Dungeon daemon: `crates/ghostlight-dungeon/src/` (`runtime.rs` for routes and
|
||||
commands, `play.rs` for the play table, `eve.rs` for the surface).
|
||||
Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`.
|
||||
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
|
||||
tag `parked/ghostlight-eve-surface`; not on main.
|
||||
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
|
||||
- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale: `docs/architecture/ghostlight-dungeon-mvp.md`.
|
||||
- Claude SDK sidecar: `sidecar/claude-sdk/`.
|
||||
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
|
||||
`notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09
|
||||
rebuild, not the current plan.
|
||||
|
||||
## Build, Verify, Deploy
|
||||
|
||||
Ghostlight Dungeon ships to Linux on Yggdrasil. Idunn freezes an exact commit,
|
||||
runs the locked native Linux tests, builds the daemon and web projection in
|
||||
pinned containers, and seals the release. The steps are
|
||||
`deployment/idunn/recipe.toml`; the owning runbook is
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
|
||||
|
||||
You are probably reading this on a Windows workstation. That is where the shell
|
||||
is, not where the artifact runs. A Windows compile is not evidence about the
|
||||
Linux release; build for the target, or let the deploy path's builder do it, and
|
||||
say which one you used. Heavy builds and test runs go to Yggdrasil, not the
|
||||
workstation. Deploy only through Idunn (`idunn up ghostlight`).
|
||||
Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and
|
||||
lifecycle brakes engaged, unit stopped; retirement waits on
|
||||
`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the
|
||||
`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence
|
||||
about Linux, so say which host you used. Do not run `idunn up ghostlight`.
|
||||
|
||||
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
|
||||
`F:\Projects\gamecult-ops` before acting.
|
||||
|
|
|
|||
702
Cargo.lock
generated
702
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
13
Cargo.toml
13
Cargo.toml
|
|
@ -1,8 +1,8 @@
|
|||
[workspace]
|
||||
members = [
|
||||
"crates/ghostlight",
|
||||
"crates/ghostlight-dungeon",
|
||||
"crates/ghostlight-persona-projection",
|
||||
"crates/ghostlight-verse",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
|
|
@ -13,19 +13,10 @@ version = "0.1.0"
|
|||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
aes-gcm = "0.10"
|
||||
async-trait = "0.1"
|
||||
async-stream = "0.3"
|
||||
axum = { version = "0.8", features = ["macros"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
base64 = "0.22"
|
||||
codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" }
|
||||
cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" }
|
||||
cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" }
|
||||
cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" }
|
||||
jsonwebtoken = "9"
|
||||
hmac = "0.12"
|
||||
rand = "0.9"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
rmp-serde = "1.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
|
@ -33,9 +24,7 @@ serde_json = "1"
|
|||
sha2 = "0.10"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
unicode-segmentation = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
zeroize = "1.8"
|
||||
|
|
|
|||
219
README.md
219
README.md
|
|
@ -1,148 +1,121 @@
|
|||
# Ghostlight
|
||||
|
||||
Ghostlight is a Rust library that keeps a simulated world honest, and a play
|
||||
daemon built on it. A language model proposes; the library's kernel checks each
|
||||
proposal against typed world state and admits or refuses it. Places, people,
|
||||
knowledge, commitments, and time live in the kernel's journal, not in a model's
|
||||
context window, so they survive restarts and long stories.
|
||||
**Ghostlight is a world kernel: typed world state that a language model can
|
||||
propose changes to and can never write.** The valuable part is
|
||||
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
|
||||
|
||||
## What is here
|
||||
**The thesis.** World state is typed documents. Every change to it, and every
|
||||
narrative dependency on it, is an operation in a closed, predefined algebra.
|
||||
Models propose operations; the kernel checks each against the state (references,
|
||||
preconditions, authority, conservation, who knows what) and admits it or refuses
|
||||
it with the complete list of mismatches. Narration describes only what was
|
||||
committed. A model-as-game-master holds the world in a context window and
|
||||
drifts; here the world is a digest-chained journal that survives restarts and
|
||||
cannot be talked into contradicting itself.
|
||||
|
||||
| Part | Path | What it is |
|
||||
|---|---|---|
|
||||
| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. |
|
||||
| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. |
|
||||
| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. |
|
||||
| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. |
|
||||
| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. |
|
||||
| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. |
|
||||
| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. |
|
||||
**Use it as a structured state backend.** Do not judge it by the parked Dungeon's
|
||||
prose. The kernel is a library; prose comes from whichever model you plug into
|
||||
its ports.
|
||||
|
||||
## How it works
|
||||
- `crates/ghostlight`: the sealed library. A consumer gets create/open
|
||||
(`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command
|
||||
submission through role-scoped ports (`PlayPort`, `ConsumerPort`,
|
||||
`ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port
|
||||
entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`,
|
||||
`InferencePort`). Mutable state, ID issuers, the reducer and the journal
|
||||
writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs`
|
||||
and `crates/ghostlight/tests/external_admission.rs` prove the seal.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects,
|
||||
places, routes, holdings, knowledge, commitments, pressures), the operation
|
||||
algebra, and what the reducer checks.
|
||||
- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a
|
||||
program that reads a world and proposes changes to the characters it controls
|
||||
(`POST /cultnet/world-patch`; no process on main serves that door, a host serves
|
||||
`ghostlight::admit_document`).
|
||||
|
||||
- The kernel is sealed. A consumer reaches it through create/open, immutable
|
||||
snapshots, command submission, typed receipts, and the runner and port entry
|
||||
points. There is no public mutable state, ID issuer, reducer, or journal
|
||||
writer, and the crate declares no cargo features. The seal is proven by
|
||||
`compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by
|
||||
`crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests,
|
||||
and opportunities and asserts that each is refused with nothing committed.
|
||||
- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a
|
||||
command, is decided by one reducer, and commits atomically to a
|
||||
digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb).
|
||||
- Models author through typed tools and a repair loop
|
||||
(`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A
|
||||
refused proposal returns its mismatches to the model; it never edits state.
|
||||
- A Persona speaks only prose. A Projector builds its view, and an Interpreter
|
||||
lowers its words into typed proposals with exact source quotes
|
||||
(`crates/ghostlight/src/controllers.rs`).
|
||||
- Vault evidence for seeding comes from a read-only markdown directory reader
|
||||
(`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the
|
||||
directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`
|
||||
(`crates/ghostlight-dungeon/src/runtime.rs`).
|
||||
- Other programs can propose changes to the characters they control through
|
||||
`POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`,
|
||||
`crates/ghostlight/src/consumer.rs`). The contract is
|
||||
`docs/architecture/ghostlight-world-consumer-api.md`.
|
||||
Start with those two documents, then `cargo doc -p ghostlight --open`.
|
||||
|
||||
## Ghostlight Dungeon
|
||||
**Populate the algebra first.** The kernel owns the component operations; a world owns its
|
||||
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
|
||||
Speak, which records an utterance and changes nothing. Until your world declares affordances
|
||||
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
|
||||
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
|
||||
will be thin no matter how good the model is. The Aetheria verse genesis
|
||||
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
|
||||
only record the act; their effects are the next phase.
|
||||
|
||||
Dungeon is the single-player, DM-led client. A player signs in with Heimdall,
|
||||
creates a world, seeds it from a vault, approves and activates it, and plays in
|
||||
free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the
|
||||
turn lifecycle; the interface is one Eve surface, `ghostlight.play`
|
||||
(`crates/ghostlight-dungeon/src/eve.rs`, described in
|
||||
`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is
|
||||
not a developer, Dungeon is judged by whether someone can sit down and play.
|
||||
## Elaborators
|
||||
|
||||
Where it runs:
|
||||
An elaborator is a model session, confined to one jurisdiction of the world, that
|
||||
adds structure where the world is thin: it answers a derived causal boundary
|
||||
(a destination nobody has described yet) or a scale deficit with typed
|
||||
declarations, and the kernel verifies the result like any other patch. Sessions
|
||||
are resumable and checkpointed against committed history. Eight stock lenses
|
||||
(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil,
|
||||
ember, numen) give each session a role-specific emphasis, for example customs and
|
||||
local jokes, or money and ledgers. A lens changes the prompt only, never what the
|
||||
kernel admits. Because boundaries are derived from state, elaborators can be
|
||||
dispatched as the player approaches a place or a social structure, instead of
|
||||
authoring the world up front.
|
||||
|
||||
- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`.
|
||||
Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery
|
||||
and Heimdall's command-boundary record. The operating runbook is
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`.
|
||||
- **Inference** is routed by model name: a `local/` prefix goes to an
|
||||
OpenAI-compatible loopback endpoint
|
||||
(`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`),
|
||||
which in production is Bonsai on Raven's GPU reached through a loopback
|
||||
tunnel; a `claude` prefix goes to the SDK sidecar
|
||||
(`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`).
|
||||
Ghostlight never reads, stores, or forwards a model credential.
|
||||
- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and
|
||||
derives authority from the world's owner and approvers.
|
||||
## Direction (planned, not built)
|
||||
|
||||
## Direction
|
||||
Use the kernel to simulate a whole world with many kinds of actor: Aetheria's
|
||||
factions and a persistent cast whose members keep identity, memory and
|
||||
commitments across sessions. Newspaper-staff simulations before the 2026-08
|
||||
teardown are the earlier proof of the idea; that code is in git history, not in
|
||||
the tree. Running this at scale is gated by inference cost, not by the design.
|
||||
`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates
|
||||
an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run`
|
||||
advances it a number of ticks under a token cap, reporting per tick what each
|
||||
faction did and what it cost. Nothing loads AetheriaLore prose, models
|
||||
storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`.
|
||||
|
||||
The next work makes Ghostlight the simulation behind Aetheria's public story.
|
||||
The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for:
|
||||
## Ghostlight Dungeon (parked)
|
||||
|
||||
- a world loaded from the Aetheria verse, with factions and a cast;
|
||||
- storylets for Aetheria;
|
||||
- a persistent cast whose characters keep identity, memory, and commitments
|
||||
across sessions, so serialized episodes can follow them;
|
||||
- episodes a cheap pipeline can turn into a video stream, and channel
|
||||
characters who later appear in the game.
|
||||
The single-player, DM-led client and its daemon, Eve play surface and browser
|
||||
host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and
|
||||
nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI
|
||||
(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag.
|
||||
|
||||
None of this exists yet. Nothing under `crates/` loads Aetheria lore, models
|
||||
storylets, or produces an episode. Every event the kernel admits is meant to
|
||||
have exactly one render path: ship action filmed in Aetheria, or a social verb
|
||||
staged as an Aetheria conversation. Today a world can declare any affordance
|
||||
kind, and the kernel does not enforce that restriction.
|
||||
## Repo map
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `crates/ghostlight/` | The sealed kernel library. |
|
||||
| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. |
|
||||
| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). |
|
||||
| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. |
|
||||
| `AGENTS.md` | Invariants and where things are. |
|
||||
| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). |
|
||||
| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. |
|
||||
|
||||
`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design
|
||||
essays and proposals from before the rebuild. They describe organs (campaigns,
|
||||
co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the
|
||||
running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history.
|
||||
|
||||
## Build and test
|
||||
|
||||
The release target is Linux on Yggdrasil; a Windows build says nothing about
|
||||
it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the
|
||||
verification:
|
||||
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
|
||||
workstation:
|
||||
|
||||
```sh
|
||||
git submodule update --init vendor/eve
|
||||
cargo build --locked --workspace
|
||||
cargo test --locked -p ghostlight-persona-projection
|
||||
cargo test --locked -p ghostlight
|
||||
cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon
|
||||
cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon
|
||||
npm --prefix web ci
|
||||
npm --prefix web test
|
||||
npm --prefix web run build
|
||||
cargo test --locked -p ghostlight-verse
|
||||
```
|
||||
|
||||
The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test`
|
||||
(also the root scripts `sdk:build` and `sdk:test`). Four Dungeon tests are
|
||||
`#[ignore]`d because they drive the vendored browser lowering through
|
||||
`tools/eve_client_bridge.mjs` and need Node beside cargo. The release
|
||||
acceptance step runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
(ignored by default) against a real model.
|
||||
The kernel alone needs only `cargo test -p ghostlight`. The sidecar has
|
||||
`npm run sdk:build` and `npm run sdk:test`. The acceptance test
|
||||
`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored
|
||||
by default and needs a real model.
|
||||
|
||||
To run Dungeon locally: `npm run dungeon:run`, or
|
||||
`cargo run -p ghostlight-dungeon -- --state-root PATH`. Configuration is by
|
||||
`GHOSTLIGHT_*` environment variables; the recipe's `required_environment`
|
||||
lists the ones a deployment sets.
|
||||
Inference is routed by model name: `local/` goes to an OpenAI-compatible
|
||||
loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/`
|
||||
to an https endpoint. Ghostlight never reads, stores or forwards a model
|
||||
credential beyond a key file named at run time.
|
||||
|
||||
## Documents
|
||||
|
||||
- `AGENTS.md`: repository doctrine for agents.
|
||||
- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture
|
||||
of the world machine.
|
||||
- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the
|
||||
kernel's current mechanism.
|
||||
- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract.
|
||||
- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface.
|
||||
- `docs/architecture/ghostlight-library-extraction.md`,
|
||||
`ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in
|
||||
`-cut.md` and `-postmortem.md` are dated history.
|
||||
- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line
|
||||
predates the play-agent cuts; read it with `-cut.md` beside it, not as the
|
||||
current play surface (`crates/ghostlight-dungeon/src/play.rs` is).
|
||||
- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by
|
||||
the play agent. Nothing in `crates/` implements it.
|
||||
- `notes/fresh-workspace-handoff.md`: where things run and where open work is
|
||||
recorded.
|
||||
- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays
|
||||
written before the rebuild. They describe campaigns, co-op, Nemesis
|
||||
and strategic resolvers as organs; none of that exists in `crates/`. They do not
|
||||
describe the running machine.
|
||||
- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted
|
||||
vaults; nothing in `crates/` implements it.
|
||||
|
||||
Runtime documents use MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, browser, model-provider, and diagnostic boundaries.
|
||||
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
|
||||
publication, model-provider and diagnostic boundaries.
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
[package]
|
||||
name = "ghostlight-dungeon"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
autobins = false
|
||||
|
||||
[[bin]]
|
||||
name = "ghostlight-dungeon"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
aes-gcm.workspace = true
|
||||
async-trait.workspace = true
|
||||
async-stream.workspace = true
|
||||
axum.workspace = true
|
||||
chrono.workspace = true
|
||||
base64.workspace = true
|
||||
codex-connector.workspace = true
|
||||
cultcache-rs.workspace = true
|
||||
cultnet-rs.workspace = true
|
||||
cultmesh-rs.workspace = true
|
||||
jsonwebtoken.workspace = true
|
||||
libc = "0.2"
|
||||
hmac.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
rand.workspace = true
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
sha2.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-http.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
uuid.workspace = true
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
tower = { version = "0.5", features = ["util"] }
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
use std::{env, path::Path, process::Command};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT");
|
||||
let manifest_dir =
|
||||
env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR");
|
||||
let repo_root = Path::new(&manifest_dir).join("../..");
|
||||
emit_git_rerun_paths(&repo_root);
|
||||
// Release tooling injects the clean-tree commit. Git discovery is only a
|
||||
// convenience for local builds. The exact ref and reflog dependencies above
|
||||
// keep that convenience from silently publishing stale provenance.
|
||||
let commit = env::var("GHOSTLIGHT_BUILD_COMMIT")
|
||||
.ok()
|
||||
.filter(|value| is_commit(value))
|
||||
.or_else(|| git_commit(&repo_root))
|
||||
.expect("GhostlightDungeon builds require an exact 40-hex source commit");
|
||||
println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}");
|
||||
}
|
||||
|
||||
fn emit_git_rerun_paths(repo_root: &Path) {
|
||||
for git_path in ["HEAD", "logs/HEAD", "packed-refs"] {
|
||||
if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) {
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"])
|
||||
&& let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference])
|
||||
{
|
||||
emit_rerun_path(repo_root, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_rerun_path(repo_root: &Path, git_path: &str) {
|
||||
let path = Path::new(git_path);
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
repo_root.join(path)
|
||||
};
|
||||
println!("cargo:rerun-if-changed={}", resolved.display());
|
||||
}
|
||||
|
||||
fn git_commit(repo_root: &Path) -> Option<String> {
|
||||
git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value))
|
||||
}
|
||||
|
||||
fn git_output(repo_root: &Path, args: &[&str]) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.args(args)
|
||||
.current_dir(repo_root)
|
||||
.output()
|
||||
.ok()?;
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
String::from_utf8(output.stdout)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_owned())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn is_commit(value: &str) -> bool {
|
||||
value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
|
@ -1,869 +0,0 @@
|
|||
//! Local custody for authenticated Heimdall sessions.
|
||||
//!
|
||||
//! This store owns only browser-session continuity. It does not own world
|
||||
//! selection, commands, idempotency, exports, or fictional state.
|
||||
|
||||
use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh};
|
||||
use ghostlight::VerifiedPrincipalEvidence;
|
||||
use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead};
|
||||
use anyhow::{Context, bail};
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{collections::BTreeMap, path::Path};
|
||||
|
||||
const STORE_TYPE: &str = "ghostlight.app_session_store.v2";
|
||||
/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the
|
||||
/// `app-sessions` state slot's schema against this constant.
|
||||
pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2";
|
||||
const STORE_KEY: &str = "primary";
|
||||
const MAX_SESSION_RECORDS: usize = 1_024;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSessionState {
|
||||
schema: String,
|
||||
sessions: BTreeMap<String, AppSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AppSession {
|
||||
schema: String,
|
||||
cookie_hash: String,
|
||||
account_subject_hash: String,
|
||||
heimdall_session_id: String,
|
||||
access_revision: u64,
|
||||
verified_capabilities: Vec<String>,
|
||||
access_expires_at: DateTime<Utc>,
|
||||
refresh_expires_at: DateTime<Utc>,
|
||||
encrypted_refresh_claim: String,
|
||||
created_at: DateTime<Utc>,
|
||||
last_refresh_at: DateTime<Utc>,
|
||||
revoked_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,980 +0,0 @@
|
|||
//! 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
{
|
||||
"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
|
|
@ -1,21 +0,0 @@
|
|||
mod app_session;
|
||||
mod eve;
|
||||
mod heimdall;
|
||||
mod idunn_health;
|
||||
mod mesh;
|
||||
mod play;
|
||||
mod runtime;
|
||||
|
||||
use anyhow::bail;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let args = std::env::args_os().skip(1).collect::<Vec<_>>();
|
||||
let state_root = match args.as_slice() {
|
||||
[flag, path] if flag == "--state-root" => Some(PathBuf::from(path)),
|
||||
[] => None,
|
||||
_ => bail!("usage: ghostlight-dungeon [--state-root PATH]"),
|
||||
};
|
||||
runtime::run(state_root).await
|
||||
}
|
||||
|
|
@ -1,464 +0,0 @@
|
|||
//! Derived CultMesh and Eve projection.
|
||||
//!
|
||||
//! The mesh publishes the world owner's current surface. It never pulls or
|
||||
//! repairs world state and it has no command authority.
|
||||
|
||||
use crate::eve;
|
||||
use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot};
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
use cultcache_rs::DatabaseEntry;
|
||||
use cultmesh_rs::{
|
||||
CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions,
|
||||
publish_cultnet_messages_to_rudp_catalog,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::{
|
||||
net::SocketAddr,
|
||||
path::Path,
|
||||
sync::{
|
||||
Arc, Condvar, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
thread,
|
||||
};
|
||||
|
||||
pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon";
|
||||
pub(crate) const SURFACE_ID: &str = "ghostlight.play";
|
||||
pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands";
|
||||
pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1";
|
||||
pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1";
|
||||
/// The repository that owns this surface, as the command receipt contract
|
||||
/// requires of every result.
|
||||
pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight";
|
||||
|
||||
const HEALTH_KEY: &str = "ghostlight:dungeon:health";
|
||||
const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon";
|
||||
const SURFACE_KEY: &str = "eve:surface:ghostlight.play";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")]
|
||||
struct EveSurfaceRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "gamecult.eve.provider_advertisement",
|
||||
schema = "gamecult.eve.provider_advertisement.v1"
|
||||
)]
|
||||
struct EveProviderAdvertisementRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, DatabaseEntry)]
|
||||
#[cultcache(
|
||||
type = "ghostlight.service_health",
|
||||
schema = "ghostlight.service_health.v2"
|
||||
)]
|
||||
struct ServiceHealthRecord {
|
||||
#[cultcache(key = 0)]
|
||||
value: Value,
|
||||
}
|
||||
|
||||
cultmesh_rs::cultmesh_documents!(GhostlightDocuments {
|
||||
EveSurfaceRecord => "gamecult.eve.surface.v1",
|
||||
EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1",
|
||||
ServiceHealthRecord => "ghostlight.service_health.v2",
|
||||
});
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct MeshRuntimeIdentity {
|
||||
pub(crate) runtime_id: String,
|
||||
pub(crate) service_id: String,
|
||||
pub(crate) located_service: String,
|
||||
}
|
||||
|
||||
impl Default for MeshRuntimeIdentity {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
runtime_id: "ghostlight-dungeon".into(),
|
||||
service_id: "ghostlight-dungeon".into(),
|
||||
located_service: "local".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct MeshPublisher {
|
||||
node: Arc<Mutex<CultMeshNode>>,
|
||||
remote: Option<RemoteReplication>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct RemoteReplication {
|
||||
target: SocketAddr,
|
||||
runtime_id: String,
|
||||
pending: Arc<(Mutex<Option<Vec<cultnet_rs::CultNetMessage>>>, Condvar)>,
|
||||
alive: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl MeshPublisher {
|
||||
pub(crate) fn open(
|
||||
store_path: impl AsRef<Path>,
|
||||
rudp_target: Option<SocketAddr>,
|
||||
identity: MeshRuntimeIdentity,
|
||||
) -> Result<Self> {
|
||||
if let Some(parent) = store_path.as_ref().parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let node = CultMesh::create_node(
|
||||
store_path,
|
||||
GhostlightDocuments,
|
||||
CultMeshNodeOptions {
|
||||
runtime_id: identity.runtime_id.clone(),
|
||||
// Ghostlight derives its own interface from the local owner.
|
||||
// Stale remote surfaces must never become startup authority.
|
||||
pull_on_start: false,
|
||||
},
|
||||
)?;
|
||||
let remote = rudp_target
|
||||
.map(|target| RemoteReplication::start(target, identity.runtime_id.clone()))
|
||||
.transpose()?;
|
||||
Ok(Self {
|
||||
node: Arc::new(Mutex::new(node)),
|
||||
remote,
|
||||
identity,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value {
|
||||
provider_advertisement(&self.identity, updated_at)
|
||||
}
|
||||
|
||||
pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result<Value> {
|
||||
let surface = eve::mesh_surface(snapshot);
|
||||
let world_state = eve::world_state(snapshot);
|
||||
let version = eve::surface_version(snapshot);
|
||||
let updated_at = Utc::now().to_rfc3339();
|
||||
let health = json!({
|
||||
"schema":"ghostlight.service_health.v2",
|
||||
"status":"ok",
|
||||
"worldState":world_state,
|
||||
"surfaceVersion":version,
|
||||
"updatedAtUtc":updated_at,
|
||||
"runtime":self.identity.runtime_id,
|
||||
"commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
});
|
||||
let advertisement = provider_advertisement(&self.identity, &updated_at);
|
||||
let mut node = self
|
||||
.node
|
||||
.lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?;
|
||||
let mut remote_messages = Vec::new();
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
ADVERTISEMENT_KEY,
|
||||
&EveProviderAdvertisementRecord {
|
||||
value: advertisement,
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
SURFACE_KEY,
|
||||
&EveSurfaceRecord { value: surface },
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
// Health is the completion marker for this derived projection. A
|
||||
// failed advertisement or surface write must leave the prior version
|
||||
// visibly stale rather than publishing green first.
|
||||
self.put_and_stage(
|
||||
&mut node,
|
||||
HEALTH_KEY,
|
||||
&ServiceHealthRecord {
|
||||
value: health.clone(),
|
||||
},
|
||||
&mut remote_messages,
|
||||
)?;
|
||||
drop(node);
|
||||
if let Some(remote) = &self.remote {
|
||||
remote.enqueue(remote_messages);
|
||||
}
|
||||
Ok(health)
|
||||
}
|
||||
|
||||
pub(crate) fn health(&self) -> Result<Value> {
|
||||
if self
|
||||
.remote
|
||||
.as_ref()
|
||||
.is_some_and(|remote| !remote.is_alive())
|
||||
{
|
||||
anyhow::bail!("Odin projection worker stopped");
|
||||
}
|
||||
self.node
|
||||
.try_lock()
|
||||
.map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))?
|
||||
.get_required::<ServiceHealthRecord>(HEALTH_KEY)
|
||||
.map(|record| record.value)
|
||||
}
|
||||
|
||||
fn put_and_stage<T>(
|
||||
&self,
|
||||
node: &mut CultMeshNode,
|
||||
key: impl Into<String>,
|
||||
value: &T,
|
||||
remote_messages: &mut Vec<cultnet_rs::CultNetMessage>,
|
||||
) -> Result<()>
|
||||
where
|
||||
T: DatabaseEntry + serde::Serialize,
|
||||
{
|
||||
let key = key.into();
|
||||
node.put(&key, value)?;
|
||||
if let Some(remote) = &self.remote {
|
||||
remote_messages.push(node.create_rudp_document_message(
|
||||
&key,
|
||||
value,
|
||||
&remote.options(),
|
||||
)?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value {
|
||||
json!({
|
||||
"schema":"gamecult.eve.provider_advertisement.v1",
|
||||
"providerId":PROVIDER_ID,
|
||||
"serviceId":identity.service_id,
|
||||
"verseId":"gamecult.private",
|
||||
"rootVerse":"gamecult",
|
||||
"canonicalService":"ghostlight-dungeon",
|
||||
"locatedService":identity.located_service,
|
||||
"cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon",
|
||||
"title":"Ghostlight Dungeon",
|
||||
"kind":"narrative.simulation",
|
||||
"updatedAtUtc":updated_at,
|
||||
"freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000},
|
||||
"schemas":[
|
||||
"gamecult.eve.surface.v1",
|
||||
"gamecult.eve.command_invocation.v1",
|
||||
COMMAND_RESULT_SCHEMA,
|
||||
"heimdall.access_gate_state.v1",
|
||||
STATE_SCHEMA,
|
||||
CONSUMER_PATCH_SCHEMA,
|
||||
CONSUMER_RECEIPT_SCHEMA
|
||||
],
|
||||
"witnesses":[{
|
||||
"kind":"source-commit",
|
||||
"ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development")
|
||||
}],
|
||||
"surfaces":[{
|
||||
"surfaceId":SURFACE_ID,
|
||||
"schema":"gamecult.eve.surface.v1",
|
||||
"url":"/api/eve/surfaces/ghostlight.play",
|
||||
"transport":"https-json",
|
||||
"status":"available",
|
||||
"surfaceKind":"subject-scoped-narrative",
|
||||
"interactionModel":"typed-command-receipts",
|
||||
"worldInteraction":{
|
||||
"projectionKind":"owner-derived",
|
||||
"stateSchemas":[STATE_SCHEMA],
|
||||
"commandBoundary":COMMAND_BOUNDARY,
|
||||
"receiptSchema":COMMAND_RESULT_SCHEMA,
|
||||
"loweringTargets":["browser","eve-native","tui"],
|
||||
"ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands."
|
||||
},
|
||||
"requiresPlugins":[{
|
||||
"pluginId":"gamecult.heimdall.access",
|
||||
"versionRange":"^1.0.0",
|
||||
"availability":"required",
|
||||
"requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"]
|
||||
}]
|
||||
}],
|
||||
"commands":[{
|
||||
"command":COMMAND_BOUNDARY,
|
||||
"transport":"https-json",
|
||||
"summary":"Canonical Ghostlight Eve command boundary."
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
impl RemoteReplication {
|
||||
fn start(target: SocketAddr, runtime_id: String) -> Result<Self> {
|
||||
let pending = Arc::new((
|
||||
Mutex::new(None::<Vec<cultnet_rs::CultNetMessage>>),
|
||||
Condvar::new(),
|
||||
));
|
||||
let worker_pending = pending.clone();
|
||||
let worker_runtime_id = runtime_id.clone();
|
||||
let alive = Arc::new(AtomicBool::new(true));
|
||||
let worker_alive = alive.clone();
|
||||
thread::Builder::new()
|
||||
.name("ghostlight-odin-rudp".into())
|
||||
.spawn(move || {
|
||||
struct Liveness(Arc<AtomicBool>);
|
||||
impl Drop for Liveness {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(false, Ordering::Release);
|
||||
}
|
||||
}
|
||||
let _liveness = Liveness(worker_alive);
|
||||
loop {
|
||||
let messages = {
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
while pending.is_none() {
|
||||
pending = ready
|
||||
.wait(pending)
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
pending.take().unwrap_or_default()
|
||||
};
|
||||
if messages.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let options = remote_options(target, &worker_runtime_id);
|
||||
if let Err(error) =
|
||||
publish_cultnet_messages_to_rudp_catalog(&messages, options)
|
||||
{
|
||||
tracing::warn!(
|
||||
document_count = messages.len(),
|
||||
%target,
|
||||
%error,
|
||||
"Odin publication failed; retaining the newest derived projection for retry"
|
||||
);
|
||||
let (lock, ready) = &*worker_pending;
|
||||
let mut pending = lock
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
if pending.is_none() {
|
||||
*pending = Some(messages);
|
||||
}
|
||||
let _ = ready
|
||||
.wait_timeout(pending, std::time::Duration::from_secs(2))
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
target,
|
||||
runtime_id,
|
||||
pending,
|
||||
alive,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_alive(&self) -> bool {
|
||||
self.alive.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
fn options(&self) -> CultMeshRudpDocumentPublishOptions {
|
||||
remote_options(self.target, &self.runtime_id)
|
||||
}
|
||||
|
||||
fn enqueue(&self, messages: Vec<cultnet_rs::CultNetMessage>) {
|
||||
let (lock, ready) = &*self.pending;
|
||||
let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||
*pending = Some(messages);
|
||||
ready.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions {
|
||||
let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id);
|
||||
options.source_agent_id = Some(PROVIDER_ID.into());
|
||||
options.source_role = Some("narrative-simulation".into());
|
||||
options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()];
|
||||
options
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn publishes_only_owner_derived_surface_and_health() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let health = publisher.publish(None).unwrap();
|
||||
assert_eq!(health["worldState"], "empty");
|
||||
assert_eq!(health["surfaceVersion"], 0);
|
||||
assert_eq!(publisher.health().unwrap(), health);
|
||||
let node = publisher.node.lock().unwrap();
|
||||
let surface = node
|
||||
.get_required::<EveSurfaceRecord>(SURFACE_KEY)
|
||||
.unwrap()
|
||||
.value;
|
||||
assert_eq!(surface["type"], "surface-state");
|
||||
assert_eq!(surface["schema"], "gamecult.eve.surface.v1");
|
||||
assert_eq!(surface["providerId"], PROVIDER_ID);
|
||||
assert_eq!(surface["version"], 0);
|
||||
assert!(
|
||||
surface["updatedAtUtc"]
|
||||
.as_str()
|
||||
.is_some_and(|value| value.parse::<DateTime<Utc>>().is_ok())
|
||||
);
|
||||
assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]);
|
||||
assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]);
|
||||
assert!(
|
||||
node.get_required::<EveProviderAdvertisementRecord>(ADVERTISEMENT_KEY)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(node.get_required::<ServiceHealthRecord>(HEALTH_KEY).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_odin_cannot_block_local_publication() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
publisher.publish(None).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(500));
|
||||
assert_eq!(publisher.health().unwrap()["worldState"], "empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopped_odin_worker_makes_runtime_health_non_green() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
Some("127.0.0.1:1".parse().unwrap()),
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
publisher.publish(None).unwrap();
|
||||
publisher
|
||||
.remote
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.alive
|
||||
.store(false, Ordering::Release);
|
||||
assert!(publisher.health().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertisement_has_one_surface_and_one_command_boundary() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let publisher = MeshPublisher::open(
|
||||
directory.path().join("mesh-v2.cc"),
|
||||
None,
|
||||
MeshRuntimeIdentity::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z");
|
||||
assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,27 +0,0 @@
|
|||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
#[test]
|
||||
fn embedded_commit_matches_checkout_head() {
|
||||
let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "HEAD"])
|
||||
.current_dir(&repo_root)
|
||||
.output()
|
||||
.expect("git must be available for the provenance regression test");
|
||||
let checkout_head = if output.status.success() {
|
||||
String::from_utf8(output.stdout)
|
||||
.expect("git commit must be UTF-8")
|
||||
.trim()
|
||||
.to_owned()
|
||||
} else {
|
||||
fs::read_to_string(repo_root.join(".ghostlight-source-commit"))
|
||||
.expect("frozen source archive must carry its exact commit witness")
|
||||
.trim()
|
||||
.to_owned()
|
||||
};
|
||||
assert_eq!(
|
||||
env!("GHOSTLIGHT_BUILD_COMMIT"),
|
||||
checkout_head,
|
||||
"the binary embedded stale Git provenance"
|
||||
);
|
||||
}
|
||||
15
crates/ghostlight-verse/Cargo.toml
Normal file
15
crates/ghostlight-verse/Cargo.toml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
[package]
|
||||
name = "ghostlight-verse"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
ghostlight = { path = "../ghostlight" }
|
||||
tokio.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
tempfile = "3"
|
||||
346
crates/ghostlight-verse/src/main.rs
Normal file
346
crates/ghostlight-verse/src/main.rs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
//! The verse runner's operator command line.
|
||||
//!
|
||||
//! `genesis` creates the canonical Aetheria verse world in a state root, and
|
||||
//! `run` advances it a given number of ticks under a token cap, printing per
|
||||
//! tick what each faction did and what it cost. A run starts only when this
|
||||
//! command is invoked, and ends when it has done what it was asked.
|
||||
//!
|
||||
//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and
|
||||
//! `calls.cc`. The provider key is read from a file at run time and appears
|
||||
//! nowhere else.
|
||||
|
||||
use anyhow::{Context, Result, anyhow, bail};
|
||||
use chrono::{Duration, Utc};
|
||||
use ghostlight::{
|
||||
CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX,
|
||||
DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent,
|
||||
HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort,
|
||||
RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore,
|
||||
WorldMailbox, call_run_id, id_text, open_controller_work, open_inference,
|
||||
read_aetheria_catalog, run_manual,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::ops::ControlFlow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitCode;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// What a manual run spends per tick on cognition cells. The operator's lever
|
||||
/// on cost is the token cap; the partition always covers every faction.
|
||||
const COVER: CoverBudget = CoverBudget {
|
||||
cells: 8,
|
||||
constituent_cap: 8,
|
||||
urgency_slots: 2,
|
||||
};
|
||||
|
||||
const RUNTIME_ID: &str = "ghostlight-verse";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
let arguments: Vec<String> = std::env::args().skip(1).collect();
|
||||
match command(arguments).await {
|
||||
Ok(code) => code,
|
||||
Err(error) => {
|
||||
eprintln!("ghostlight-verse: {error:#}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn command(arguments: Vec<String>) -> Result<ExitCode> {
|
||||
let Some((name, rest)) = arguments.split_first() else {
|
||||
bail!("usage: ghostlight-verse genesis|run [flags]");
|
||||
};
|
||||
match name.as_str() {
|
||||
"genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await,
|
||||
"run" => run(Flags::parse(rest, &RUN_FLAGS)?).await,
|
||||
_ => bail!("unknown command, expected genesis or run"),
|
||||
}
|
||||
}
|
||||
|
||||
const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"];
|
||||
const RUN_FLAGS: [&str; 7] = [
|
||||
"state",
|
||||
"ticks",
|
||||
"token-cap",
|
||||
"tick-span-minutes",
|
||||
"model",
|
||||
"base-url",
|
||||
"key-file",
|
||||
];
|
||||
/// Flags a command may omit.
|
||||
const OPTIONAL: [&str; 1] = ["key-file"];
|
||||
|
||||
/// `--name value` pairs, each named once and from the command's own list.
|
||||
struct Flags(BTreeMap<&'static str, String>);
|
||||
|
||||
impl Flags {
|
||||
fn parse(arguments: &[String], allowed: &[&'static str]) -> Result<Self> {
|
||||
let mut seen = BTreeMap::new();
|
||||
let mut rest = arguments.iter();
|
||||
while let Some(flag) = rest.next() {
|
||||
let Some(name) = flag.strip_prefix("--") else {
|
||||
bail!("expected a --flag, found a bare argument");
|
||||
};
|
||||
let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else {
|
||||
bail!("a flag this command does not take was given");
|
||||
};
|
||||
let Some(value) = rest.next() else {
|
||||
bail!("--{known} needs a value");
|
||||
};
|
||||
if seen.insert(known, value.clone()).is_some() {
|
||||
bail!("--{known} was given twice");
|
||||
}
|
||||
}
|
||||
for &required in allowed {
|
||||
if !OPTIONAL.contains(&required) && !seen.contains_key(required) {
|
||||
bail!("--{required} is required");
|
||||
}
|
||||
}
|
||||
Ok(Self(seen))
|
||||
}
|
||||
|
||||
fn text(&self, name: &str) -> &str {
|
||||
self.0.get(name).map_or("", String::as_str)
|
||||
}
|
||||
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
PathBuf::from(self.text(name))
|
||||
}
|
||||
|
||||
fn number<T: std::str::FromStr + PartialOrd + Default>(&self, name: &str) -> Result<T> {
|
||||
match self.text(name).parse::<T>() {
|
||||
Ok(value) if value > T::default() => Ok(value),
|
||||
_ => bail!("--{name} is not a positive integer"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn genesis(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
std::fs::create_dir_all(&state).context("the state directory cannot be created")?;
|
||||
let catalog = read_aetheria_catalog(flags.path("catalog"))
|
||||
.context("the Aetheria catalog cannot be read")?;
|
||||
let factions = catalog.factions.len();
|
||||
let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1));
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let receipt = mailbox
|
||||
.create_verse(
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Aetheria Verse".into(),
|
||||
brief: "The Aetheria verse, simulated from its catalog.".into(),
|
||||
catalog,
|
||||
aetheria_commit: flags.text("aetheria-commit").into(),
|
||||
lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()),
|
||||
targets: BTreeMap::new(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the verse world cannot be created")?;
|
||||
// A created world is a draft: the owner approves and activates it, so the
|
||||
// factions hold opportunities.
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision;
|
||||
mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.context("the world cannot be activated")?;
|
||||
}
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
println!("world {}", id_text(receipt.world_id));
|
||||
println!("factions {factions}");
|
||||
println!("phase Active");
|
||||
Ok(ExitCode::SUCCESS)
|
||||
}
|
||||
|
||||
async fn run(flags: Flags) -> Result<ExitCode> {
|
||||
let state = flags.path("state");
|
||||
let ticks: u32 = flags.number("ticks")?;
|
||||
let cap: u64 = flags.number("token-cap")?;
|
||||
let span: u32 = flags.number("tick-span-minutes")?;
|
||||
let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?;
|
||||
if !state.join("world.cc").is_file() {
|
||||
bail!("the state directory holds no world; run genesis first");
|
||||
}
|
||||
|
||||
let model = flags.text("model").to_owned();
|
||||
let models = ControllerModels {
|
||||
projector: model.clone(),
|
||||
persona: model.clone(),
|
||||
interpreter: model.clone(),
|
||||
operational_agent: model.clone(),
|
||||
elaborator: model.clone(),
|
||||
};
|
||||
let inner = open_lane(&flags, &model, &models)?;
|
||||
|
||||
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
|
||||
.map_err(store_fault("the world file in --state cannot be opened"))?;
|
||||
let world = mailbox.snapshot().await.context("the world cannot be read")?;
|
||||
let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).map_err(store_fault("the call records in --state cannot be opened"))?);
|
||||
let runs = VerseRunStore::open(state.join("runs.cc")).map_err(store_fault("the run records in --state cannot be opened"))?;
|
||||
let work = open_controller_work(state.join("controller-work.cc"))
|
||||
.map_err(store_fault("the controller work store in --state cannot be opened"))?;
|
||||
let config = VerseRunConfig {
|
||||
ticks,
|
||||
tick_span,
|
||||
cover: COVER,
|
||||
};
|
||||
let claim = runs
|
||||
.claim(&world, cap, &config)
|
||||
.context("the run cannot be claimed")?;
|
||||
let sequence = claim.sequence();
|
||||
// A start that fails after the claim ends the claimed run, so it binds
|
||||
// nothing: the next claim takes the next number.
|
||||
let started = RecordingInferencePort::open(
|
||||
inner,
|
||||
calls,
|
||||
call_run_id(sequence),
|
||||
cap,
|
||||
&RunStart::of(&world),
|
||||
)
|
||||
.context("the run's call records cannot be opened")
|
||||
.and_then(|port| {
|
||||
let port = Arc::new(port);
|
||||
ControllerRunner::open(mailbox.clone(), port.clone(), work, models)
|
||||
.map(|runner| (port, runner))
|
||||
.context("the controller organ cannot open")
|
||||
});
|
||||
let (port, runner) = match started {
|
||||
Ok(started) => started,
|
||||
Err(error) => {
|
||||
return Err(match runs.abandon(claim, "start_refused") {
|
||||
Ok(()) => error,
|
||||
Err(_) => error.context("the refused run's record could not be ended"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}"
|
||||
);
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
{
|
||||
let interrupted = interrupted.clone();
|
||||
tokio::spawn(async move {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
interrupted.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
}
|
||||
let outcome = run_manual(
|
||||
&mailbox,
|
||||
&runner,
|
||||
&port,
|
||||
&runs,
|
||||
claim,
|
||||
&config,
|
||||
|report| {
|
||||
print!("{report}");
|
||||
if interrupted.load(Ordering::SeqCst) {
|
||||
ControlFlow::Break(())
|
||||
} else {
|
||||
ControlFlow::Continue(())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("the run could not be recorded")?;
|
||||
println!(
|
||||
"status: {}; {} of {} ticks closed",
|
||||
outcome.record.status,
|
||||
outcome.record.ticks_closed(),
|
||||
outcome.record.ticks_requested
|
||||
);
|
||||
drop(runner);
|
||||
drop(port);
|
||||
drop(mailbox);
|
||||
task.await.context("the world file did not close cleanly")?;
|
||||
Ok(match outcome.record.status {
|
||||
RunStatus::Failed { .. } => ExitCode::FAILURE,
|
||||
_ => ExitCode::SUCCESS,
|
||||
})
|
||||
}
|
||||
|
||||
/// The inference lane the model's prefix names. A hosted model needs an https
|
||||
/// origin and a key file; a local one reaches a loopback server and needs
|
||||
/// neither, so a key file beside it is refused.
|
||||
fn open_lane(
|
||||
flags: &Flags,
|
||||
model: &str,
|
||||
models: &ControllerModels,
|
||||
) -> Result<Arc<dyn ghostlight::InferencePort>> {
|
||||
let (mut hosted, mut local) = (None, None);
|
||||
if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) {
|
||||
let Some(key) = flags.0.get("key-file") else {
|
||||
bail!("a hosted model needs --key-file");
|
||||
};
|
||||
hosted = Some(HostedBinding {
|
||||
base_url: flags.text("base-url").to_owned(),
|
||||
key_path: Path::new(key).to_owned(),
|
||||
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) {
|
||||
if flags.0.contains_key("key-file") {
|
||||
bail!("a local model takes no --key-file");
|
||||
}
|
||||
let endpoint: SocketAddr = flags
|
||||
.text("base-url")
|
||||
.strip_prefix("http://")
|
||||
.and_then(|address| address.trim_end_matches('/').parse().ok())
|
||||
.context("--base-url for a local model is not http://<address>:<port>")?;
|
||||
local = Some(LocalBinding {
|
||||
endpoint,
|
||||
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
|
||||
caller_runtime_id: RUNTIME_ID.into(),
|
||||
});
|
||||
} else {
|
||||
bail!("--model names no inference lane (expected a hosted/ or local/ prefix)");
|
||||
}
|
||||
open_inference(None, None, local, hosted, &models.each())
|
||||
.map_err(|error| anyhow!(lane_fault(&error)))
|
||||
}
|
||||
|
||||
/// A store that cannot open says which one and that it is under `--state`,
|
||||
/// never the path or the store's own text, which carries the path.
|
||||
fn store_fault<E>(message: &'static str) -> impl FnOnce(E) -> anyhow::Error {
|
||||
move |_| anyhow!(message)
|
||||
}
|
||||
|
||||
/// What a lane that cannot open says: the flag or the kind of fault, never a
|
||||
/// value the operator passed.
|
||||
fn lane_fault(error: &ControllerOpenError) -> &'static str {
|
||||
match error {
|
||||
ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier",
|
||||
ControllerOpenError::Connector(_) => "the connector lane cannot open",
|
||||
ControllerOpenError::WorkStore(_) => "the controller work journal cannot open",
|
||||
ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane",
|
||||
ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file",
|
||||
ControllerOpenError::LocalEndpointNotLoopback { .. } => {
|
||||
"--base-url for a local model must be a loopback address"
|
||||
}
|
||||
ControllerOpenError::HostedEndpointNotHttps => {
|
||||
"--base-url for a hosted model must be an https URL"
|
||||
}
|
||||
ControllerOpenError::HostedKeyUnreadable => {
|
||||
"--key-file is unreadable, empty, or not one run of visible ASCII"
|
||||
}
|
||||
ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix",
|
||||
ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty",
|
||||
}
|
||||
}
|
||||
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
445
crates/ghostlight-verse/tests/verse_binary.rs
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
//! The `ghostlight-verse` command against a loopback scripted responder. No
|
||||
//! real model is called and no key file is read: the responder answers the
|
||||
//! OpenAI-compatible chat-completions request the local lane sends.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{SocketAddr, TcpListener, TcpStream};
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Output};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse");
|
||||
const CATALOG: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
/// What the responder reports each call cost: 120 prompt and 30 completion.
|
||||
const PER_CALL: u64 = 150;
|
||||
|
||||
fn verse(arguments: &[&str]) -> Output {
|
||||
Command::new(BINARY).args(arguments).output().unwrap()
|
||||
}
|
||||
|
||||
fn stdout(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stdout).into_owned()
|
||||
}
|
||||
|
||||
fn stderr(output: &Output) -> String {
|
||||
String::from_utf8_lossy(&output.stderr).into_owned()
|
||||
}
|
||||
|
||||
fn genesis(state: &Path) -> Output {
|
||||
verse(&[
|
||||
"genesis",
|
||||
"--catalog",
|
||||
CATALOG,
|
||||
"--aetheria-commit",
|
||||
COMMIT,
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--owner-hash",
|
||||
"verse-owner",
|
||||
])
|
||||
}
|
||||
|
||||
/// Answers every chat-completions request with one tool call: the faction the
|
||||
/// prompt names speaks its own line. Counts the requests it served.
|
||||
struct Responder {
|
||||
address: SocketAddr,
|
||||
served: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Responder {
|
||||
fn start() -> Self {
|
||||
Self::answering(200)
|
||||
}
|
||||
|
||||
/// A provider that refuses every call with this status.
|
||||
fn refusing(status: u16) -> Self {
|
||||
Self::answering(status)
|
||||
}
|
||||
|
||||
fn answering(status: u16) -> Self {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let served = Arc::new(AtomicUsize::new(0));
|
||||
let counter = served.clone();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let Ok(stream) = stream else { return };
|
||||
let counter = counter.clone();
|
||||
std::thread::spawn(move || serve(stream, &counter, status));
|
||||
}
|
||||
});
|
||||
Self { address, served }
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
format!("http://{}", self.address)
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) {
|
||||
let mut raw = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
let (head_end, length) = loop {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
|
||||
let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
|
||||
let length = head
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("content-length:"))
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.unwrap_or(0);
|
||||
break (end + 4, length);
|
||||
}
|
||||
};
|
||||
while raw.len() < head_end + length {
|
||||
let read = stream.read(&mut chunk).unwrap_or(0);
|
||||
if read == 0 {
|
||||
return;
|
||||
}
|
||||
raw.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap();
|
||||
let text = request.to_string();
|
||||
let faction = ["Adrasteia", "Brannoch"]
|
||||
.into_iter()
|
||||
.find(|name| text.contains(&format!("Decision owner:\\n{name}\\n")))
|
||||
.expect("a request names its faction");
|
||||
let sequence = served.fetch_add(1, Ordering::SeqCst);
|
||||
let reply = json!({
|
||||
"id": format!("reply-{sequence}"),
|
||||
"choices": [{
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": null,
|
||||
"tool_calls": [{
|
||||
"id": format!("call-{sequence}"),
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "speak",
|
||||
"arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(),
|
||||
},
|
||||
}],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": { "prompt_tokens": 120, "completion_tokens": 30 },
|
||||
})
|
||||
.to_string();
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
|
||||
reply.len()
|
||||
);
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
}
|
||||
|
||||
fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output {
|
||||
verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.to_str().unwrap(),
|
||||
"--ticks",
|
||||
ticks,
|
||||
"--token-cap",
|
||||
cap,
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/scripted",
|
||||
"--base-url",
|
||||
&responder.url(),
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binary_run_prints_acts_and_spend() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let made = genesis(state.path());
|
||||
assert!(made.status.success(), "{}", stderr(&made));
|
||||
let made = stdout(&made);
|
||||
assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}");
|
||||
assert!(state.path().join("world.cc").is_file());
|
||||
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
|
||||
// Two ticks, each with both factions named by verb, and the spend as it
|
||||
// accrues against the cap.
|
||||
assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}");
|
||||
for tick in 0..2u64 {
|
||||
let spent = PER_CALL * 2 * (tick + 1);
|
||||
assert!(
|
||||
text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(&format!(
|
||||
" tokens: {} this tick, {spent} spent, {} remaining\n",
|
||||
PER_CALL * 2,
|
||||
1_000_000 - spent
|
||||
)),
|
||||
"{text}"
|
||||
);
|
||||
}
|
||||
for faction in ["Adrasteia", "Brannoch"] {
|
||||
assert_eq!(
|
||||
text.matches(&format!(" {faction} speak [")).count(),
|
||||
2,
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}");
|
||||
}
|
||||
assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 4);
|
||||
|
||||
// The state root holds each store the run wrote.
|
||||
for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] {
|
||||
assert!(state.path().join(file).is_file(), "{file} was not written");
|
||||
}
|
||||
|
||||
// A second run is the next in the world and carries on from its clock.
|
||||
let again = run(state.path(), &responder, "1", "1000000");
|
||||
assert!(again.status.success(), "{}", stderr(&again));
|
||||
let text = stdout(&again);
|
||||
assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}");
|
||||
assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::start();
|
||||
let output = run(state.path(), &responder, "3", "1000");
|
||||
assert!(output.status.success(), "{}", stderr(&output));
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}");
|
||||
assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}");
|
||||
assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_run_that_fails_says_so_and_exits_nonzero() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let responder = Responder::refusing(500);
|
||||
let output = run(state.path(), &responder, "2", "1000000");
|
||||
assert!(!output.status.success());
|
||||
let text = stdout(&output);
|
||||
assert!(text.contains("tick 0 open: "), "{text}");
|
||||
assert!(
|
||||
text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
responder.served.load(Ordering::SeqCst),
|
||||
1,
|
||||
"the run stopped at the first refusal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
let responder = Responder::start();
|
||||
|
||||
// No world yet.
|
||||
let output = run(state.path(), &responder, "1", "1000");
|
||||
assert!(!output.status.success());
|
||||
assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output));
|
||||
|
||||
assert!(genesis(state.path()).status.success());
|
||||
// A second genesis does not overwrite the first.
|
||||
assert!(!genesis(state.path()).status.success());
|
||||
|
||||
let secret = "sk-not-a-real-key-0000";
|
||||
let key = state.path().join(secret);
|
||||
let cases: [(Vec<&str>, &str); 9] = [
|
||||
(vec![], "usage"),
|
||||
(vec!["fly"], "unknown command"),
|
||||
(
|
||||
vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"],
|
||||
"--token-cap is required",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "many",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "0",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--ticks is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--token-cap is not a positive integer",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4",
|
||||
"--base-url", "http://127.0.0.1:1",
|
||||
],
|
||||
"--model names no inference lane",
|
||||
),
|
||||
(
|
||||
// A hosted model needs its key file; a missing one is named by flag.
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x",
|
||||
"--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"--key-file is unreadable",
|
||||
),
|
||||
(
|
||||
vec![
|
||||
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
|
||||
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
|
||||
"--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(),
|
||||
],
|
||||
"a local model takes no --key-file",
|
||||
),
|
||||
];
|
||||
for (arguments, expected) in cases {
|
||||
let output = verse(&arguments);
|
||||
assert!(!output.status.success(), "{arguments:?} succeeded");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains(expected), "{arguments:?}: {error}");
|
||||
assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}");
|
||||
assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() {
|
||||
let state = tempfile::tempdir().unwrap();
|
||||
assert!(genesis(state.path()).status.success());
|
||||
let output = verse(&[
|
||||
"run",
|
||||
"--state",
|
||||
state.path().to_str().unwrap(),
|
||||
"--ticks",
|
||||
"1",
|
||||
"--token-cap",
|
||||
"1000",
|
||||
"--tick-span-minutes",
|
||||
"60",
|
||||
"--model",
|
||||
"local/x",
|
||||
"--base-url",
|
||||
"http://10.77.0.99:4242",
|
||||
]);
|
||||
assert!(!output.status.success());
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--base-url"), "{error}");
|
||||
assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}");
|
||||
// The lane failed before any run was claimed.
|
||||
assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output));
|
||||
}
|
||||
|
||||
/// A start refused after its claim ends the claimed record Failed, so the run
|
||||
/// binds nothing. Reached from the command by removing the run records while
|
||||
/// the call ledger still holds run 1 under another cap.
|
||||
#[test]
|
||||
fn a_start_the_ledger_refuses_ends_its_claimed_record_failed() {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("state");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let responder = Responder::start();
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
std::fs::remove_file(state.join("runs.cc")).unwrap();
|
||||
|
||||
let refused = run(&state, &responder, "1", "500000");
|
||||
assert!(!refused.status.success());
|
||||
|
||||
let world = {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let (mailbox, task) = ghostlight::WorldMailbox::open(state.join("world.cc")).unwrap();
|
||||
let world = runtime.block_on(mailbox.snapshot()).unwrap().world_id;
|
||||
drop(mailbox);
|
||||
runtime.block_on(task).unwrap();
|
||||
world
|
||||
};
|
||||
let runs = ghostlight::VerseRunStore::open(state.join("runs.cc"))
|
||||
.unwrap()
|
||||
.runs(world)
|
||||
.unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(
|
||||
runs[0].status,
|
||||
ghostlight::RunStatus::Failed {
|
||||
reason: "start_refused".into()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// A store that cannot open names the flag and the fault, never the path.
|
||||
#[test]
|
||||
fn a_store_that_cannot_open_never_echoes_the_state_path() {
|
||||
let responder = Responder::start();
|
||||
for file in ["world.cc", "controller-work.cc"] {
|
||||
for held in [false, true] {
|
||||
let parent = tempfile::tempdir().unwrap();
|
||||
let state = parent.path().join("distinctivestatename");
|
||||
std::fs::create_dir_all(&state).unwrap();
|
||||
assert!(genesis(&state).status.success());
|
||||
let first = run(&state, &responder, "1", "1000000");
|
||||
assert!(first.status.success(), "{}", stderr(&first));
|
||||
|
||||
let target = state.join(file);
|
||||
let _guard: Box<dyn std::any::Any> = if !held {
|
||||
std::fs::write(&target, b"not a database").unwrap();
|
||||
Box::new(())
|
||||
} else if file == "world.cc" {
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let entered = runtime.enter();
|
||||
let opened = ghostlight::WorldMailbox::open(&target).unwrap();
|
||||
drop(entered);
|
||||
Box::new((runtime, opened))
|
||||
} else {
|
||||
Box::new(ghostlight::open_controller_work(&target).unwrap())
|
||||
};
|
||||
|
||||
let output = run(&state, &responder, "1", "1000000");
|
||||
assert!(!output.status.success(), "{file} held={held}");
|
||||
let error = stderr(&output);
|
||||
assert!(error.contains("--state"), "{file} held={held}: {error}");
|
||||
assert!(
|
||||
!error.contains("distinctivestatename"),
|
||||
"{file} held={held} echoed the path: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ cultcache-rs.workspace = true
|
|||
ghostlight-persona-projection = { path = "../ghostlight-persona-projection" }
|
||||
reqwest.workspace = true
|
||||
rmp-serde.workspace = true
|
||||
rmpv = "1.3"
|
||||
serde.workspace = true
|
||||
serde_bytes = "0.11"
|
||||
serde_json.workspace = true
|
||||
|
|
@ -19,8 +20,15 @@ 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"
|
||||
|
|
|
|||
297
crates/ghostlight/src/aetheria.rs
Normal file
297
crates/ghostlight/src/aetheria.rs
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
//! Aetheria's catalog file, read once into the two things Ghostlight takes from
|
||||
//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records
|
||||
//! keyed for import.
|
||||
//!
|
||||
//! The catalog is Aetheria's: this reader opens it read-only and never writes
|
||||
//! it, and it ignores every schema it does not mirror. The mirrors below decode
|
||||
//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction`
|
||||
//! v1 by integer key: a MessagePack-keyed object is an array indexed by key,
|
||||
//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored
|
||||
//! and a field it drops is an error naming the record and the key. A record
|
||||
//! that does not decode, an enum value outside the mirror and a verb that
|
||||
//! breaks the grammar's name or role rules are errors, never skipped rows.
|
||||
|
||||
use crate::ImportKey;
|
||||
use crate::grammar::{
|
||||
GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath,
|
||||
};
|
||||
use cultcache_rs::SingleFileMessagePackBackingStore;
|
||||
use rmpv::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar";
|
||||
const VERB_SCHEMA: &str = "aetheria.verse_verb";
|
||||
const FACTION_SCHEMA: &str = "aetheria.faction";
|
||||
|
||||
const VERB_NAME_MAX: usize = 48;
|
||||
const ROLE_NAME_MAX: usize = 32;
|
||||
/// The invoking subject, never a role.
|
||||
const ACTOR_ROLE: &str = "actor";
|
||||
|
||||
/// What Ghostlight takes from Aetheria's catalog.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaCatalog {
|
||||
pub grammar: GrammarBinding,
|
||||
/// In record-key order.
|
||||
pub factions: Vec<AetheriaFaction>,
|
||||
}
|
||||
|
||||
/// One `aetheria.faction` record. `allegiance` is carried and read by no
|
||||
/// consumer yet.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct AetheriaFaction {
|
||||
pub key: ImportKey,
|
||||
pub name: String,
|
||||
pub short_name: String,
|
||||
pub description: String,
|
||||
pub allegiance: BTreeMap<ImportKey, f32>,
|
||||
}
|
||||
|
||||
/// Which of the grammar's name or role rules a verb broke.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum VerbRule {
|
||||
#[error("verb name is not canonical")]
|
||||
Name,
|
||||
#[error("role name is not canonical")]
|
||||
RoleName,
|
||||
#[error("role name is reserved for the invoking subject")]
|
||||
ReservedRole,
|
||||
#[error("role name is declared twice")]
|
||||
DuplicateRole,
|
||||
}
|
||||
|
||||
/// Why a catalog could not be read. Records are named by record key and fields
|
||||
/// by integer key; no error carries a value the catalog held.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum CatalogReadError {
|
||||
#[error("the catalog file cannot be read")]
|
||||
Unreadable,
|
||||
#[error("the catalog holds no aetheria.verse_grammar global")]
|
||||
NoGrammar,
|
||||
#[error("the catalog holds more than one aetheria.verse_grammar record")]
|
||||
MultipleGrammars,
|
||||
#[error("record {record:?} field {field} is missing or not the mirrored type")]
|
||||
MalformedRecord { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a canonical import key")]
|
||||
NonCanonicalKey { record: String, field: u8 },
|
||||
#[error("record {record:?} is a second aetheria.faction under one record key")]
|
||||
DuplicateFaction { record: String },
|
||||
#[error("record {record:?} field {field} is not a known render path")]
|
||||
UnknownRenderPath { record: String, field: u8 },
|
||||
#[error("record {record:?} field {field} is not a known referent kind")]
|
||||
UnknownReferentKind { record: String, field: u8 },
|
||||
#[error("verb record {record:?} breaks a grammar rule: {rule}")]
|
||||
InvalidVerb { record: String, rule: VerbRule },
|
||||
#[error("the verbs do not bind a grammar: {0}")]
|
||||
Grammar(#[from] GrammarError),
|
||||
}
|
||||
|
||||
/// Reads the catalog at `path` without writing it. An absent file is
|
||||
/// `Unreadable`, not an empty catalog.
|
||||
pub fn read_aetheria_catalog(path: impl AsRef<Path>) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let path = path.as_ref();
|
||||
std::fs::metadata(path).map_err(|_| CatalogReadError::Unreadable)?;
|
||||
let mut rows = SingleFileMessagePackBackingStore::new(path)
|
||||
.pull_all_read_only_snapshot()
|
||||
.map_err(|_| CatalogReadError::Unreadable)?;
|
||||
rows.sort_by(|a, b| a.key.cmp(&b.key));
|
||||
|
||||
let mut revision = None;
|
||||
let mut verbs = Vec::new();
|
||||
let mut factions = Vec::new();
|
||||
for row in &rows {
|
||||
match row.r#type.as_str() {
|
||||
GRAMMAR_SCHEMA => {
|
||||
if revision.is_some() {
|
||||
return Err(CatalogReadError::MultipleGrammars);
|
||||
}
|
||||
let cells = Cells::of(&row.key, &row.payload)?;
|
||||
revision = Some(u32::try_from(cells.int(1)?).map_err(|_| cells.malformed(1))?);
|
||||
}
|
||||
VERB_SCHEMA => verbs.push(read_verb(&Cells::of(&row.key, &row.payload)?)?),
|
||||
FACTION_SCHEMA => {
|
||||
let faction = read_faction(&row.key, &Cells::of(&row.key, &row.payload)?)?;
|
||||
if factions
|
||||
.last()
|
||||
.is_some_and(|seen: &AetheriaFaction| seen.key == faction.key)
|
||||
{
|
||||
return Err(CatalogReadError::DuplicateFaction {
|
||||
record: row.key.clone(),
|
||||
});
|
||||
}
|
||||
factions.push(faction);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let revision = revision.ok_or(CatalogReadError::NoGrammar)?;
|
||||
Ok(AetheriaCatalog {
|
||||
grammar: GrammarBinding::new(GRAMMAR_SCHEMA, revision, verbs)?,
|
||||
factions,
|
||||
})
|
||||
}
|
||||
|
||||
/// A record's payload: the positional array of its keyed fields.
|
||||
struct Cells<'a> {
|
||||
record: &'a str,
|
||||
cells: Vec<Value>,
|
||||
}
|
||||
|
||||
impl<'a> Cells<'a> {
|
||||
fn of(record: &'a str, payload: &[u8]) -> Result<Self, CatalogReadError> {
|
||||
let mut payload = payload;
|
||||
match rmpv::decode::read_value(&mut payload) {
|
||||
Ok(Value::Array(cells)) => Ok(Self { record, cells }),
|
||||
_ => Err(CatalogReadError::MalformedRecord {
|
||||
record: record.to_owned(),
|
||||
field: 0,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::MalformedRecord {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_canonical(&self, field: u8) -> CatalogReadError {
|
||||
CatalogReadError::NonCanonicalKey {
|
||||
record: self.record.to_owned(),
|
||||
field,
|
||||
}
|
||||
}
|
||||
|
||||
fn cell(&self, field: u8) -> Result<&Value, CatalogReadError> {
|
||||
self.cells
|
||||
.get(usize::from(field))
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn int(&self, field: u8) -> Result<i64, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_i64()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
fn text(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
|
||||
/// A string Aetheria may leave null; null reads as empty.
|
||||
fn text_or_empty(&self, field: u8) -> Result<String, CatalogReadError> {
|
||||
match self.cell(field)? {
|
||||
Value::Nil => Ok(String::new()),
|
||||
Value::String(text) => text
|
||||
.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| self.malformed(field)),
|
||||
_ => Err(self.malformed(field)),
|
||||
}
|
||||
}
|
||||
|
||||
fn array(&self, field: u8) -> Result<&Vec<Value>, CatalogReadError> {
|
||||
self.cell(field)?
|
||||
.as_array()
|
||||
.ok_or_else(|| self.malformed(field))
|
||||
}
|
||||
}
|
||||
|
||||
fn read_verb(cells: &Cells) -> Result<(String, GrammarVerb), CatalogReadError> {
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: cells.record.to_owned(),
|
||||
rule,
|
||||
};
|
||||
let name = cells.text(1)?;
|
||||
if !canonical_name(&name, VERB_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::Name));
|
||||
}
|
||||
let render_path = match cells.int(2)? {
|
||||
1 => RenderPath::ShipAction,
|
||||
2 => RenderPath::Conversation,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownRenderPath {
|
||||
record: cells.record.to_owned(),
|
||||
field: 2,
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut roles: Vec<GrammarRole> = Vec::new();
|
||||
for role in cells.array(3)? {
|
||||
let role = Cells {
|
||||
record: cells.record,
|
||||
cells: role.as_array().cloned().ok_or_else(|| cells.malformed(3))?,
|
||||
};
|
||||
let role_name = role.text(1).map_err(|_| cells.malformed(3))?;
|
||||
if !canonical_name(&role_name, ROLE_NAME_MAX) {
|
||||
return Err(invalid(VerbRule::RoleName));
|
||||
}
|
||||
if role_name == ACTOR_ROLE {
|
||||
return Err(invalid(VerbRule::ReservedRole));
|
||||
}
|
||||
if roles.iter().any(|seen| seen.role == role_name) {
|
||||
return Err(invalid(VerbRule::DuplicateRole));
|
||||
}
|
||||
let referent = match role.int(2).map_err(|_| cells.malformed(3))? {
|
||||
1 => GrammarReferentKind::Person,
|
||||
2 => GrammarReferentKind::Faction,
|
||||
3 => GrammarReferentKind::Population,
|
||||
4 => GrammarReferentKind::Place,
|
||||
5 => GrammarReferentKind::Cargo,
|
||||
_ => {
|
||||
return Err(CatalogReadError::UnknownReferentKind {
|
||||
record: cells.record.to_owned(),
|
||||
field: 3,
|
||||
});
|
||||
}
|
||||
};
|
||||
roles.push(GrammarRole {
|
||||
role: role_name,
|
||||
referent,
|
||||
});
|
||||
}
|
||||
Ok((name, GrammarVerb { render_path, roles }))
|
||||
}
|
||||
|
||||
/// A lower-case letter, then lower-case letters, digits and underscores.
|
||||
fn canonical_name(name: &str, max_len: usize) -> bool {
|
||||
let mut chars = name.chars();
|
||||
name.len() <= max_len
|
||||
&& chars.next().is_some_and(|c| c.is_ascii_lowercase())
|
||||
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
|
||||
}
|
||||
|
||||
fn read_faction(key: &str, cells: &Cells) -> Result<AetheriaFaction, CatalogReadError> {
|
||||
let own = ImportKey::new(FACTION_SCHEMA, key);
|
||||
if !own.is_canonical() {
|
||||
return Err(cells.non_canonical(0));
|
||||
}
|
||||
let Some(allegiance) = cells.cell(12)?.as_map() else {
|
||||
return Err(cells.malformed(12));
|
||||
};
|
||||
let mut standing = BTreeMap::new();
|
||||
for (other, weight) in allegiance {
|
||||
let other = other.as_str().ok_or_else(|| cells.malformed(12))?;
|
||||
let weight = weight.as_f64().ok_or_else(|| cells.malformed(12))?;
|
||||
let other = ImportKey::new(FACTION_SCHEMA, other);
|
||||
if !other.is_canonical() {
|
||||
return Err(cells.non_canonical(12));
|
||||
}
|
||||
standing.insert(other, weight as f32);
|
||||
}
|
||||
Ok(AetheriaFaction {
|
||||
key: own,
|
||||
name: cells.text(1)?,
|
||||
short_name: cells.text_or_empty(2)?,
|
||||
description: cells.text_or_empty(3)?,
|
||||
allegiance: standing,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
589
crates/ghostlight/src/aetheria/tests.rs
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
use super::*;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`)
|
||||
/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`.
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn fixture_rows() -> Vec<CultCacheEnvelope> {
|
||||
SingleFileMessagePackBackingStore::new(FIXTURE)
|
||||
.pull_all_read_only_snapshot()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn row_of<'a>(rows: &'a mut [CultCacheEnvelope], key: &str) -> &'a mut CultCacheEnvelope {
|
||||
rows.iter_mut().find(|row| row.key == key).unwrap()
|
||||
}
|
||||
|
||||
/// Re-encodes one record's positional payload after `edit`.
|
||||
fn edit(rows: &mut [CultCacheEnvelope], key: &str, change: impl FnOnce(&mut Vec<Value>)) {
|
||||
let row = row_of(rows, key);
|
||||
let Value::Array(mut cells) = rmp_serde::from_slice(&row.payload).unwrap() else {
|
||||
panic!("{key} is not a positional record");
|
||||
};
|
||||
change(&mut cells);
|
||||
row.payload = rmp_serde::to_vec(&Value::Array(cells)).unwrap();
|
||||
}
|
||||
|
||||
fn catalog_of(rows: &[CultCacheEnvelope]) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut store = SingleFileMessagePackBackingStore::new(&path);
|
||||
for row in rows {
|
||||
store.push(row).unwrap();
|
||||
}
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
fn read(rows: &[CultCacheEnvelope]) -> Result<AetheriaCatalog, CatalogReadError> {
|
||||
let (_dir, path) = catalog_of(rows);
|
||||
read_aetheria_catalog(&path)
|
||||
}
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What Aetheria's fixture writer authored, spelled out by hand.
|
||||
fn authored_verbs() -> Vec<(String, GrammarVerb)> {
|
||||
vec![
|
||||
(
|
||||
"haul".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("cargo", GrammarReferentKind::Cargo),
|
||||
("destination", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"attack".into(),
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("target", GrammarReferentKind::Faction)],
|
||||
),
|
||||
),
|
||||
("speak".into(), verb(RenderPath::Conversation, &[])),
|
||||
]
|
||||
}
|
||||
|
||||
fn faction_key(record: &str) -> ImportKey {
|
||||
ImportKey::new("aetheria.faction", record)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_aetheria_written_fixture() {
|
||||
let catalog = read_aetheria_catalog(FIXTURE).unwrap();
|
||||
|
||||
let identity = catalog.grammar.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.verse_grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
for (kind, expected) in authored_verbs() {
|
||||
assert_eq!(catalog.grammar.verb(&kind), Some(&expected), "{kind}");
|
||||
}
|
||||
assert!(catalog.grammar.verb("mine").is_none());
|
||||
|
||||
assert_eq!(
|
||||
catalog.factions,
|
||||
vec![
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-adrasteia"),
|
||||
name: "Adrasteia".into(),
|
||||
short_name: "ADR".into(),
|
||||
description: "First.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-brannoch"), 0.75)]),
|
||||
},
|
||||
AetheriaFaction {
|
||||
key: faction_key("faction-brannoch"),
|
||||
name: "Brannoch".into(),
|
||||
short_name: "BRN".into(),
|
||||
description: "Second.".into(),
|
||||
allegiance: BTreeMap::from([(faction_key("faction-adrasteia"), 0.25)]),
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_matches_a_binding_built_by_hand() {
|
||||
let by_hand = GrammarBinding::new("aetheria.verse_grammar", 1, authored_verbs()).unwrap();
|
||||
let read = read_aetheria_catalog(FIXTURE).unwrap().grammar;
|
||||
assert_eq!(read.identity().digest, by_hand.identity().digest);
|
||||
assert_eq!(read, by_hand);
|
||||
|
||||
// The digest follows the verbs: a role the file drops is a different grammar.
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[3].as_array_mut().unwrap().pop();
|
||||
});
|
||||
assert_ne!(
|
||||
read_catalog_digest(&rows),
|
||||
by_hand.identity().digest,
|
||||
"dropping a role did not move the digest"
|
||||
);
|
||||
}
|
||||
|
||||
fn read_catalog_digest(rows: &[CultCacheEnvelope]) -> String {
|
||||
read(rows).unwrap().grammar.identity().digest
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_catalog_without_a_grammar() {
|
||||
let mut rows = fixture_rows();
|
||||
rows.retain(|row| row.r#type != "aetheria.verse_grammar");
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::NoGrammar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_a_second_grammar_record() {
|
||||
let mut rows = fixture_rows();
|
||||
let mut second = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.clone();
|
||||
second.key.push_str("-again");
|
||||
rows.push(second);
|
||||
assert_eq!(read(&rows), Err(CatalogReadError::MultipleGrammars));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_render_path() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[2] = json!(3));
|
||||
let error = read(&rows).unwrap_err();
|
||||
assert_eq!(
|
||||
error,
|
||||
CatalogReadError::UnknownRenderPath {
|
||||
record: "verb-haul".into(),
|
||||
field: 2
|
||||
}
|
||||
);
|
||||
assert!(!error.to_string().contains('3'), "the value was echoed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_an_unknown_referent_kind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(9);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::UnknownReferentKind {
|
||||
record: "verb-attack".into(),
|
||||
field: 3
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_verb_breaking_the_name_or_role_rules_is_refused_by_key() {
|
||||
let broken = |mutate: fn(&mut Vec<Value>)| {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", mutate);
|
||||
read(&rows).unwrap_err()
|
||||
};
|
||||
let invalid = |rule| CatalogReadError::InvalidVerb {
|
||||
record: "verb-haul".into(),
|
||||
rule,
|
||||
};
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("Haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("1haul")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("")),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[1] = json!("h".repeat(49))),
|
||||
invalid(VerbRule::Name)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("Cargo")),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("c".repeat(33))),
|
||||
invalid(VerbRule::RoleName)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][0][1] = json!("actor")),
|
||||
invalid(VerbRule::ReservedRole)
|
||||
);
|
||||
assert_eq!(
|
||||
broken(|cells| cells[3][1][1] = json!("cargo")),
|
||||
invalid(VerbRule::DuplicateRole)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_longest_canonical_names_are_admitted() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| {
|
||||
cells[1] = json!(format!("h{}9", "_9".repeat(23)));
|
||||
cells[3][0][1] = json!(format!("c{}", "z".repeat(31)));
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.grammar.identity().revision, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_verbs_with_one_name_do_not_bind() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-haul", |cells| cells[1] = json!("attack"));
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::Grammar(GrammarError::DuplicateVerb))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_record_that_is_not_the_mirrored_shape_is_named_by_key_and_field() {
|
||||
let malformed = |record: &str, field| CatalogReadError::MalformedRecord {
|
||||
record: record.into(),
|
||||
field,
|
||||
};
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").payload =
|
||||
rmp_serde::to_vec(&json!("not an array")).unwrap();
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 0)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[1] = Value::Null
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 1)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({"faction-adrasteia": "heavy"});
|
||||
});
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells.truncate(12));
|
||||
assert_eq!(read(&rows), Err(malformed("faction-brannoch", 12)));
|
||||
|
||||
let mut rows = fixture_rows();
|
||||
let grammar = rows
|
||||
.iter()
|
||||
.find(|row| row.r#type == "aetheria.verse_grammar")
|
||||
.unwrap()
|
||||
.key
|
||||
.clone();
|
||||
edit(&mut rows, &grammar, |cells| cells[1] = json!(-1));
|
||||
assert_eq!(read(&rows), Err(malformed(&grammar, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_aetheria_adds_and_schemas_it_does_not_mirror_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-speak", |cells| cells.push(json!("later")));
|
||||
edit(&mut rows, "faction-adrasteia", |cells| cells.push(json!(7)));
|
||||
let mut stranger = rows[0].clone();
|
||||
stranger.key = "ship-1".into();
|
||||
stranger.r#type = "aetheria.ship".into();
|
||||
stranger.schema_id = Some("sha256:not-mirrored".into());
|
||||
stranger.payload = vec![0xc1];
|
||||
rows.push(stranger);
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_null_short_name_or_description_reads_as_empty() {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-adrasteia", |cells| {
|
||||
cells[2] = Value::Null;
|
||||
cells[3] = Value::Null;
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(catalog.factions[0].short_name, "");
|
||||
assert_eq!(catalog.factions[0].description, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_file_is_unreadable_not_an_empty_catalog() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(dir.path().join("missing.cc")),
|
||||
Err(CatalogReadError::Unreadable)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reading_leaves_the_catalog_and_its_directory_untouched() {
|
||||
let (dir, path) = catalog_of(&fixture_rows());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
let listing = |dir: &std::path::Path| {
|
||||
let mut names: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().file_name())
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
};
|
||||
let before = listing(dir.path());
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
read_aetheria_catalog(&path).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), bytes);
|
||||
assert_eq!(listing(dir.path()), before, "the reader created a file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_referent_code_maps_to_its_own_kind() {
|
||||
// The fixture binds only Faction, Cargo and Place; Person and Population
|
||||
// are authored here at the role's integer key, each read back as itself.
|
||||
for (code, expected) in [
|
||||
(1, GrammarReferentKind::Person),
|
||||
(2, GrammarReferentKind::Faction),
|
||||
(3, GrammarReferentKind::Population),
|
||||
(4, GrammarReferentKind::Place),
|
||||
(5, GrammarReferentKind::Cargo),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "verb-attack", |cells| {
|
||||
cells[3][0][2] = json!(code)
|
||||
});
|
||||
let catalog = read(&rows).unwrap();
|
||||
assert_eq!(
|
||||
catalog.grammar.verb("attack"),
|
||||
Some(&verb(RenderPath::ShipAction, &[("target", expected)])),
|
||||
"code {code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn faction_with_key(record: &str) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
row_of(&mut rows, "faction-brannoch").key = record.into();
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_record_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in [
|
||||
"faction-cafe\u{301}",
|
||||
"faction-brannoch ",
|
||||
"faction-\u{202e}brannoch",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(
|
||||
read(&faction_with_key(bad)),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: bad.into(),
|
||||
field: 0
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_canonical_text_is_refused_by_key_and_field() {
|
||||
for bad in ["faction-cafe\u{301}", "faction-adrasteia ", "\u{202e}"] {
|
||||
let mut rows = fixture_rows();
|
||||
edit(&mut rows, "faction-brannoch", |cells| {
|
||||
cells[12] = json!({ bad: 0.5 });
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::NonCanonicalKey {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The fixture's snapshot bytes with the record rows changed by `change`, for
|
||||
/// what the store's own writer will not produce: it keeps one row per record
|
||||
/// key and stores rows in key order.
|
||||
fn spliced_catalog(
|
||||
change: impl FnOnce(&mut Vec<rmpv::Value>),
|
||||
) -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let bytes = std::fs::read(FIXTURE).unwrap();
|
||||
let rmpv::Value::Array(mut snapshot) = rmpv::decode::read_value(&mut bytes.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("snapshot is not a tuple");
|
||||
};
|
||||
let rmpv::Value::Array(records) = &mut snapshot[2] else {
|
||||
panic!("snapshot holds no records");
|
||||
};
|
||||
change(records);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("catalog.cc");
|
||||
let mut spliced = Vec::new();
|
||||
rmpv::encode::write_value(&mut spliced, &rmpv::Value::Array(snapshot)).unwrap();
|
||||
std::fs::write(&path, spliced).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_faction_rows_under_one_record_key_are_refused() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
let twin = records
|
||||
.iter()
|
||||
.find(|record| record[0].as_str() == Some("faction-adrasteia"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
records.push(twin);
|
||||
});
|
||||
assert_eq!(
|
||||
read_aetheria_catalog(&path),
|
||||
Err(CatalogReadError::DuplicateFaction {
|
||||
record: "faction-adrasteia".into()
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn factions_are_read_in_record_key_order_whatever_the_file_order() {
|
||||
let (_dir, path) = spliced_catalog(|records| {
|
||||
records.sort_by(|a, b| b[0].as_str().cmp(&a[0].as_str()));
|
||||
assert_eq!(records[0][0].as_str(), Some("verb-speak"));
|
||||
});
|
||||
let names: Vec<_> = read_aetheria_catalog(&path)
|
||||
.unwrap()
|
||||
.factions
|
||||
.into_iter()
|
||||
.map(|faction| faction.name)
|
||||
.collect();
|
||||
assert_eq!(names, ["Adrasteia", "Brannoch"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_that_is_not_a_map_is_malformed_not_empty() {
|
||||
for not_a_map in [
|
||||
json!([]),
|
||||
json!(["faction-adrasteia"]),
|
||||
Value::Null,
|
||||
json!(1),
|
||||
] {
|
||||
let mut rows = fixture_rows();
|
||||
let shape = not_a_map.clone();
|
||||
edit(&mut rows, "faction-brannoch", |cells| cells[12] = shape);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
}),
|
||||
"{not_a_map}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends cells to a faction as Aetheria's C# CultCache writes a `byte[]`
|
||||
/// (MessagePack bin) and a `DateTime` (MessagePack timestamp, ext -1).
|
||||
#[test]
|
||||
fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, "faction-adrasteia");
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
cells.push(rmpv::Value::Binary(vec![1, 0, 7]));
|
||||
cells.push(rmpv::Value::Ext(
|
||||
-1,
|
||||
vec![0, 0, 0, 0, 0x65, 0x1f, 0x2a, 0x80],
|
||||
));
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
|
||||
assert_eq!(
|
||||
read(&rows).unwrap(),
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
/// A faction record re-encoded with `change` applied to its decoded cells, for
|
||||
/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes.
|
||||
fn with_cells(key: &str, change: impl FnOnce(&mut Vec<rmpv::Value>)) -> Vec<CultCacheEnvelope> {
|
||||
let mut rows = fixture_rows();
|
||||
let row = row_of(&mut rows, key);
|
||||
let rmpv::Value::Array(mut cells) =
|
||||
rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap()
|
||||
else {
|
||||
panic!("not positional");
|
||||
};
|
||||
change(&mut cells);
|
||||
let mut payload = Vec::new();
|
||||
rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap();
|
||||
row.payload = payload;
|
||||
rows
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() {
|
||||
for key in [
|
||||
rmpv::Value::from(5),
|
||||
rmpv::Value::Nil,
|
||||
rmpv::Value::Binary(b"faction-adrasteia".to_vec()),
|
||||
] {
|
||||
let rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]);
|
||||
});
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: 12
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_is_not_utf8_is_malformed_by_key_and_field() {
|
||||
// The marker is overwritten in the encoded bytes with an invalid sequence of
|
||||
// the same length, since a decoded string cannot hold one.
|
||||
const MARKER: &str = "ZZZZ";
|
||||
for field in [1usize, 3] {
|
||||
let mut rows = with_cells("faction-brannoch", |cells| {
|
||||
cells[field] = rmpv::Value::from(MARKER);
|
||||
});
|
||||
let payload = &mut row_of(&mut rows, "faction-brannoch").payload;
|
||||
let at = payload
|
||||
.windows(MARKER.len())
|
||||
.position(|window| window == MARKER.as_bytes())
|
||||
.unwrap();
|
||||
payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]);
|
||||
assert_eq!(
|
||||
read(&rows),
|
||||
Err(CatalogReadError::MalformedRecord {
|
||||
record: "faction-brannoch".into(),
|
||||
field: field as u8
|
||||
}),
|
||||
"field {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -469,7 +469,7 @@ mod tests {
|
|||
AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind,
|
||||
ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle,
|
||||
EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef,
|
||||
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref,
|
||||
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex,
|
||||
RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability,
|
||||
WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph,
|
||||
commit_digest, derive_boundaries, derive_cover, derive_opportunities,
|
||||
|
|
@ -518,6 +518,7 @@ mod tests {
|
|||
},
|
||||
controller,
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
WorldPatch {
|
||||
|
|
@ -735,8 +736,7 @@ mod tests {
|
|||
assert!(!graph.subjects.contains(&mirror.first));
|
||||
let cover = derive_cover(
|
||||
snapshot.world_id,
|
||||
snapshot.now,
|
||||
30,
|
||||
TickIndex(0),
|
||||
&opportunities,
|
||||
&graph,
|
||||
CoverBudget {
|
||||
|
|
@ -1195,6 +1195,7 @@ mod tests {
|
|||
controller: NewController::External { consumer },
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})])
|
||||
};
|
||||
// A binding to another consumer is outside its ground.
|
||||
|
|
@ -1573,6 +1574,7 @@ mod tests {
|
|||
controller,
|
||||
affordances,
|
||||
position: Some(Ref::Existing(mirror.commons)),
|
||||
import_key: None,
|
||||
})])
|
||||
};
|
||||
let pairing = |result: Result<SubmitReceipt, KernelError>| {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,6 @@
|
|||
//! The cover is a partition, never a filter: every active subject is in exactly
|
||||
//! one cell every tick. The budget decides at what resolution, not whether.
|
||||
|
||||
use super::clock::FictionalMinutes;
|
||||
use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
|
@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16;
|
|||
/// the name, not by a second namespace.
|
||||
const COVER_NAMESPACE: &str = "ghostlight.cover.v1";
|
||||
|
||||
/// Monotone tick index. Derived from the world clock, never stored. It is
|
||||
/// threaded into every derived command id, so a resumed tick over the same
|
||||
/// snapshot re-derives the same ids and the kernel's idempotency ledger answers
|
||||
/// with the original receipts instead of double-committing.
|
||||
/// A tick's ordinal: the number of clock advances the world had committed when
|
||||
/// the tick opened. The driver reads it from the world and freezes it in the
|
||||
/// tick document; it is never derived from `now`, so it does not depend on the
|
||||
/// span. It is threaded into every derived command id, so a resumed tick
|
||||
/// re-submits the same ids and the kernel's idempotency ledger answers with the
|
||||
/// original receipts instead of double-committing.
|
||||
#[derive(
|
||||
Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
|
||||
)]
|
||||
#[serde(transparent)]
|
||||
pub struct TickIndex(pub u64);
|
||||
|
||||
impl TickIndex {
|
||||
/// Floor division by the span one `AdvanceTime` carries. The driver submits
|
||||
/// the clock *after* the cells run, so every cell in one tick sees the same
|
||||
/// `now` and therefore the same index.
|
||||
pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self {
|
||||
Self(now.0 / u64::from(tick_minutes.max(1)))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a subject was represented this tick. Derived from the cell, carried
|
||||
/// nowhere in world state: representation is a compute budget, not world truth.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -254,7 +246,7 @@ impl AgencyGraph {
|
|||
}
|
||||
}
|
||||
|
||||
/// The partition. Pure over `(world, now, opportunities, graph, budget)`.
|
||||
/// The partition. Pure over `(world, tick, opportunities, graph, budget)`.
|
||||
///
|
||||
/// `opportunities` arrives in the kernel's one attention order (pressure, then
|
||||
/// attention debt, then id) and is never re-sorted and never filtered here: a
|
||||
|
|
@ -262,13 +254,11 @@ impl AgencyGraph {
|
|||
/// is a fact about cognition rather than a scheduling preference.
|
||||
pub fn derive_cover(
|
||||
world: WorldId,
|
||||
now: FictionalMinutes,
|
||||
tick_minutes: u32,
|
||||
tick: TickIndex,
|
||||
opportunities: &[DecisionOpportunity],
|
||||
graph: &AgencyGraph,
|
||||
budget: CoverBudget,
|
||||
) -> Cover {
|
||||
let tick = TickIndex::of(now, tick_minutes);
|
||||
let active: Vec<&DecisionOpportunity> = opportunities
|
||||
.iter()
|
||||
.filter(|opportunity| opportunity.controller_mode != ControllerMode::Human)
|
||||
|
|
@ -601,8 +591,7 @@ mod tests {
|
|||
) -> Cover {
|
||||
derive_cover(
|
||||
WorldId::nil_for_test(),
|
||||
FictionalMinutes(tick * 60),
|
||||
60,
|
||||
TickIndex(tick),
|
||||
opportunities,
|
||||
graph,
|
||||
budget,
|
||||
|
|
|
|||
|
|
@ -2158,6 +2158,7 @@ mod tests {
|
|||
})
|
||||
.collect(),
|
||||
receipt_digest: "sha256:receipt".into(),
|
||||
usage: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
209
crates/ghostlight/src/grammar.rs
Normal file
209
crates/ghostlight/src/grammar.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//! The action grammar a world is bound to at creation.
|
||||
//!
|
||||
//! A grammar is the closed set of affordance kinds a world may declare, each
|
||||
//! with its role signature and the one render path its acts take. The grammar's
|
||||
//! home is the Aetheria repo; the kernel holds only this binding: the verbs it
|
||||
//! enforces, and a content digest that names exactly which grammar that was.
|
||||
//! The digest is derived here and nowhere else, so a binding cannot claim a
|
||||
//! digest its verbs do not have.
|
||||
|
||||
use crate::SubjectKind;
|
||||
use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// How an act of one verb reaches the player: filmed in the engine as a ship
|
||||
/// action, or shown as Aetheria's portrait-and-choices conversation.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RenderPath {
|
||||
ShipAction,
|
||||
Conversation,
|
||||
}
|
||||
|
||||
/// What a grammar role may be bound to, in the grammar's own vocabulary. Each
|
||||
/// lowers to exactly one kernel `RefKind`.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GrammarReferentKind {
|
||||
Person,
|
||||
Faction,
|
||||
Population,
|
||||
Place,
|
||||
Cargo,
|
||||
}
|
||||
|
||||
impl GrammarReferentKind {
|
||||
fn ref_kind(self) -> RefKind {
|
||||
match self {
|
||||
Self::Person => RefKind::Subject(Some(SubjectKind::Person)),
|
||||
Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
Self::Population => RefKind::Subject(Some(SubjectKind::Population)),
|
||||
Self::Place => RefKind::Entity(EntityKind::Place),
|
||||
Self::Cargo => RefKind::Entity(EntityKind::Resource),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarRole {
|
||||
pub role: String,
|
||||
pub referent: GrammarReferentKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct GrammarVerb {
|
||||
pub render_path: RenderPath,
|
||||
/// In signature order: the order is part of the verb.
|
||||
pub roles: Vec<GrammarRole>,
|
||||
}
|
||||
|
||||
impl GrammarVerb {
|
||||
/// Whether a declared role list is this verb's signature: same names, same
|
||||
/// referent kinds, same order.
|
||||
pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool {
|
||||
self.role_specs() == roles
|
||||
}
|
||||
|
||||
/// This verb's signature as the kernel declares it: the one lowering of a
|
||||
/// grammar role, so declaring a verb and admitting a declaration cannot
|
||||
/// disagree.
|
||||
pub(crate) fn role_specs(&self) -> Vec<RoleSpec> {
|
||||
self.roles
|
||||
.iter()
|
||||
.map(|role| RoleSpec {
|
||||
role: Role(role.role.clone()),
|
||||
kind: role.referent.ref_kind(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a bound grammar refuses an affordance entry.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GrammarRefusal {
|
||||
KindNotInGrammar,
|
||||
RolesDisagree,
|
||||
SpeakNotConversation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum GrammarError {
|
||||
#[error("grammar schema id is empty")]
|
||||
EmptySchemaId,
|
||||
#[error("grammar declares one verb kind twice")]
|
||||
DuplicateVerb,
|
||||
#[error("grammar digest does not match its verbs")]
|
||||
DigestMismatch,
|
||||
}
|
||||
|
||||
/// Which grammar a world is bound to: its schema id, revision and content
|
||||
/// digest. What a consumer reads; the verb table stays in the binding.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct GrammarIdentity {
|
||||
pub schema_id: String,
|
||||
pub revision: u32,
|
||||
pub digest: String,
|
||||
}
|
||||
|
||||
/// The grammar a world was created under. Immutable: the only constructor
|
||||
/// derives the digest, and deserialisation re-derives it and refuses a mismatch.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(try_from = "RawBinding")]
|
||||
pub struct GrammarBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawBinding {
|
||||
schema_id: String,
|
||||
revision: u32,
|
||||
digest: String,
|
||||
verbs: BTreeMap<String, GrammarVerb>,
|
||||
}
|
||||
|
||||
impl TryFrom<RawBinding> for GrammarBinding {
|
||||
type Error = GrammarError;
|
||||
|
||||
fn try_from(raw: RawBinding) -> Result<Self, GrammarError> {
|
||||
let bound = Self::new(raw.schema_id, raw.revision, raw.verbs)?;
|
||||
if bound.digest != raw.digest {
|
||||
return Err(GrammarError::DigestMismatch);
|
||||
}
|
||||
Ok(bound)
|
||||
}
|
||||
}
|
||||
|
||||
impl GrammarBinding {
|
||||
/// The one constructor. Verbs arrive in any order and are keyed by kind;
|
||||
/// the digest is the sha256 of the canonical encoding of the kind-sorted
|
||||
/// verbs, so the same verbs bind to the same digest however they were listed.
|
||||
pub fn new(
|
||||
schema_id: impl Into<String>,
|
||||
revision: u32,
|
||||
verbs: impl IntoIterator<Item = (String, GrammarVerb)>,
|
||||
) -> Result<Self, GrammarError> {
|
||||
let schema_id = schema_id.into();
|
||||
if schema_id.is_empty() {
|
||||
return Err(GrammarError::EmptySchemaId);
|
||||
}
|
||||
let mut table = BTreeMap::new();
|
||||
for (kind, verb) in verbs {
|
||||
if table.insert(kind, verb).is_some() {
|
||||
return Err(GrammarError::DuplicateVerb);
|
||||
}
|
||||
}
|
||||
let encoded = rmp_serde::to_vec_named(&table).expect("a verb table encodes");
|
||||
let digest = format!("sha256:{:x}", Sha256::digest(encoded));
|
||||
Ok(Self {
|
||||
schema_id,
|
||||
revision,
|
||||
digest,
|
||||
verbs: table,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identity(&self) -> GrammarIdentity {
|
||||
GrammarIdentity {
|
||||
schema_id: self.schema_id.clone(),
|
||||
revision: self.revision,
|
||||
digest: self.digest.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Every verb, in kind order.
|
||||
pub(crate) fn verbs(&self) -> impl Iterator<Item = (&str, &GrammarVerb)> {
|
||||
self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb))
|
||||
}
|
||||
|
||||
pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> {
|
||||
self.verbs.get(kind)
|
||||
}
|
||||
|
||||
/// The one grammar check. The resolver turns a refusal into a mismatch and
|
||||
/// `admit_resolved`, the only writer of the affordance catalog, into an
|
||||
/// invariant error, so no path adds an entry without passing it.
|
||||
pub(crate) fn refusal(
|
||||
&self,
|
||||
kind: &AffordanceKindName,
|
||||
roles: &[RoleSpec],
|
||||
) -> Option<GrammarRefusal> {
|
||||
let Some(verb) = self.verb(&kind.0) else {
|
||||
return Some(GrammarRefusal::KindNotInGrammar);
|
||||
};
|
||||
if !verb.admits_roles(roles) {
|
||||
return Some(GrammarRefusal::RolesDisagree);
|
||||
}
|
||||
if *kind == kernel_speak_entry().kind && verb.render_path != RenderPath::Conversation {
|
||||
return Some(GrammarRefusal::SpeakNotConversation);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
745
crates/ghostlight/src/grammar/tests.rs
Normal file
745
crates/ghostlight/src/grammar/tests.rs
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
use super::*;
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind,
|
||||
Mismatch, OutcomeBand, Precondition, Role, RoleSpec,
|
||||
};
|
||||
use crate::tests::{auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt,
|
||||
WorldId, WorldKernel, WorldPatch,
|
||||
};
|
||||
|
||||
fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb {
|
||||
GrammarVerb {
|
||||
render_path: path,
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, referent)| GrammarRole {
|
||||
role: (*role).into(),
|
||||
referent: *referent,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
/// What the fixture world's genesis needs: the kernel speak entry and the
|
||||
/// fixture's own `convene`. Anything else a test wants is added to it.
|
||||
fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding {
|
||||
let mut verbs = vec![
|
||||
("speak".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
verbs.extend(
|
||||
extra
|
||||
.iter()
|
||||
.map(|(kind, v)| ((*kind).to_owned(), v.clone())),
|
||||
);
|
||||
GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap()
|
||||
}
|
||||
|
||||
fn bound(grammar: Option<GrammarBinding>, title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
world.grammar = grammar;
|
||||
world
|
||||
}
|
||||
|
||||
fn declare(handle: &str, kind: &str, roles: &[(&str, RefKind)]) -> Declaration {
|
||||
Declaration::Affordance(AffordanceDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
kind: AffordanceKindName(kind.into()),
|
||||
roles: roles
|
||||
.iter()
|
||||
.map(|(role, kind)| RoleSpec {
|
||||
role: Role((*role).into()),
|
||||
kind: *kind,
|
||||
})
|
||||
.collect(),
|
||||
// Speech with one audience is the smallest entry the resolver does
|
||||
// not refuse as inert; the grammar check does not read it.
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
carries_speech: true,
|
||||
})
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Each call gets its own store path: kernels are created, not reopened.
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn admit(kernel: &mut WorldKernel, patch: WorldPatch) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
CallerId::Principal(owner()),
|
||||
CommandBody::AdmitPatch {
|
||||
answers: None,
|
||||
patch,
|
||||
},
|
||||
),
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
}
|
||||
|
||||
fn person() -> RefKind {
|
||||
RefKind::Subject(Some(SubjectKind::Person))
|
||||
}
|
||||
|
||||
fn place() -> RefKind {
|
||||
RefKind::Entity(EntityKind::Place)
|
||||
}
|
||||
|
||||
fn not_in_grammar(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceKindNotInGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
fn disagrees(handle: &str) -> Vec<Mismatch> {
|
||||
vec![Mismatch::AffordanceRolesDisagreeWithGrammar {
|
||||
handle: DraftHandle::new(handle),
|
||||
}]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_an_affordance_kind_outside_the_grammar() {
|
||||
// Genesis: a declared kind the binding lacks is refused.
|
||||
let mut world = bound(Some(grammar_with(&[])), "Outside");
|
||||
world.patch.declarations.push(declare("wave", "wave", &[]));
|
||||
assert_eq!(rejected(create(world).1), not_in_grammar("wave"));
|
||||
|
||||
// A later AdmitPatch: the same kind is refused, and a verb the grammar
|
||||
// does carry is admitted by the same path.
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Outside"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
let receipt = admit(&mut kernel, patch_of(vec![declare("hail", "hail", &[])])).unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_refuses_a_role_signature_the_grammar_does_not_carry() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[
|
||||
("who", GrammarReferentKind::Person),
|
||||
("where_", GrammarReferentKind::Place),
|
||||
],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Signatures"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for (label, roles) in [
|
||||
("renamed", vec![("whom", person()), ("where_", place())]),
|
||||
("rekinded", vec![("who", person()), ("where_", person())]),
|
||||
("swapped", vec![("where_", place()), ("who", person())]),
|
||||
("short", vec![("who", person())]),
|
||||
(
|
||||
"long",
|
||||
vec![("who", person()), ("where_", place()), ("why", person())],
|
||||
),
|
||||
] {
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(label, "meet", &roles)]),
|
||||
));
|
||||
assert_eq!(set, disagrees(label), "{label}");
|
||||
}
|
||||
// A role on a verb the grammar declares with none.
|
||||
let set = rejected(admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
));
|
||||
assert_eq!(set, disagrees("hail"));
|
||||
// The exact signature is admitted.
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare(
|
||||
"meet",
|
||||
"meet",
|
||||
&[("who", person()), ("where_", place())],
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_grammar_referent_kind_admits_exactly_its_own_kernel_kind() {
|
||||
let kinds = [
|
||||
(GrammarReferentKind::Person, person()),
|
||||
(
|
||||
GrammarReferentKind::Faction,
|
||||
RefKind::Subject(Some(SubjectKind::Institution)),
|
||||
),
|
||||
(
|
||||
GrammarReferentKind::Population,
|
||||
RefKind::Subject(Some(SubjectKind::Population)),
|
||||
),
|
||||
(GrammarReferentKind::Place, place()),
|
||||
(
|
||||
GrammarReferentKind::Cargo,
|
||||
RefKind::Entity(EntityKind::Resource),
|
||||
),
|
||||
];
|
||||
for (referent, own) in &kinds {
|
||||
let extra = [("act", verb(RenderPath::ShipAction, &[("with", *referent)]))];
|
||||
for (_, offered) in &kinds {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Referents"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let result = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("act", "act", &[("with", *offered)])]),
|
||||
);
|
||||
if offered == own {
|
||||
assert!(
|
||||
matches!(result, Ok(SubmitReceipt::Applied(_))),
|
||||
"{referent:?} refused its own kind"
|
||||
);
|
||||
} else {
|
||||
assert_eq!(
|
||||
rejected(result),
|
||||
disagrees("act"),
|
||||
"{referent:?} admitted {offered:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_world_without_speak_refuses_the_kernel_speak_grant() {
|
||||
let no_speak = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[("convene".to_owned(), verb(RenderPath::Conversation, &[]))],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(no_speak), "Mute")).1),
|
||||
not_in_grammar(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
|
||||
// The kernel entry is held to the signature as well as the kind.
|
||||
let loud = GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
(
|
||||
"speak".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(loud), "Loud")).1),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unbound_world_keeps_the_open_catalog() {
|
||||
let mut world = bound(None, "Open");
|
||||
world
|
||||
.patch
|
||||
.declarations
|
||||
.push(declare("wave", "wave", &[("at", person())]));
|
||||
let (_dir, kernel) = create(world);
|
||||
let mut kernel = kernel.unwrap();
|
||||
let receipt = admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![declare("beacon", "beacon", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert!(snapshot.grammar.is_none());
|
||||
assert!(snapshot.affordances.len() >= 4);
|
||||
assert!(snapshot.affordances.iter().all(|a| a.render_path.is_none()));
|
||||
|
||||
// The state row of a world with no grammar carries no grammar key, so its
|
||||
// bytes and digests are those of a world written before grammars existed.
|
||||
let encoded = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
let keys: std::collections::BTreeMap<String, serde::de::IgnoredAny> =
|
||||
rmp_serde::from_slice(&encoded).unwrap();
|
||||
assert!(!keys.contains_key("grammar"));
|
||||
assert!(keys.contains_key("brief"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bound_world_reports_its_grammar_and_derives_render_paths_from_it() {
|
||||
let extra = [("dock", verb(RenderPath::ShipAction, &[]))];
|
||||
let grammar = grammar_with(&extra);
|
||||
let identity = grammar.identity();
|
||||
let (_dir, kernel) = create(bound(Some(grammar), "Reports"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
admit(&mut kernel, patch_of(vec![declare("dock", "dock", &[])])).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
assert_eq!(snapshot.grammar, Some(identity));
|
||||
let path_of = |kind: &str| {
|
||||
snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == kind)
|
||||
.unwrap_or_else(|| panic!("{kind} is in the catalog"))
|
||||
.render_path
|
||||
};
|
||||
assert_eq!(path_of("dock"), Some(RenderPath::ShipAction));
|
||||
assert_eq!(path_of("convene"), Some(RenderPath::Conversation));
|
||||
assert_eq!(path_of("speak"), Some(RenderPath::Conversation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reopened_grammar_world_still_holds_its_grammar() {
|
||||
let grammar = grammar_with(&[]);
|
||||
let identity = grammar.identity();
|
||||
let (directory, kernel) = create(bound(Some(grammar), "Reopened"));
|
||||
let kernel = kernel.unwrap();
|
||||
let world_id = kernel.snapshot().unwrap().world_id;
|
||||
drop(kernel);
|
||||
let mut reopened = WorldKernel::open(directory.path().join("world.cc"), world_id).unwrap();
|
||||
assert_eq!(reopened.snapshot().unwrap().grammar, Some(identity));
|
||||
assert_eq!(
|
||||
rejected(admit(
|
||||
&mut reopened,
|
||||
patch_of(vec![declare("wave", "wave", &[])])
|
||||
)),
|
||||
not_in_grammar("wave")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_bound_grammar_is_part_of_the_world_state_digest() {
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&[])), "Digested"));
|
||||
let kernel = kernel.unwrap();
|
||||
let bound_digest = crate::state_digest(&kernel.state).unwrap();
|
||||
let mut state = kernel.state.clone();
|
||||
state.grammar = None;
|
||||
let open_digest = crate::state_digest(&state).unwrap();
|
||||
state.grammar = Some(grammar_with(&[("dock", verb(RenderPath::ShipAction, &[]))]));
|
||||
let other_digest = crate::state_digest(&state).unwrap();
|
||||
assert_ne!(bound_digest, open_digest);
|
||||
assert_ne!(bound_digest, other_digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grammar_digest_is_derived_and_order_free() {
|
||||
let forward = [
|
||||
("alpha".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
(
|
||||
"beta".to_owned(),
|
||||
verb(
|
||||
RenderPath::Conversation,
|
||||
&[("to", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("gamma".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let mut reversed = forward.clone();
|
||||
reversed.reverse();
|
||||
let a = GrammarBinding::new("aetheria.grammar", 1, forward.clone()).unwrap();
|
||||
let b = GrammarBinding::new("aetheria.grammar", 1, reversed).unwrap();
|
||||
assert_eq!(a.digest, b.digest);
|
||||
assert_eq!(a, b);
|
||||
|
||||
// Content-derived: each part of a verb moves the digest.
|
||||
let changed = |edit: &dyn Fn(&mut Vec<(String, GrammarVerb)>)| {
|
||||
let mut verbs = forward.to_vec();
|
||||
edit(&mut verbs);
|
||||
GrammarBinding::new("aetheria.grammar", 1, verbs)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[0].1.render_path = RenderPath::Conversation)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[1].1.roles[0].role = "from".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| v[1].1.roles[0].referent = GrammarReferentKind::Faction)
|
||||
);
|
||||
assert_ne!(a.digest, changed(&|v| v[2].0 = "delta".into()));
|
||||
assert_ne!(
|
||||
a.digest,
|
||||
changed(&|v| {
|
||||
v.pop();
|
||||
})
|
||||
);
|
||||
// Role order is part of a verb.
|
||||
let ordered = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[("meet".to_owned(), verb(RenderPath::ShipAction, roles))],
|
||||
)
|
||||
.unwrap()
|
||||
.digest
|
||||
};
|
||||
assert_ne!(
|
||||
ordered(&[
|
||||
("a", GrammarReferentKind::Person),
|
||||
("b", GrammarReferentKind::Place)
|
||||
]),
|
||||
ordered(&[
|
||||
("b", GrammarReferentKind::Place),
|
||||
("a", GrammarReferentKind::Person)
|
||||
])
|
||||
);
|
||||
// The identity reads the same fields back.
|
||||
let identity = a.identity();
|
||||
assert_eq!(identity.schema_id, "aetheria.grammar");
|
||||
assert_eq!(identity.revision, 1);
|
||||
assert_eq!(identity.digest, a.digest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tampered_grammar_digest_fails_to_deserialise() {
|
||||
let binding = grammar_with(&[]);
|
||||
let value = serde_json::to_value(&binding).unwrap();
|
||||
// Untouched: the round trip holds.
|
||||
let back: GrammarBinding = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(back, binding);
|
||||
|
||||
// A digest the verbs do not have.
|
||||
let mut forged = value.clone();
|
||||
forged["digest"] = "sha256:00".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(forged.clone()).is_err());
|
||||
|
||||
// A verb edited under the digest it already had.
|
||||
let mut edited = value;
|
||||
edited["verbs"]["convene"]["render_path"] = "ship_action".into();
|
||||
assert!(serde_json::from_value::<GrammarBinding>(edited).is_err());
|
||||
|
||||
// The named encoding a state row uses refuses it the same way.
|
||||
let bytes = rmp_serde::to_vec_named(&forged).unwrap();
|
||||
assert!(rmp_serde::from_slice::<GrammarBinding>(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_names_each_verb_once_and_has_a_schema() {
|
||||
assert_eq!(
|
||||
GrammarBinding::new("", 1, Vec::new()).unwrap_err(),
|
||||
GrammarError::EmptySchemaId
|
||||
);
|
||||
assert_eq!(
|
||||
GrammarBinding::new(
|
||||
"g",
|
||||
1,
|
||||
[
|
||||
("hail".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
("hail".to_owned(), verb(RenderPath::ShipAction, &[])),
|
||||
]
|
||||
)
|
||||
.unwrap_err(),
|
||||
GrammarError::DuplicateVerb
|
||||
);
|
||||
}
|
||||
|
||||
/// The fixture world was written by the code at base 367e2eb, which has no
|
||||
/// grammar field anywhere. Reopening it must reproduce the digests that code
|
||||
/// recorded.
|
||||
#[test]
|
||||
fn existing_world_reopens_with_unchanged_digests() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("world.cc");
|
||||
std::fs::write(&path, include_bytes!("../../tests/fixtures/base_world.cc")).unwrap();
|
||||
let world_id: WorldId =
|
||||
serde_json::from_str(include_str!("../../tests/fixtures/base_world.id").trim()).unwrap();
|
||||
let kernel = WorldKernel::open(&path, world_id).expect("a base-written world reopens");
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let recorded = include_str!("../../tests/fixtures/base_world.digests");
|
||||
let mut lines = recorded.lines();
|
||||
assert_eq!(snapshot.revision.to_string(), lines.next().unwrap());
|
||||
assert_eq!(snapshot.state_digest, lines.next().unwrap());
|
||||
assert_eq!(
|
||||
snapshot.last_commit_digest.as_deref(),
|
||||
Some(lines.next().unwrap())
|
||||
);
|
||||
assert!(snapshot.grammar.is_none());
|
||||
// Its state still derives the recorded digest from its bytes.
|
||||
assert_eq!(
|
||||
crate::state_digest(&kernel.state).unwrap(),
|
||||
snapshot.state_digest
|
||||
);
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<crate::PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
/// A bound Draft world whose revision has moved past genesis: one owner patch
|
||||
/// that adds a dead-end place the elaborator can answer.
|
||||
fn advanced_grammar_world() -> (tempfile::TempDir, WorldKernel, crate::EntityId) {
|
||||
let extra = [("hail", verb(RenderPath::Conversation, &[]))];
|
||||
let (dir, kernel) = create(bound(Some(grammar_with(&extra)), "Advanced"));
|
||||
let mut kernel = kernel.unwrap();
|
||||
let commons = *kernel.state.entities.keys().next().unwrap();
|
||||
admit(
|
||||
&mut kernel,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(crate::patch::EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(crate::patch::RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: crate::patch::Ref::Existing(commons),
|
||||
to: crate::patch::Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: crate::patch::AccessKind::Public,
|
||||
cost: crate::patch::Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
type Door = (&'static str, CallerId, Option<crate::PatchAnswer>);
|
||||
|
||||
/// Each door refuses an outside kind and a wrong role signature, typed, and the
|
||||
/// refusal leaves the revision where it was. The owner and the consumer declare
|
||||
/// unanswered only in Draft; Play and the elaborator act in Active.
|
||||
fn refuse_at_each_door(kernel: &mut WorldKernel, doors: Vec<Door>) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let kind = submit_as(
|
||||
kernel,
|
||||
caller.clone(),
|
||||
answers.clone(),
|
||||
patch_of(vec![declare("wave", "wave", &[])]),
|
||||
);
|
||||
assert_eq!(rejected(kind), not_in_grammar("wave"), "{door}");
|
||||
let roles = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![declare("hail", "hail", &[("who", person())])]),
|
||||
);
|
||||
assert_eq!(rejected(roles), disagrees("hail"), "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_admission_door_refuses_outside_the_grammar_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_grammar_world();
|
||||
assert_eq!(kernel.state.revision, 1);
|
||||
let draft_doors = vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
(
|
||||
"consumer",
|
||||
CallerId::System(crate::SystemCapability::Consumer {
|
||||
consumer: crate::ConsumerId::of_name("grammar-door"),
|
||||
}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, draft_doors);
|
||||
crate::tests::activate(&mut kernel);
|
||||
assert!(
|
||||
kernel.state.revision > 1,
|
||||
"the world has moved past genesis"
|
||||
);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
let active_doors = vec![
|
||||
(
|
||||
"play",
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(crate::SystemCapability::Elaborator {
|
||||
jurisdiction: crate::JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(crate::PatchAnswer::Boundary(answer)),
|
||||
),
|
||||
];
|
||||
refuse_at_each_door(&mut kernel, active_doors);
|
||||
// The same path admits what the grammar carries, so the refusals above are
|
||||
// the grammar's and not a closed door.
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(crate::SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![declare("hail", "hail", &[])]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_that_binds_speak_as_anything_but_conversation_is_refused_at_bind() {
|
||||
let ship = |roles: &[(&str, GrammarReferentKind)]| {
|
||||
GrammarBinding::new(
|
||||
"aetheria.grammar",
|
||||
1,
|
||||
[
|
||||
("speak".to_owned(), verb(RenderPath::ShipAction, roles)),
|
||||
("convene".to_owned(), verb(RenderPath::Conversation, &[])),
|
||||
],
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
rejected(create(bound(Some(ship(&[])), "Filmed speech")).1),
|
||||
vec![Mismatch::SpeakRenderPathDisagreesWithGrammar {
|
||||
handle: DraftHandle::new(crate::patch::KERNEL_SPEAK_HANDLE),
|
||||
}]
|
||||
);
|
||||
// A ShipAction speak with a role fails on the signature first and is still
|
||||
// refused: no variant of speak but zero roles, Conversation, is admitted.
|
||||
assert_eq!(
|
||||
rejected(
|
||||
create(bound(
|
||||
Some(ship(&[("to", GrammarReferentKind::Person)])),
|
||||
"Loud"
|
||||
))
|
||||
.1
|
||||
),
|
||||
disagrees(crate::patch::KERNEL_SPEAK_HANDLE)
|
||||
);
|
||||
// The control: Conversation speak binds.
|
||||
assert!(create(bound(Some(grammar_with(&[])), "Spoken")).1.is_ok());
|
||||
}
|
||||
|
||||
/// `apply_effect` re-decides an effect it is handed. An effect resolved against
|
||||
/// a world with no grammar must not install an entry the bound grammar lacks:
|
||||
/// the catalog's only writer holds the grammar check itself.
|
||||
#[test]
|
||||
fn an_effect_resolved_elsewhere_cannot_install_an_entry_outside_the_grammar() {
|
||||
let extra = [
|
||||
(
|
||||
"meet",
|
||||
verb(
|
||||
RenderPath::ShipAction,
|
||||
&[("who", GrammarReferentKind::Person)],
|
||||
),
|
||||
),
|
||||
("hail", verb(RenderPath::Conversation, &[])),
|
||||
];
|
||||
let (_dir, kernel) = create(bound(Some(grammar_with(&extra)), "Forged"));
|
||||
let kernel = kernel.unwrap();
|
||||
let mut twin = kernel.state.clone();
|
||||
twin.grammar = None;
|
||||
let resolve = |kind: &str, roles: &[(&str, RefKind)]| {
|
||||
crate::patch::resolve_patch(
|
||||
&twin,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![declare("probe", kind, roles)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the unbound twin admits any canonical kind")
|
||||
};
|
||||
for (label, resolved, admitted) in [
|
||||
("outside kind", resolve("wave", &[]), false),
|
||||
("wrong roles", resolve("meet", &[("whom", person())]), false),
|
||||
("carried verb", resolve("hail", &[]), true),
|
||||
] {
|
||||
let mut state = kernel.state.clone();
|
||||
let catalog = state.affordance_catalog.len();
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
},
|
||||
);
|
||||
if admitted {
|
||||
result.unwrap_or_else(|error| panic!("{label}: {error:?}"));
|
||||
assert_eq!(state.affordance_catalog.len(), catalog + 1, "{label}");
|
||||
} else {
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{label}: {result:?}"
|
||||
);
|
||||
assert_eq!(state.affordance_catalog.len(), catalog, "{label}");
|
||||
}
|
||||
}
|
||||
}
|
||||
752
crates/ghostlight/src/import_key_tests.rs
Normal file
752
crates/ghostlight/src/import_key_tests.rs
Normal file
|
|
@ -0,0 +1,752 @@
|
|||
//! One record, one subject: admission keeps the import-key mapping injective at
|
||||
//! every door and at every revision, and a world nobody imported into is
|
||||
//! byte-for-byte what it was before keys existed.
|
||||
|
||||
use crate::patch::{
|
||||
AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind,
|
||||
ImportKey, Mismatch, Ref, RouteDeclaration,
|
||||
};
|
||||
use crate::tests::{activate, auth_principal, command, creation, owner};
|
||||
use crate::{
|
||||
CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey,
|
||||
KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt,
|
||||
SystemCapability, WorldKernel, WorldPatch,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn key(schema: &str, record: &str) -> ImportKey {
|
||||
ImportKey::new(schema, record)
|
||||
}
|
||||
|
||||
fn alpha() -> ImportKey {
|
||||
key("aetheria.ship", "Alpha")
|
||||
}
|
||||
|
||||
/// The fixture genesis with its witness imported from `alpha()`.
|
||||
fn keyed_creation(title: &str) -> CreateWorld {
|
||||
let mut world = creation(CommandId::new(), title);
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& subject.handle == DraftHandle::new("persona")
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
world
|
||||
}
|
||||
|
||||
fn create(world: CreateWorld) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let result = WorldKernel::create(
|
||||
directory.path().join("world.cc"),
|
||||
world,
|
||||
&auth_principal(owner()),
|
||||
)
|
||||
.map(|(kernel, _)| kernel);
|
||||
(directory, result)
|
||||
}
|
||||
|
||||
fn keyed_world() -> (tempfile::TempDir, WorldKernel) {
|
||||
let (dir, kernel) = create(keyed_creation("Keyed"));
|
||||
(dir, kernel.unwrap())
|
||||
}
|
||||
|
||||
fn rejected(result: Result<impl Sized, KernelError>) -> Vec<Mismatch> {
|
||||
match result {
|
||||
Err(KernelError::PatchRejected(set)) => set,
|
||||
Err(other) => panic!("expected a patch refusal, got {other:?}"),
|
||||
Ok(_) => panic!("expected a patch refusal, the command was admitted"),
|
||||
}
|
||||
}
|
||||
|
||||
fn patch_of(declarations: Vec<Declaration>) -> WorldPatch {
|
||||
WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_as(
|
||||
kernel: &mut WorldKernel,
|
||||
caller: CallerId,
|
||||
answers: Option<PatchAnswer>,
|
||||
patch: WorldPatch,
|
||||
) -> Result<SubmitReceipt, KernelError> {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
kernel.submit(
|
||||
command(
|
||||
&snapshot,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::AdmitPatch { answers, patch },
|
||||
),
|
||||
&crate::AuthenticatedCaller::fixture(caller),
|
||||
)
|
||||
}
|
||||
|
||||
fn commons(kernel: &WorldKernel) -> EntityId {
|
||||
*kernel.state.entities.keys().next().unwrap()
|
||||
}
|
||||
|
||||
/// A mirror subject: the one shape every door may declare, since it needs no
|
||||
/// catalog grant.
|
||||
fn mirror(handle: &str, import_key: Option<ImportKey>, place: EntityId) -> Declaration {
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(handle),
|
||||
label: format!("The {handle}"),
|
||||
kind: SubjectKind::Person,
|
||||
controller: NewController::External {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
},
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(place)),
|
||||
import_key,
|
||||
})
|
||||
}
|
||||
|
||||
fn held(handle: &str) -> Mismatch {
|
||||
Mismatch::ImportKeyHeld {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn duplicate(handle: &str) -> Mismatch {
|
||||
Mismatch::DuplicateImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn malformed(handle: &str) -> Mismatch {
|
||||
Mismatch::MalformedImportKey {
|
||||
handle: DraftHandle::new(handle),
|
||||
}
|
||||
}
|
||||
|
||||
fn keys_in(kernel: &WorldKernel) -> Vec<ImportKey> {
|
||||
kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_patch_cannot_declare_two_subjects_with_one_import_key() {
|
||||
// At genesis.
|
||||
let mut world = creation(CommandId::new(), "Twins");
|
||||
for declaration in &mut world.patch.declarations {
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& (subject.handle == DraftHandle::new("persona")
|
||||
|| subject.handle == DraftHandle::new("operator"))
|
||||
{
|
||||
subject.import_key = Some(alpha());
|
||||
}
|
||||
}
|
||||
let (_dir, result) = create(world);
|
||||
assert_eq!(rejected(result), vec![duplicate("operator")]);
|
||||
|
||||
// In a later patch, with the key unheld.
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let beta = Some(key("aetheria.ship", "Beta"));
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
mirror("first", beta.clone(), place),
|
||||
mirror("second", beta, place),
|
||||
]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![duplicate("second")]);
|
||||
assert_eq!(kernel.state.revision, revision, "a refusal moved the world");
|
||||
}
|
||||
|
||||
/// The fixture world past genesis but still in Draft: the owner has added an
|
||||
/// unwalked place the elaborator can later answer.
|
||||
fn advanced_keyed_world() -> (tempfile::TempDir, WorldKernel, EntityId) {
|
||||
let (dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new("dead-end"),
|
||||
label: "The Unwalked Road".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
Declaration::Route(RouteDeclaration {
|
||||
handle: DraftHandle::new("gate"),
|
||||
label: "The Field Gate".into(),
|
||||
from: Ref::Existing(place),
|
||||
to: Ref::Draft(DraftHandle::new("dead-end")),
|
||||
access: AccessKind::Public,
|
||||
cost: Cost(1),
|
||||
}),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let dead_end = *kernel
|
||||
.state
|
||||
.entities
|
||||
.iter()
|
||||
.find(|(_, record)| record.label == "The Unwalked Road")
|
||||
.map(|(id, _)| id)
|
||||
.unwrap();
|
||||
(dir, kernel, dead_end)
|
||||
}
|
||||
|
||||
/// Each door names the held key, typed, and the refusal leaves the revision
|
||||
/// where it was. The owner and the consumer declare in Draft only; Play and the
|
||||
/// elaborator act in Active.
|
||||
fn refuse_held_key_at_each_door(
|
||||
kernel: &mut WorldKernel,
|
||||
place: EntityId,
|
||||
doors: Vec<(&str, CallerId, Option<PatchAnswer>)>,
|
||||
) {
|
||||
for (door, caller, answers) in doors {
|
||||
let revision = kernel.state.revision;
|
||||
let result = submit_as(
|
||||
kernel,
|
||||
caller,
|
||||
answers,
|
||||
patch_of(vec![mirror("copy", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(result), vec![held("copy")], "{door}");
|
||||
assert_eq!(
|
||||
kernel.state.revision, revision,
|
||||
"{door} refusal moved the world"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn consumer() -> CallerId {
|
||||
CallerId::System(SystemCapability::Consumer {
|
||||
consumer: ConsumerId::of_name("import-key"),
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_at_every_door_after_the_revision_has_moved() {
|
||||
let (_dir, mut kernel, dead_end) = advanced_keyed_world();
|
||||
assert!(kernel.state.revision > 0, "the world has moved past genesis");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("owner", CallerId::Principal(owner()), None),
|
||||
("consumer", consumer(), None),
|
||||
],
|
||||
);
|
||||
activate(&mut kernel);
|
||||
let answer = crate::derive_boundaries(&kernel.state)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|boundary| {
|
||||
matches!(boundary, crate::CausalBoundary::UnelaboratedDestination { place, .. }
|
||||
if *place == dead_end)
|
||||
})
|
||||
.expect("the dead end is an unelaborated destination");
|
||||
refuse_held_key_at_each_door(
|
||||
&mut kernel,
|
||||
dead_end,
|
||||
vec![
|
||||
("play", CallerId::System(SystemCapability::Play), None),
|
||||
(
|
||||
"elaborator",
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer.clone())),
|
||||
),
|
||||
],
|
||||
);
|
||||
// The same doors admit an unheld key, so the refusals are the key's; and
|
||||
// what a door admitted is held against every later door.
|
||||
let fresh = key("aetheria.ship", "Gamma");
|
||||
let receipt = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Elaborator {
|
||||
jurisdiction: JurisdictionKey::PlaceSubtree(dead_end),
|
||||
}),
|
||||
Some(PatchAnswer::Boundary(answer)),
|
||||
patch_of(vec![mirror("fresh", Some(fresh.clone()), dead_end)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::System(SystemCapability::Play),
|
||||
None,
|
||||
patch_of(vec![mirror("fresh-again", Some(fresh), dead_end)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("fresh-again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_import_key_is_refused_in_a_later_patch() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let revision = kernel.state.revision;
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("again", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("again")]);
|
||||
assert_eq!(kernel.state.revision, revision);
|
||||
assert_eq!(keys_in(&kernel), vec![alpha()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_retired_subjects_import_key_is_never_reissued() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let witness = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.import_key.is_some())
|
||||
.unwrap()
|
||||
.id;
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
WorldPatch {
|
||||
declarations: Vec::new(),
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Existing(witness),
|
||||
}],
|
||||
evidence: Vec::new(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let retired = kernel
|
||||
.snapshot()
|
||||
.unwrap()
|
||||
.subjects
|
||||
.into_iter()
|
||||
.find(|subject| subject.id == witness)
|
||||
.unwrap();
|
||||
assert!(retired.retired, "the witness is retired");
|
||||
assert_eq!(
|
||||
retired.import_key,
|
||||
Some(alpha()),
|
||||
"a retired subject keeps its key"
|
||||
);
|
||||
let error = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("reborn", Some(alpha()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(error), vec![held("reborn")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_that_differ_in_any_way_are_distinct_records() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
// Same length, case, prefix, the same concatenation split elsewhere, and
|
||||
// the parts swapped: none is the held key.
|
||||
let forgeries = [
|
||||
key("aetheria.ship", "Alphb"),
|
||||
key("aetheria.ship", "alpha"),
|
||||
key("aetheria.ship", "Alph"),
|
||||
key("aetheria.ship", "Alpha2"),
|
||||
key("aetheria.shipA", "lpha"),
|
||||
key("aetheria.sh", "ipAlpha"),
|
||||
key("Aetheria.ship", "Alpha"),
|
||||
key("Alpha", "aetheria.ship"),
|
||||
key("aetheria.ship2", "Alpha"),
|
||||
];
|
||||
let declarations = forgeries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, forged)| mirror(&format!("forged-{index}"), Some(forged.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("nine distinct keys beside the held one are admitted together");
|
||||
let held_now: BTreeSet<ImportKey> = keys_in(&kernel).into_iter().collect();
|
||||
assert_eq!(held_now.len(), forgeries.len() + 1);
|
||||
// And each is then held exactly: repeating any one is refused.
|
||||
for (index, forged) in forgeries.iter().enumerate() {
|
||||
let again = submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("repeat", Some(forged.clone()), place)]),
|
||||
);
|
||||
assert_eq!(rejected(again), vec![held("repeat")], "forgery {index}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_malformed_import_key_is_refused_without_echoing_it() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let long = "x".repeat(201);
|
||||
let wide = "é".repeat(101);
|
||||
let cases = [
|
||||
key("", "Delta"),
|
||||
key("aetheria.ship", ""),
|
||||
key("aetheria ship", "Delta"),
|
||||
key("aetheria.ship", "Del\tta"),
|
||||
key("aetheria.ship", "Del\u{a0}ta"),
|
||||
key("aetheria.ship", " Delta"),
|
||||
key("aetheria.ship", "Delta\n"),
|
||||
key(&long, "Delta"),
|
||||
key("aetheria.ship", &long),
|
||||
key("aetheria.ship", &wide),
|
||||
key(&wide, "Delta"),
|
||||
];
|
||||
for (index, bad) in cases.iter().enumerate() {
|
||||
let set = rejected(submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror("bad", Some(bad.clone()), place)]),
|
||||
));
|
||||
assert_eq!(set, vec![malformed("bad")], "case {index}");
|
||||
let spelled = format!("{set:?}");
|
||||
assert!(
|
||||
!spelled.contains("Delta") && !spelled.contains("aetheria"),
|
||||
"case {index} echoed the key"
|
||||
);
|
||||
}
|
||||
// The limit is 200 bytes per part, counted in bytes.
|
||||
let edge = "é".repeat(100);
|
||||
let at_limit = [
|
||||
key(&"x".repeat(200), &edge),
|
||||
key("aetheria.ship", &"y".repeat(200)),
|
||||
];
|
||||
let declarations = at_limit
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, limit)| mirror(&format!("edge-{index}"), Some(limit.clone()), place))
|
||||
.collect();
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(declarations),
|
||||
)
|
||||
.expect("keys of exactly 200 bytes per part are admitted");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subjects_map_refuses_a_second_subject_for_one_key_even_when_resolved_elsewhere() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = |handle: &str| {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", "Omega")),
|
||||
place,
|
||||
)]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("the key is unheld at the revision both were resolved against")
|
||||
};
|
||||
let (first, second) = (resolve("first"), resolve("second"));
|
||||
let mut state = kernel.state.clone();
|
||||
let before = state.subjects.len();
|
||||
let effect = |resolved| crate::WorldEffect::PatchAdmitted {
|
||||
answers: None,
|
||||
resolved,
|
||||
};
|
||||
crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(first),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(state.subjects.len(), before + 1);
|
||||
let result = crate::apply_effect(
|
||||
&mut state,
|
||||
CommandId::new(),
|
||||
&CallerId::Principal(owner()),
|
||||
&effect(second),
|
||||
);
|
||||
assert!(
|
||||
matches!(result, Err(KernelError::Invariant(_))),
|
||||
"{result:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
state.subjects.len(),
|
||||
before + 1,
|
||||
"the second subject was installed"
|
||||
);
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|window| window == needle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyless_world_digests_unchanged() {
|
||||
// A subject nobody imported serialises without the field at all, so every
|
||||
// digest written before keys existed is the digest of the same bytes.
|
||||
let (_dir, kernel) = create(creation(CommandId::new(), "Keyless"));
|
||||
let kernel = kernel.unwrap();
|
||||
let state = rmp_serde::to_vec_named(&kernel.state).unwrap();
|
||||
assert!(
|
||||
!contains(&state, b"import_key"),
|
||||
"a keyless world grew a field"
|
||||
);
|
||||
// A keyed world carries it, round-trips, and digests differently.
|
||||
let (_dir, keyed) = keyed_world();
|
||||
let keyed_state = rmp_serde::to_vec_named(&keyed.state).unwrap();
|
||||
assert!(contains(&keyed_state, b"import_key"));
|
||||
let back: crate::WorldState = rmp_serde::from_slice(&keyed_state).unwrap();
|
||||
assert_eq!(
|
||||
crate::state_digest(&back).unwrap(),
|
||||
crate::state_digest(&keyed.state).unwrap()
|
||||
);
|
||||
assert_ne!(
|
||||
crate::state_digest(&keyed.state).unwrap(),
|
||||
crate::state_digest(&kernel.state).unwrap()
|
||||
);
|
||||
// And a pre-key state deserialises with every key absent.
|
||||
let stripped: crate::WorldState = rmp_serde::from_slice(&state).unwrap();
|
||||
assert!(stripped.subjects.values().all(|s| s.import_key.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_keyed_world_reopens_with_its_keys_and_digests() {
|
||||
let (dir, kernel) = keyed_world();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
let world_id = snapshot.world_id;
|
||||
drop(kernel);
|
||||
let reopened = WorldKernel::open(dir.path().join("world.cc"), world_id).unwrap();
|
||||
let again = reopened.snapshot().unwrap();
|
||||
assert_eq!(again.state_digest, snapshot.state_digest);
|
||||
let keys = keys_in(&reopened);
|
||||
assert_eq!(keys, vec![alpha()]);
|
||||
// A reader of the snapshot gets both parts back, separately.
|
||||
assert_eq!(keys[0].schema(), "aetheria.ship");
|
||||
assert_eq!(keys[0].record(), "Alpha");
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_record_key_is_one_canonical_spelling() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let nfc = "Caf\u{e9}";
|
||||
let nfd = "Cafe\u{301}";
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.ship", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
submit(&mut kernel, "nfc", nfc).expect("the NFC spelling is admitted");
|
||||
let refused = [
|
||||
nfd,
|
||||
"Alpha\u{200b}",
|
||||
"Alpha\u{0}",
|
||||
"\u{feff}Alpha",
|
||||
"Alpha\u{202e}",
|
||||
"Alpha\u{7}",
|
||||
"Del ta",
|
||||
"Del\tta",
|
||||
"Del\u{a0}ta",
|
||||
"Del\u{2003}ta",
|
||||
"Del\nta",
|
||||
// The violation sits deep in a long, real-shaped path, past any prefix.
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Cafe\u{301}",
|
||||
"Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan\u{200b}Hart",
|
||||
];
|
||||
for (index, record) in refused.iter().enumerate() {
|
||||
let set = rejected(submit(&mut kernel, "bad", record));
|
||||
assert_eq!(set, vec![malformed("bad")], "spelling {index}");
|
||||
assert!(
|
||||
!format!("{set:?}").contains("Alpha"),
|
||||
"spelling {index} echoed the key"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
2,
|
||||
"only the genesis key and the NFC key are held"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lore_page_path_with_spaces_is_a_record_key() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let record = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart";
|
||||
let declare = |kernel: &mut WorldKernel, handle: &str| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
declare(&mut kernel, "ewan").expect("a path with interior spaces is admitted");
|
||||
assert_eq!(rejected(declare(&mut kernel, "again")), vec![held("again")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_loaded_state_with_a_bad_import_key_is_refused() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
submit_as(
|
||||
&mut kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
"beta",
|
||||
Some(key("aetheria.ship", "Beta")),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(crate::journal::verify_state_shape(&kernel.state).is_ok());
|
||||
let corrupt = |edit: &dyn Fn(&mut ImportKey)| {
|
||||
let mut state = kernel.state.clone();
|
||||
for subject in state.subjects.values_mut() {
|
||||
if let Some(held) = subject.import_key.as_mut() {
|
||||
edit(held);
|
||||
}
|
||||
}
|
||||
crate::journal::verify_state_shape(&state)
|
||||
};
|
||||
let shared = corrupt(&|held| *held = alpha());
|
||||
assert!(
|
||||
matches!(&shared, Err(crate::journal::JournalError::Corrupt(why)) if why.contains("import key")),
|
||||
"two subjects sharing one key: {shared:?}"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", " Beta ")).is_err(),
|
||||
"a key with edge spaces must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("", "")).is_err(),
|
||||
"an empty key must not load"
|
||||
);
|
||||
assert!(
|
||||
corrupt(&|held| *held = key("aetheria.ship", "Be\u{200b}ta")).is_err(),
|
||||
"a key with a format character must not load"
|
||||
);
|
||||
}
|
||||
|
||||
/// The admission backstop, reached with an effect resolution never produced: a
|
||||
/// resolved patch whose two subjects carry one key. The control is the same
|
||||
/// effect with distinct keys, admitted by the same function.
|
||||
#[test]
|
||||
fn admitting_a_resolved_effect_refuses_two_subjects_with_one_key() {
|
||||
let (_dir, kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let resolve = || {
|
||||
crate::patch::resolve_patch(
|
||||
&kernel.state,
|
||||
CommandId::new(),
|
||||
&patch_of(vec![
|
||||
mirror("first", Some(key("aetheria.ship", "Beta")), place),
|
||||
mirror("second", Some(key("aetheria.ship", "Gamma")), place),
|
||||
]),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("two distinct keys resolve")
|
||||
};
|
||||
let resolves_to = crate::resolution_revision(&kernel.state).unwrap();
|
||||
let mut control = kernel.state.clone();
|
||||
crate::admit_resolved(&mut control, &resolve(), resolves_to)
|
||||
.expect("distinct keys are admitted");
|
||||
|
||||
let mut twinned = resolve();
|
||||
assert_eq!(twinned.subjects.len(), 2);
|
||||
twinned.subjects[1].subject.import_key = twinned.subjects[0].subject.import_key.clone();
|
||||
let mut state = kernel.state.clone();
|
||||
let error = crate::admit_resolved(&mut state, &twinned, resolves_to)
|
||||
.expect_err("one key on two resolved subjects is refused");
|
||||
assert!(
|
||||
matches!(&error, KernelError::Invariant(why) if why.contains("import key")),
|
||||
"{error:?}"
|
||||
);
|
||||
assert!(!format!("{error:?}").contains("Beta"), "the refusal echoed the key");
|
||||
}
|
||||
|
||||
/// A decomposable character at every position of a real-shaped key: the NFD
|
||||
/// spelling is refused wherever it sits, the NFC spelling is admitted, so the
|
||||
/// rule cannot be a prefix, suffix or first-character check.
|
||||
#[test]
|
||||
fn a_decomposable_character_is_refused_in_nfd_at_every_position() {
|
||||
let (_dir, mut kernel) = keyed_world();
|
||||
let place = commons(&kernel);
|
||||
let template: Vec<char> = "Worldbuilding/Pre-Elysium/Factions/Powers/Minor/Ewan Hart"
|
||||
.chars()
|
||||
.collect();
|
||||
let submit = |kernel: &mut WorldKernel, handle: &str, record: String| {
|
||||
submit_as(
|
||||
kernel,
|
||||
CallerId::Principal(owner()),
|
||||
None,
|
||||
patch_of(vec![mirror(
|
||||
handle,
|
||||
Some(key("aetheria.lore", &record)),
|
||||
place,
|
||||
)]),
|
||||
)
|
||||
};
|
||||
let spelled = |position: usize, inserted: &str| {
|
||||
let mut record: String = template[..position].iter().collect();
|
||||
record.push_str(inserted);
|
||||
record.extend(&template[position..]);
|
||||
record
|
||||
};
|
||||
for position in 0..=template.len() {
|
||||
let nfd = spelled(position, "e\u{301}");
|
||||
assert!(
|
||||
!unicode_normalization::is_nfc(&nfd),
|
||||
"position {position} is not NFD"
|
||||
);
|
||||
let set = rejected(submit(&mut kernel, "bad", nfd));
|
||||
assert_eq!(set, vec![malformed("bad")], "NFD at position {position}");
|
||||
}
|
||||
for position in 0..=template.len() {
|
||||
let nfc = spelled(position, "\u{e9}");
|
||||
submit(&mut kernel, &format!("good{position}"), nfc)
|
||||
.unwrap_or_else(|_| panic!("NFC at position {position} was refused"));
|
||||
}
|
||||
assert_eq!(
|
||||
keys_in(&kernel).len(),
|
||||
1 + template.len() + 1,
|
||||
"the genesis key and one NFC key per position are held, no NFD key"
|
||||
);
|
||||
}
|
||||
|
|
@ -181,6 +181,11 @@ impl WorldJournal {
|
|||
self.commits.get(&command_id)
|
||||
}
|
||||
|
||||
/// Every commit the journal holds, in no particular order.
|
||||
pub(super) fn commits(&self) -> impl Iterator<Item = &WorldCommit> {
|
||||
self.commits.values()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) fn commit_count(&self) -> usize {
|
||||
self.commits.len()
|
||||
|
|
@ -826,6 +831,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError>
|
|||
"one subject holds two overlapping jurisdictions of one kind".into(),
|
||||
));
|
||||
}
|
||||
let mut declared_keys = BTreeSet::new();
|
||||
let no_held = BTreeSet::new();
|
||||
for subject in state.subjects.values() {
|
||||
if let Some(key) = &subject.import_key
|
||||
&& super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some()
|
||||
{
|
||||
return Err(JournalError::Corrupt(
|
||||
"subject import key is malformed or held by two subjects".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut controller_ids = BTreeSet::new();
|
||||
for (subject_id, subject) in &state.subjects {
|
||||
if !super::patch::is_canonical_text(&subject.label) {
|
||||
|
|
@ -1107,8 +1123,10 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
|
|
@ -1190,8 +1208,10 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
|
|
@ -1220,6 +1240,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -1755,8 +1776,10 @@ mod tests {
|
|||
controller: NewController::Human { principal: owner },
|
||||
affordances: kernel_speak_grant(),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
};
|
||||
let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap();
|
||||
let mut forged_head = kernel.state.clone();
|
||||
|
|
|
|||
|
|
@ -56,11 +56,15 @@
|
|||
//! ```
|
||||
|
||||
mod action;
|
||||
mod aetheria;
|
||||
mod clock;
|
||||
mod consumer;
|
||||
mod controllers;
|
||||
mod cover;
|
||||
mod elaboration;
|
||||
mod grammar;
|
||||
#[cfg(test)]
|
||||
mod import_key_tests;
|
||||
mod journal;
|
||||
mod lens;
|
||||
mod local_inference;
|
||||
|
|
@ -70,6 +74,7 @@ mod sdk_inference;
|
|||
mod table;
|
||||
mod tool_schema;
|
||||
mod vault;
|
||||
mod verse;
|
||||
|
||||
pub(crate) use action::ActionMismatch;
|
||||
pub(crate) use clock::Motion;
|
||||
|
|
@ -80,23 +85,33 @@ 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, InferenceFaultDisposition, InferenceOutput,
|
||||
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput,
|
||||
InferencePort,
|
||||
InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint,
|
||||
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle,
|
||||
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle,
|
||||
TracingInferencePort, open_controller_work, open_inference,
|
||||
};
|
||||
pub(crate) use cover::{CellId, Constituent, Resolution};
|
||||
pub use aetheria::{
|
||||
AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog,
|
||||
};
|
||||
pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover};
|
||||
pub use elaboration::{
|
||||
ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery,
|
||||
EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row,
|
||||
};
|
||||
pub use grammar::{
|
||||
GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb,
|
||||
RenderPath,
|
||||
};
|
||||
pub use lens::{Lens, LensWeights};
|
||||
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
|
||||
pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding};
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use local_inference::open_local_port_with_timeout;
|
||||
pub(crate) use mailbox::ElaborationPort;
|
||||
pub use mailbox::{ConsumerPort, ControllerPort, MailboxError, PlayPort, SeedPort, WorldMailbox};
|
||||
pub use patch::{
|
||||
|
|
@ -111,6 +126,7 @@ pub(crate) use patch::{
|
|||
PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach,
|
||||
SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef,
|
||||
};
|
||||
pub use patch::ImportKey;
|
||||
#[cfg(test)]
|
||||
use patch::{
|
||||
AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp,
|
||||
|
|
@ -128,6 +144,13 @@ pub use table::{
|
|||
id_text, id_text_matches, table_view,
|
||||
};
|
||||
pub use vault::{VaultError, VaultEvidenceSource};
|
||||
pub use verse::{
|
||||
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord,
|
||||
RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim,
|
||||
ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint,
|
||||
TickReport, UnattributedCall, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
|
||||
call_run_id, run_manual,
|
||||
};
|
||||
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
|
@ -423,6 +446,10 @@ struct CreateWorld {
|
|||
/// The world's flavor as genesis writes it. `SetLensWeights` may replace
|
||||
/// it later; this command carries what the world began with.
|
||||
lens_weights: LensWeights,
|
||||
/// The grammar the world is bound to for its whole life, or none for an
|
||||
/// open-catalog world. Authored here and nowhere else: genesis installs it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grammar: Option<GrammarBinding>,
|
||||
}
|
||||
|
||||
/// Unattributed creation intent. World ingress derives ownership, controller
|
||||
|
|
@ -707,6 +734,10 @@ pub enum CommandBody {
|
|||
struct SubjectState {
|
||||
label: String,
|
||||
kind: SubjectKind,
|
||||
/// Written only when a subject declaration is resolved; absent from a
|
||||
/// subject nobody imported, so its digests are unchanged.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
import_key: Option<ImportKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -826,6 +857,11 @@ struct WorldState {
|
|||
/// Authored at creation and never mutated; the one prose the world says
|
||||
/// about itself, projected as guidance to every lane.
|
||||
brief: String,
|
||||
/// Bound by genesis and never written again; absent from a world with no
|
||||
/// grammar, so such a world's state and digests are what they were before
|
||||
/// grammars existed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
grammar: Option<GrammarBinding>,
|
||||
draft_approvals: BTreeSet<PrincipalId>,
|
||||
subjects: BTreeMap<SubjectId, SubjectState>,
|
||||
entities: BTreeMap<EntityId, EntityRecord>,
|
||||
|
|
@ -991,6 +1027,8 @@ pub struct SubjectSnapshot {
|
|||
pub id: SubjectId,
|
||||
pub label: String,
|
||||
pub kind: SubjectKind,
|
||||
/// The Aetheria record this subject was imported from, if it was.
|
||||
pub import_key: Option<ImportKey>,
|
||||
/// Absent for an externally controlled mirror, which has no controller and
|
||||
/// no turn. Every reader fails closed on `None`; none gets a default.
|
||||
pub(crate) controller_id: Option<ControllerId>,
|
||||
|
|
@ -1133,6 +1171,9 @@ pub(crate) struct ForumSnapshot {
|
|||
pub struct AffordanceSnapshot {
|
||||
pub id: AffordanceId,
|
||||
pub entry: Affordance,
|
||||
/// Derived from the world's grammar by this entry's kind; none when the
|
||||
/// world has no grammar.
|
||||
pub render_path: Option<RenderPath>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -1195,6 +1236,8 @@ pub struct WorldSnapshot {
|
|||
pub owner: PrincipalId,
|
||||
pub title: String,
|
||||
pub brief: String,
|
||||
/// The bound grammar's identity; none for a world with an open catalog.
|
||||
pub grammar: Option<GrammarIdentity>,
|
||||
pub draft_approvals: BTreeSet<PrincipalId>,
|
||||
pub required_approvers: BTreeSet<PrincipalId>,
|
||||
pub subjects: Vec<SubjectSnapshot>,
|
||||
|
|
@ -1236,6 +1279,21 @@ pub struct OperatorEvent {
|
|||
pub speaker_label: String,
|
||||
pub speech: Option<Statement>,
|
||||
pub display: Option<Statement>,
|
||||
/// The affordance kind the act exercised.
|
||||
pub verb: String,
|
||||
/// How the world's grammar renders that verb; none in a world with no
|
||||
/// grammar.
|
||||
pub render_path: Option<RenderPath>,
|
||||
/// What the act bound each of the verb's roles to, in the invocation's
|
||||
/// order.
|
||||
pub roles: Vec<BoundRole>,
|
||||
}
|
||||
|
||||
/// One role an act bound, with the label of what it was bound to.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct BoundRole {
|
||||
pub role: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
|
@ -1423,7 +1481,12 @@ fn prepare_creation(
|
|||
// derived ID, so it is minted before resolution rather than by it.
|
||||
let world_id = WorldId::issue();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(
|
||||
world_id,
|
||||
input.owner.clone(),
|
||||
title.clone(),
|
||||
input.grammar.clone(),
|
||||
),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -1521,7 +1584,43 @@ impl WorldKernel {
|
|||
/// no consumer may read in full would be a loaded gun with a comment on it.
|
||||
fn operator_log(&self) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
operator_log(&self.state)
|
||||
// An event records what the world did; the invocation, which names the
|
||||
// bound roles, lives on the commit that produced it.
|
||||
let invocations: BTreeMap<EventId, &DecisionInvocation> = self
|
||||
.journal
|
||||
.commits()
|
||||
.filter_map(|commit| match &commit.effect {
|
||||
WorldEffect::DecisionExercised {
|
||||
invocation, event, ..
|
||||
} => Some((event.id, invocation)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
operator_log(&self.state, &invocations)
|
||||
}
|
||||
|
||||
/// The tick driver's opportunity list, in the same order a snapshot
|
||||
/// carries it. Separate from `snapshot` for the reason `agency_graph` is:
|
||||
/// only the tick driver reads it.
|
||||
fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
Ok(order_opportunities(
|
||||
&self.state,
|
||||
derive_opportunities(&self.state)?,
|
||||
))
|
||||
}
|
||||
|
||||
/// How many clock advances the world has committed: the next tick's
|
||||
/// ordinal, held by the world and read from its journal. Separate from
|
||||
/// `snapshot` for the reason `opportunities` is: only the tick driver
|
||||
/// reads it.
|
||||
fn clock_advances(&self) -> Result<u64, KernelError> {
|
||||
self.journal.ensure_healthy()?;
|
||||
Ok(self
|
||||
.journal
|
||||
.commits()
|
||||
.filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. }))
|
||||
.count() as u64)
|
||||
}
|
||||
|
||||
/// The scheduler's adjacency projection. Separate from `snapshot` for the
|
||||
|
|
@ -1894,7 +1993,12 @@ impl WorldState {
|
|||
/// The world before any structure. Genesis resolves its own patch against
|
||||
/// this value, so the genesis lane and `AdmitPatch` share one resolver
|
||||
/// signature instead of hand-passing empty partitions.
|
||||
fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self {
|
||||
fn empty(
|
||||
world_id: WorldId,
|
||||
owner: PrincipalId,
|
||||
title: String,
|
||||
grammar: Option<GrammarBinding>,
|
||||
) -> Self {
|
||||
Self {
|
||||
schema: STATE_SCHEMA.into(),
|
||||
world_id,
|
||||
|
|
@ -1903,6 +2007,7 @@ impl WorldState {
|
|||
owner,
|
||||
title,
|
||||
brief: String::new(),
|
||||
grammar,
|
||||
draft_approvals: BTreeSet::new(),
|
||||
subjects: BTreeMap::new(),
|
||||
entities: BTreeMap::new(),
|
||||
|
|
@ -1960,7 +2065,7 @@ impl WorldState {
|
|||
// The same re-derive-and-compare that `apply_committed_command` runs for
|
||||
// every other command. Deterministic allocation is what lets one
|
||||
// equality replace a field-by-field binding zip.
|
||||
let mut state = Self::empty(world_id, owner.clone(), title.clone());
|
||||
let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone());
|
||||
state.brief = brief.clone();
|
||||
let expected = patch::resolve_patch(
|
||||
&state,
|
||||
|
|
@ -2069,6 +2174,18 @@ fn admit_resolved(
|
|||
.filter_map(ControllerAssignment::id)
|
||||
.collect();
|
||||
for entry in &resolved.affordances {
|
||||
// The catalog's only writer holds the same grammar check the resolver
|
||||
// runs, so an effect resolved elsewhere cannot install an entry the
|
||||
// bound grammar refuses.
|
||||
if state.grammar.as_ref().is_some_and(|grammar| {
|
||||
grammar
|
||||
.refusal(&entry.affordance.kind, &entry.affordance.roles)
|
||||
.is_some()
|
||||
}) {
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted affordance is outside the bound grammar".into(),
|
||||
));
|
||||
}
|
||||
if state
|
||||
.affordance_catalog
|
||||
.insert(entry.affordance_id, entry.affordance.clone())
|
||||
|
|
@ -2079,6 +2196,21 @@ fn admit_resolved(
|
|||
));
|
||||
}
|
||||
}
|
||||
// The subjects map's only writer holds the same injectivity the resolver
|
||||
// checks, decided by the same function.
|
||||
{
|
||||
let held = patch::held_import_keys(state.subjects.values());
|
||||
let mut declared = BTreeSet::new();
|
||||
for subject in &resolved.subjects {
|
||||
if let Some(key) = &subject.subject.import_key
|
||||
&& patch::import_key_fault(key, &held, &mut declared).is_some()
|
||||
{
|
||||
return Err(KernelError::Invariant(
|
||||
"admitted subject's import key is malformed, held or repeated".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for subject in &resolved.subjects {
|
||||
let scope = DecisionScope {
|
||||
subject_id: subject.subject_id,
|
||||
|
|
@ -3663,7 +3795,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent
|
|||
/// The human operator's story feed, derived from the causal record. The events
|
||||
/// log is not a perception surface: `knowledge` is the sole answer to what a
|
||||
/// subject may act on and what its projection may contain.
|
||||
fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
fn operator_log(
|
||||
state: &WorldState,
|
||||
invocations: &BTreeMap<EventId, &DecisionInvocation>,
|
||||
) -> Result<Vec<OperatorEvent>, KernelError> {
|
||||
state
|
||||
.events
|
||||
.iter()
|
||||
|
|
@ -3672,6 +3807,30 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
|||
.subjects
|
||||
.get(&event.scope.subject_id)
|
||||
.ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?;
|
||||
let invocation = invocations.get(&event.id).ok_or_else(|| {
|
||||
KernelError::Invariant("event has no committed invocation".into())
|
||||
})?;
|
||||
let entry = state
|
||||
.affordance_catalog
|
||||
.get(&event.affordance)
|
||||
.ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?;
|
||||
let roles = invocation
|
||||
.bindings
|
||||
.iter()
|
||||
.map(|binding| {
|
||||
let label = match binding.target {
|
||||
Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()),
|
||||
Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()),
|
||||
Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()),
|
||||
};
|
||||
label
|
||||
.map(|target| BoundRole {
|
||||
role: binding.role.0.clone(),
|
||||
target,
|
||||
})
|
||||
.ok_or_else(|| KernelError::Invariant("event binds no live referent".into()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(OperatorEvent {
|
||||
revision: event.revision,
|
||||
speaker: event.scope.subject_id,
|
||||
|
|
@ -3684,6 +3843,13 @@ fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
|
|||
.display
|
||||
.and_then(|fact| state.facts.get(&fact))
|
||||
.map(|record| record.statement.clone()),
|
||||
verb: entry.kind.0.clone(),
|
||||
render_path: state
|
||||
.grammar
|
||||
.as_ref()
|
||||
.and_then(|grammar| grammar.verb(&entry.kind.0))
|
||||
.map(|verb| verb.render_path),
|
||||
roles,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -3962,6 +4128,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
id: *subject_id,
|
||||
label: subject.label.clone(),
|
||||
kind: subject.kind,
|
||||
import_key: subject.import_key.clone(),
|
||||
controller_id: controller.id(),
|
||||
controller_mode: controller.mode(),
|
||||
human_controller: controller.human_principal().cloned(),
|
||||
|
|
@ -4077,6 +4244,7 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
owner: state.owner.clone(),
|
||||
title: state.title.clone(),
|
||||
brief: state.brief.clone(),
|
||||
grammar: state.grammar.as_ref().map(GrammarBinding::identity),
|
||||
draft_approvals: state.draft_approvals.clone(),
|
||||
required_approvers: required_approvers(state),
|
||||
subjects,
|
||||
|
|
@ -4086,6 +4254,11 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
|
|||
.map(|(affordance_id, entry)| AffordanceSnapshot {
|
||||
id: *affordance_id,
|
||||
entry: entry.clone(),
|
||||
render_path: state
|
||||
.grammar
|
||||
.as_ref()
|
||||
.and_then(|grammar| grammar.verb(&entry.kind.0))
|
||||
.map(|verb| verb.render_path),
|
||||
})
|
||||
.collect(),
|
||||
places,
|
||||
|
|
@ -5436,6 +5609,7 @@ mod tests {
|
|||
// The fixture world is one room. Co-located speech needs a place to
|
||||
// fill, so every fixture subject stands in the commons.
|
||||
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -5538,11 +5712,13 @@ mod tests {
|
|||
.chain(std::iter::once(Ref::Draft(DraftHandle::new("convene"))))
|
||||
.collect(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
lens_weights: stock_weights(),
|
||||
grammar: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -5687,6 +5863,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Draft(DraftHandle::new("yard"))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
// A route that should start closed is declared and closed in one
|
||||
|
|
@ -5792,6 +5969,7 @@ mod tests {
|
|||
}))
|
||||
.collect(),
|
||||
position: Some(Ref::Existing(place)),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -6312,6 +6490,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: civic_grants(handles, &speak),
|
||||
position: Some(place),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let declarations = civic_affordances()
|
||||
|
|
@ -6368,6 +6547,7 @@ mod tests {
|
|||
&speak,
|
||||
),
|
||||
position: Some(Ref::Draft(DraftHandle::new("hall"))),
|
||||
import_key: None,
|
||||
}),
|
||||
Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new("reeve"),
|
||||
|
|
@ -6379,6 +6559,7 @@ mod tests {
|
|||
&speak,
|
||||
),
|
||||
position: Some(Ref::Draft(DraftHandle::new("chamber"))),
|
||||
import_key: None,
|
||||
}),
|
||||
person(
|
||||
"farmer",
|
||||
|
|
@ -6402,6 +6583,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: civic_grants(&["levy", "petition"], &speak),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
])
|
||||
.collect();
|
||||
|
|
@ -6565,6 +6747,7 @@ mod tests {
|
|||
Ref::Draft(DraftHandle::new("recant")),
|
||||
]),
|
||||
position: place.map(|place| Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
submit_owner(
|
||||
|
|
@ -7833,6 +8016,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.road)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -8851,6 +9035,7 @@ mod custody_tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.yard)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::Admit {
|
||||
|
|
@ -9510,6 +9695,7 @@ mod custody_tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: Some(Ref::Existing(topology.yard)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![admit(u64::MAX), admit(1)],
|
||||
|
|
@ -11436,7 +11622,7 @@ mod soul_knowledge_tests {
|
|||
// Nobody holds it.
|
||||
assert!(kernel.state.knowledge.is_empty());
|
||||
// The operator reads it anyway, with the speaker resolved by label.
|
||||
let log = operator_log(&kernel.state).unwrap();
|
||||
let log = kernel.operator_log().unwrap();
|
||||
assert_eq!(
|
||||
log,
|
||||
vec![OperatorEvent {
|
||||
|
|
@ -11445,6 +11631,9 @@ mod soul_knowledge_tests {
|
|||
speaker_label: "The Yard Bystander".into(),
|
||||
speech: Some(Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
verb: "speak".into(),
|
||||
render_path: None,
|
||||
roles: Vec::new(),
|
||||
}]
|
||||
);
|
||||
// And no subject snapshot carries it.
|
||||
|
|
@ -11502,6 +11691,7 @@ mod witness_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]),
|
||||
position: Some(Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -12615,6 +12805,7 @@ mod witness_tests {
|
|||
// Every non-mirror subject must hold one.
|
||||
affordances: BTreeSet::from([Ref::Existing(beacon)]),
|
||||
position: position.map(Ref::Existing),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![witness(
|
||||
|
|
@ -12979,6 +13170,7 @@ mod clock_tests {
|
|||
Ref::Draft(DraftHandle::new("threaten")),
|
||||
]),
|
||||
position: Some(Ref::Draft(DraftHandle::new(place))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle));
|
||||
|
|
@ -15160,7 +15352,7 @@ mod clock_tests {
|
|||
}));
|
||||
let title = normalize_title(&input.title).unwrap();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -15189,7 +15381,7 @@ mod clock_tests {
|
|||
});
|
||||
let title = normalize_title(&input.title).unwrap();
|
||||
let resolved = patch::resolve_patch(
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
|
||||
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
|
||||
input.id,
|
||||
&input.patch,
|
||||
Some(&input.scale_intent),
|
||||
|
|
@ -17032,6 +17224,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -17098,6 +17291,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -17175,6 +17369,7 @@ mod clock_tests {
|
|||
},
|
||||
affordances: BTreeSet::new(),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
|
|
@ -17268,6 +17463,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::Mint {
|
||||
|
|
@ -17510,6 +17706,7 @@ mod clock_tests {
|
|||
// the boundary its own answer claims: an occupied
|
||||
// place is no longer unelaborated.
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: Vec::new(),
|
||||
evidence: Vec::new(),
|
||||
|
|
@ -17688,6 +17885,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::Retire {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -17797,6 +17995,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(commons)),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: vec![ComponentOp::GrantAffordance {
|
||||
|
|
@ -18011,6 +18210,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::GrantAffordance {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -18429,6 +18629,7 @@ mod clock_tests {
|
|||
Ref::Existing(clockwork.threaten),
|
||||
]),
|
||||
position: Some(Ref::Existing(clockwork.dead_end)),
|
||||
import_key: None,
|
||||
})],
|
||||
operations: vec![ComponentOp::RevokeAffordance {
|
||||
subject: Ref::Draft(DraftHandle::new("stray")),
|
||||
|
|
@ -18877,6 +19078,7 @@ mod clock_tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak.clone()]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
Declaration::Channel(ChannelDeclaration {
|
||||
handle: DraftHandle::new("wire"),
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -3,9 +3,10 @@ use super::{
|
|||
ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation,
|
||||
DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey,
|
||||
KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId,
|
||||
Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
|
||||
EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId,
|
||||
WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal,
|
||||
patch::kernel_speak_grant, prepare_creation,
|
||||
verse::{CreateVerseWorldIntent, VerseGenesisError},
|
||||
};
|
||||
use crate::VerifiedPrincipalEvidence;
|
||||
use chrono::Utc;
|
||||
|
|
@ -18,6 +19,21 @@ use tokio::task::JoinHandle;
|
|||
/// other. A world that wants more declares them through the patch lane.
|
||||
const GENESIS_PLACE: &str = "commons";
|
||||
|
||||
/// The commons every genesis declares, the one room its first subjects share.
|
||||
pub(crate) fn commons_declaration() -> Declaration {
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new(GENESIS_PLACE),
|
||||
label: "The Commons".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a genesis subject stands.
|
||||
pub(crate) fn commons_ref() -> Ref<EntityId> {
|
||||
Ref::Draft(DraftHandle::new(GENESIS_PLACE))
|
||||
}
|
||||
|
||||
const REQUEST_CAPACITY: usize = 32;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
|
@ -66,6 +82,16 @@ enum Request {
|
|||
AgencyGraph {
|
||||
reply: oneshot::Sender<Result<AgencyGraph, KernelError>>,
|
||||
},
|
||||
/// The tick driver's opportunity list. Separate from `Snapshot` so the
|
||||
/// list a cover is derived from has one door, held by the driver.
|
||||
Opportunities {
|
||||
reply: oneshot::Sender<Result<Vec<DecisionOpportunity>, KernelError>>,
|
||||
},
|
||||
/// The tick ordinal's one door: how many clock advances the world has
|
||||
/// committed. Held by the tick driver, as `Opportunities` is.
|
||||
ClockAdvances {
|
||||
reply: oneshot::Sender<Result<u64, KernelError>>,
|
||||
},
|
||||
ControllerReceipt {
|
||||
command_id: CommandId,
|
||||
opportunity: super::DecisionOpportunity,
|
||||
|
|
@ -120,7 +146,8 @@ impl WorldMailbox {
|
|||
kind,
|
||||
controller,
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
|
||||
position: Some(commons_ref()),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
// Roots stand beside the commons, not inside it: a root contained by
|
||||
|
|
@ -129,12 +156,7 @@ impl WorldMailbox {
|
|||
// jurisdiction's population target. The owner subject lands in
|
||||
// `Uncovered`, visible and reducing nothing.
|
||||
let mut declarations = vec![
|
||||
Declaration::Entity(EntityDeclaration {
|
||||
handle: DraftHandle::new(GENESIS_PLACE),
|
||||
label: "The Commons".into(),
|
||||
kind: EntityKind::Place,
|
||||
container: None,
|
||||
}),
|
||||
commons_declaration(),
|
||||
declare(
|
||||
"first-person",
|
||||
input.human_subject_label,
|
||||
|
|
@ -189,6 +211,7 @@ impl WorldMailbox {
|
|||
},
|
||||
scale_intent,
|
||||
lens_weights: input.lens_weights,
|
||||
grammar: None,
|
||||
},
|
||||
AuthenticatedCaller::verified_principal(principal_id),
|
||||
)
|
||||
|
|
@ -217,6 +240,20 @@ impl WorldMailbox {
|
|||
.map_err(mailbox_outcome)
|
||||
}
|
||||
|
||||
/// Creates the canonical Aetheria verse world through the same
|
||||
/// authenticated creation `create` uses.
|
||||
pub async fn create_verse(
|
||||
&self,
|
||||
intent: CreateVerseWorldIntent,
|
||||
principal: &VerifiedPrincipalEvidence,
|
||||
) -> Result<CreationReceipt, VerseGenesisError> {
|
||||
let principal_id = PrincipalId::new(principal.account_subject_hash());
|
||||
let input = intent.lower(principal_id.clone())?;
|
||||
Ok(self
|
||||
.create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id))
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// A wall-clock read at the boundary a request crosses into the mailbox is
|
||||
/// ingress, not reduce: the reducer stays deterministic over its journal,
|
||||
/// and this is the one place a caller's own claim of continued authority
|
||||
|
|
@ -459,6 +496,37 @@ impl WorldMailbox {
|
|||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
/// The tick driver's opportunity list, in snapshot order. It is on
|
||||
/// `WorldMailbox` and on no controller port, as `agency_graph` is.
|
||||
pub async fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::Opportunities { reply })
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
/// The tick driver's ordinal: how many clock advances the world has
|
||||
/// committed. It is on `WorldMailbox` and on no controller port, as
|
||||
/// `opportunities` is.
|
||||
pub async fn clock_advances(&self) -> Result<u64, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
.send(Request::ClockAdvances { reply })
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?;
|
||||
|
||||
response
|
||||
.await
|
||||
.map_err(|_| MailboxError::Unavailable)?
|
||||
.map_err(MailboxError::Kernel)
|
||||
}
|
||||
|
||||
pub async fn operator_log(&self) -> Result<Vec<OperatorEvent>, MailboxError> {
|
||||
let (reply, response) = oneshot::channel();
|
||||
self.sender
|
||||
|
|
@ -928,6 +996,32 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver<Request>)
|
|||
return;
|
||||
}
|
||||
}
|
||||
Request::Opportunities { reply } => {
|
||||
let result = match &owned {
|
||||
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
|
||||
OwnedWorld::Live(kernel) => kernel.opportunities(),
|
||||
};
|
||||
let owner_must_stop = result
|
||||
.as_ref()
|
||||
.is_err_and(KernelError::requires_owner_restart);
|
||||
let _ = reply.send(result);
|
||||
if owner_must_stop {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Request::ClockAdvances { reply } => {
|
||||
let result = match &owned {
|
||||
OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated),
|
||||
OwnedWorld::Live(kernel) => kernel.clock_advances(),
|
||||
};
|
||||
let owner_must_stop = result
|
||||
.as_ref()
|
||||
.is_err_and(KernelError::requires_owner_restart);
|
||||
let _ = reply.send(result);
|
||||
if owner_must_stop {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Request::ControllerReceipt {
|
||||
command_id,
|
||||
opportunity,
|
||||
|
|
@ -988,6 +1082,7 @@ mod tests {
|
|||
fn creation(id: CommandId, title: &str) -> CreateWorld {
|
||||
CreateWorld {
|
||||
lens_weights: crate::tests::stock_weights(),
|
||||
grammar: None,
|
||||
id,
|
||||
owner: PrincipalId::new("owner"),
|
||||
title: title.into(),
|
||||
|
|
@ -1009,6 +1104,7 @@ mod tests {
|
|||
},
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))),
|
||||
import_key: None,
|
||||
}),
|
||||
],
|
||||
operations: Vec::new(),
|
||||
|
|
@ -1396,6 +1492,7 @@ mod tests {
|
|||
controller,
|
||||
affordances: kernel_speak_grant(),
|
||||
position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))),
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
let room = |handle: &str| {
|
||||
|
|
@ -1425,6 +1522,7 @@ mod tests {
|
|||
evidence: Vec::new(),
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef::default(),
|
||||
grammar: None,
|
||||
},
|
||||
&authenticated,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
|
||||
use super::clock::{FictionalMinutes, TickMinutes};
|
||||
use super::tool_schema;
|
||||
use super::grammar::GrammarRefusal;
|
||||
use super::{
|
||||
AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights,
|
||||
NewController, SubjectId, SubjectKind, SubjectState, WorldId,
|
||||
|
|
@ -22,6 +23,7 @@ use serde::{Deserialize, Serialize};
|
|||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{BTreeMap, BTreeSet, btree_map::Entry};
|
||||
use unicode_properties::{GeneralCategory, UnicodeGeneralCategory};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
|
|
@ -944,6 +946,102 @@ impl EvidenceRef {
|
|||
}
|
||||
}
|
||||
|
||||
/// The Aetheria record a subject was imported from: the CultCache schema id and
|
||||
/// the record key, compared exactly as two separate texts so no pair of
|
||||
/// distinct records can spell one key. Admission keeps the mapping injective;
|
||||
/// a key is never matched by prefix, case or normalisation.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImportKey {
|
||||
schema: String,
|
||||
record: String,
|
||||
}
|
||||
|
||||
/// The longest either part of an import key may be, in bytes.
|
||||
const MAX_IMPORT_KEY_PART_BYTES: usize = 200;
|
||||
|
||||
impl ImportKey {
|
||||
pub fn new(schema: impl Into<String>, record: impl Into<String>) -> Self {
|
||||
Self {
|
||||
schema: schema.into(),
|
||||
record: record.into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn schema(&self) -> &str {
|
||||
&self.schema
|
||||
}
|
||||
|
||||
pub fn record(&self) -> &str {
|
||||
&self.record
|
||||
}
|
||||
|
||||
/// The one predicate for an import key, called by the resolver and by the
|
||||
/// load-time state gate alike. The record part is canonical free text: not
|
||||
/// empty, at most 200 bytes, no leading, trailing or doubled space, already NFC
|
||||
/// (refused, never normalised, so the kernel derives no second spelling),
|
||||
/// no control or format character, and no whitespace but U+0020. The schema
|
||||
/// part is a schema id: the same text with no space at all.
|
||||
pub(super) fn is_canonical(&self) -> bool {
|
||||
is_canonical_key_text(&self.record)
|
||||
&& is_canonical_key_text(&self.schema)
|
||||
&& !self.schema.contains(' ')
|
||||
}
|
||||
}
|
||||
|
||||
fn is_canonical_key_text(part: &str) -> bool {
|
||||
!part.is_empty()
|
||||
&& part.len() <= MAX_IMPORT_KEY_PART_BYTES
|
||||
&& !part.starts_with(' ')
|
||||
&& !part.ends_with(' ')
|
||||
&& !part.contains(" ")
|
||||
&& unicode_normalization::is_nfc(part)
|
||||
&& part.chars().all(|c| {
|
||||
!c.is_control()
|
||||
&& c.general_category() != GeneralCategory::Format
|
||||
&& (c == ' ' || !c.is_whitespace())
|
||||
})
|
||||
}
|
||||
|
||||
/// Why an import key cannot join a world: the resolver names these by draft
|
||||
/// handle, the state gates by invariant text.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum ImportKeyFault {
|
||||
Malformed,
|
||||
Held,
|
||||
Duplicate,
|
||||
}
|
||||
|
||||
/// The keys the subjects hold, retired ones included, so a key is never
|
||||
/// reissued. Derived on demand; no stored index.
|
||||
pub(super) fn held_import_keys<'a>(
|
||||
subjects: impl IntoIterator<Item = &'a super::SubjectState>,
|
||||
) -> BTreeSet<&'a ImportKey> {
|
||||
subjects
|
||||
.into_iter()
|
||||
.filter_map(|subject| subject.import_key.as_ref())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The one decision on a key: malformed, held by a subject already in the
|
||||
/// world, or already claimed in `declared` (which it joins). Every door that
|
||||
/// admits or loads subjects calls this, so none can decide otherwise.
|
||||
pub(super) fn import_key_fault<'a>(
|
||||
key: &'a ImportKey,
|
||||
held: &BTreeSet<&ImportKey>,
|
||||
declared: &mut BTreeSet<&'a ImportKey>,
|
||||
) -> Option<ImportKeyFault> {
|
||||
if !key.is_canonical() {
|
||||
Some(ImportKeyFault::Malformed)
|
||||
} else if held.contains(key) {
|
||||
Some(ImportKeyFault::Held)
|
||||
} else if !declared.insert(key) {
|
||||
Some(ImportKeyFault::Duplicate)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// `position` is the subject's presence: one place it stands in. A subject
|
||||
/// declared without one is unplaced until a later pass gives placement an owner.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -958,6 +1056,11 @@ pub(crate) struct SubjectDeclaration {
|
|||
/// atomically.
|
||||
pub(crate) affordances: BTreeSet<Ref<AffordanceId>>,
|
||||
pub(crate) position: Option<Ref<EntityId>>,
|
||||
/// The record this subject was imported from, if any. Absent from a
|
||||
/// subject nobody imported, so such a world's state and digests are what
|
||||
/// they were before import keys existed.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) import_key: Option<ImportKey>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
|
|
@ -1350,6 +1453,33 @@ pub enum Mismatch {
|
|||
EmptyLabel {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The world is bound to a grammar and this affordance's kind is not one of
|
||||
/// its verbs.
|
||||
AffordanceKindNotInGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The kind is a verb of the bound grammar, but the declared roles are not
|
||||
/// its signature (name, referent kind or order).
|
||||
AffordanceRolesDisagreeWithGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// The grammar binds the kernel's speak verb as anything but a Conversation.
|
||||
SpeakRenderPathDisagreesWithGrammar {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// An import key is empty, has whitespace, or is longer than 200 bytes in
|
||||
/// either part.
|
||||
MalformedImportKey {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// Two subjects in one patch declare the same import key.
|
||||
DuplicateImportKey {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
/// A subject already in the world, retired or not, holds this import key.
|
||||
ImportKeyHeld {
|
||||
handle: DraftHandle,
|
||||
},
|
||||
UnresolvedDraft {
|
||||
site: Site,
|
||||
referent: DraftHandle,
|
||||
|
|
@ -3061,6 +3191,28 @@ fn candidate_set(
|
|||
}
|
||||
}
|
||||
|
||||
/// A bound grammar is the whole catalog: a kind it lacks, or a role signature
|
||||
/// it does not carry, is refused. A world with no grammar is not checked.
|
||||
fn check_against_grammar(
|
||||
state: &super::WorldState,
|
||||
handle: &DraftHandle,
|
||||
kind: &AffordanceKindName,
|
||||
roles: &[RoleSpec],
|
||||
mismatches: &mut Vec<Mismatch>,
|
||||
) {
|
||||
let Some(grammar) = &state.grammar else {
|
||||
return;
|
||||
};
|
||||
let handle = handle.clone();
|
||||
mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal {
|
||||
GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle },
|
||||
GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle },
|
||||
GrammarRefusal::SpeakNotConversation => {
|
||||
Mismatch::SpeakRenderPathDisagreesWithGrammar { handle }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// The one resolution owner for every admission lane: declarations, references,
|
||||
/// topology admission, and operation preconditions. Every check runs against the
|
||||
/// complete candidate graph — what the world already holds plus what this patch
|
||||
|
|
@ -3084,6 +3236,11 @@ pub(super) fn resolve_patch(
|
|||
if admits_human {
|
||||
index.insert(speak_handle.clone(), RefKind::Affordance);
|
||||
}
|
||||
if admits_human {
|
||||
// The kernel-built entry is held to the grammar like any declaration.
|
||||
let speak = kernel_speak_entry();
|
||||
check_against_grammar(state, &speak_handle, &speak.kind, &speak.roles, &mut mismatches);
|
||||
}
|
||||
let mut kind_names: BTreeSet<AffordanceKindName> = state
|
||||
.affordance_catalog
|
||||
.values()
|
||||
|
|
@ -3093,6 +3250,8 @@ pub(super) fn resolve_patch(
|
|||
kind_names.insert(kernel_speak_entry().kind);
|
||||
}
|
||||
|
||||
let held_keys = held_import_keys(state.subjects.values());
|
||||
let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new();
|
||||
for (position, declaration) in patch.declarations.iter().enumerate() {
|
||||
let (handle, label, kind) = match declaration {
|
||||
Declaration::Subject(subject) => (
|
||||
|
|
@ -3132,6 +3291,21 @@ pub(super) fn resolve_patch(
|
|||
}),
|
||||
}
|
||||
}
|
||||
if let Declaration::Subject(subject) = declaration
|
||||
&& let Some(key) = &subject.import_key
|
||||
{
|
||||
let handle = subject.handle.clone();
|
||||
match import_key_fault(key, &held_keys, &mut declared_keys) {
|
||||
Some(ImportKeyFault::Malformed) => {
|
||||
mismatches.push(Mismatch::MalformedImportKey { handle });
|
||||
}
|
||||
Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }),
|
||||
Some(ImportKeyFault::Duplicate) => {
|
||||
mismatches.push(Mismatch::DuplicateImportKey { handle });
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Declaration::Affordance(affordance) = declaration {
|
||||
validate_affordance(
|
||||
&affordance.handle,
|
||||
|
|
@ -3143,6 +3317,13 @@ pub(super) fn resolve_patch(
|
|||
affordance.carries_speech,
|
||||
&mut mismatches,
|
||||
);
|
||||
check_against_grammar(
|
||||
state,
|
||||
&affordance.handle,
|
||||
&affordance.kind,
|
||||
&affordance.roles,
|
||||
&mut mismatches,
|
||||
);
|
||||
if !kind_names.insert(affordance.kind.clone()) {
|
||||
mismatches.push(Mismatch::DuplicateAffordanceKind {
|
||||
handle: affordance.handle.clone(),
|
||||
|
|
@ -5229,6 +5410,7 @@ pub(super) fn resolve_patch(
|
|||
subject: SubjectState {
|
||||
label: input.label.clone(),
|
||||
kind: input.kind,
|
||||
import_key: input.import_key.clone(),
|
||||
},
|
||||
controller,
|
||||
affordances: granted,
|
||||
|
|
@ -7734,7 +7916,8 @@ mod tests {
|
|||
};
|
||||
|
||||
/// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer:
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a
|
||||
/// `ghostlight-dungeon`'s play agent (PA.f61, parked at
|
||||
/// `parked/ghostlight-eve-surface`), which refuses a
|
||||
/// `set_persona_material` run whose patch answers true here, since
|
||||
/// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse
|
||||
/// its JSON to decide the question itself.
|
||||
|
|
@ -7853,6 +8036,7 @@ mod tests {
|
|||
controller: NewController::OperationalAgent,
|
||||
affordances: BTreeSet::from([speak_entry(kernel)]),
|
||||
position,
|
||||
import_key: None,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -8120,6 +8304,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})
|
||||
};
|
||||
|
||||
|
|
@ -8420,6 +8605,7 @@ mod tests {
|
|||
},
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
]);
|
||||
let error = kernel
|
||||
|
|
@ -8472,6 +8658,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
}),
|
||||
]);
|
||||
let receipt = submit_owner(&mut kernel, &before, admit(repaired));
|
||||
|
|
@ -9027,6 +9214,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak_entry(&kernel)]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})]);
|
||||
submit_owner(&mut kernel, &before, admit(patch.clone()));
|
||||
let admitted = *kernel
|
||||
|
|
@ -9303,6 +9491,7 @@ mod tests {
|
|||
controller: NewController::NarrativePersona,
|
||||
affordances: BTreeSet::from([speak]),
|
||||
position: None,
|
||||
import_key: None,
|
||||
})])),
|
||||
);
|
||||
let active = activate(&mut kernel);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
//! its own, computes no tool result, and holds no credential.
|
||||
|
||||
use super::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest,
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, TokenUsage,
|
||||
PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid,
|
||||
tool_name_is_valid, unix_ms,
|
||||
};
|
||||
|
|
@ -121,6 +121,22 @@ pub(super) struct SdkModelUsage {
|
|||
pub(super) cache_creation_input_tokens: u64,
|
||||
}
|
||||
|
||||
/// The reply's token usage summed over the models the run used. The whole
|
||||
/// prompt is the fresh input plus both cache figures (Anthropic reports them
|
||||
/// apart); the cached part is what was read from cache. A run that reported
|
||||
/// no usage at all is `None`, never a zero.
|
||||
fn sdk_usage(usage: &[SdkModelUsage]) -> Option<TokenUsage> {
|
||||
if usage.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let sum = |field: fn(&SdkModelUsage) -> u64| usage.iter().map(field).sum::<u64>();
|
||||
Some(TokenUsage {
|
||||
prompt: sum(|u| u.input_tokens) + sum(|u| u.cache_read_input_tokens) + sum(|u| u.cache_creation_input_tokens),
|
||||
completion: sum(|u| u.output_tokens),
|
||||
cached_prompt: Some(sum(|u| u.cache_read_input_tokens)),
|
||||
})
|
||||
}
|
||||
|
||||
/// The failures the sidecar is allowed to name. It reports a reason; this
|
||||
/// module assigns the disposition, so TypeScript can never quarantine the
|
||||
/// world's cognition by naming an integrity violation directly.
|
||||
|
|
@ -146,8 +162,27 @@ pub(super) enum SidecarFaultReason {
|
|||
}
|
||||
|
||||
impl SidecarFaultReason {
|
||||
fn into_fault(self, detail: String) -> InferenceFault {
|
||||
let detail = format!("SDK sidecar reported {self:?}: {detail}");
|
||||
/// 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",
|
||||
};
|
||||
match self {
|
||||
Self::RateLimited | Self::Overloaded | Self::ServerError | Self::ApiTimeout => {
|
||||
InferenceFault::retryable(detail)
|
||||
|
|
@ -173,10 +208,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(String),
|
||||
Codec,
|
||||
/// The child could not be started. The entry file existed at open, so this
|
||||
/// is an environment fault an operator must fix.
|
||||
Spawn(String),
|
||||
Spawn,
|
||||
}
|
||||
|
||||
/// The child-process seam, so the protocol driver is testable without Node, a
|
||||
|
|
@ -203,26 +238,21 @@ 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(format!(
|
||||
"sidecar frame length {length} is outside 1..={MAX_SIDECAR_FRAME_BYTES}"
|
||||
)));
|
||||
return Err(SidecarLinkError::Codec);
|
||||
}
|
||||
let mut body = vec![0_u8; length];
|
||||
reader
|
||||
.read_exact(&mut body)
|
||||
.await
|
||||
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
|
||||
rmp_serde::from_slice(&body).map_err(|error| SidecarLinkError::Codec(error.to_string()))
|
||||
.map_err(|_| SidecarLinkError::Codec)?;
|
||||
rmp_serde::from_slice(&body).map_err(|_| SidecarLinkError::Codec)
|
||||
}
|
||||
|
||||
fn encode_frame(frame: &SidecarFrame) -> Result<Vec<u8>, SidecarLinkError> {
|
||||
let body = rmp_serde::to_vec_named(frame)
|
||||
.map_err(|error| SidecarLinkError::Codec(error.to_string()))?;
|
||||
.map_err(|_| SidecarLinkError::Codec)?;
|
||||
if body.len() > MAX_SIDECAR_FRAME_BYTES {
|
||||
return Err(SidecarLinkError::Codec(format!(
|
||||
"sidecar frame of {} bytes exceeds {MAX_SIDECAR_FRAME_BYTES}",
|
||||
body.len()
|
||||
)));
|
||||
return Err(SidecarLinkError::Codec);
|
||||
}
|
||||
let mut framed = Vec::with_capacity(body.len() + 4);
|
||||
framed.extend_from_slice(&(body.len() as u32).to_be_bytes());
|
||||
|
|
@ -260,15 +290,15 @@ impl ChildProcessLink {
|
|||
.stderr(Stdio::inherit())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.map_err(|error| SidecarLinkError::Spawn(error.to_string()))?;
|
||||
.map_err(|_| SidecarLinkError::Spawn)?;
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdin was not piped".into()))?;
|
||||
.ok_or(SidecarLinkError::Spawn)?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| SidecarLinkError::Spawn("sidecar stdout was not piped".into()))?;
|
||||
.ok_or(SidecarLinkError::Spawn)?;
|
||||
Ok(SidecarChild {
|
||||
child,
|
||||
stdin,
|
||||
|
|
@ -466,9 +496,9 @@ impl SdkInferencePort {
|
|||
return Err(link_fault(error));
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
Err(_) => {
|
||||
let _ = self.link.restart().await;
|
||||
return Err(InferenceFault::new(error.to_string()));
|
||||
return Err(InferenceFault::new("the tool-result owner refused a tool call"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -482,10 +512,10 @@ impl SdkInferencePort {
|
|||
SidecarFrame::Fault {
|
||||
query_id: seen,
|
||||
reason,
|
||||
detail,
|
||||
..
|
||||
} if seen == query_id => {
|
||||
let _ = self.link.restart().await;
|
||||
return Err(reason.into_fault(detail));
|
||||
return Err(reason.into_fault());
|
||||
}
|
||||
_ => {
|
||||
let _ = self.link.restart().await;
|
||||
|
|
@ -501,13 +531,11 @@ impl SdkInferencePort {
|
|||
fn link_fault(error: SidecarLinkError) -> InferenceFault {
|
||||
match error {
|
||||
SidecarLinkError::Closed => {
|
||||
InferenceFault::new("the SDK sidecar exited during a query".to_string())
|
||||
InferenceFault::new("the SDK sidecar exited during a query")
|
||||
}
|
||||
SidecarLinkError::Spawn(detail) => {
|
||||
InferenceFault::new(format!("the SDK sidecar could not start: {detail}"))
|
||||
}
|
||||
SidecarLinkError::Codec(detail) => {
|
||||
InferenceFault::integrity_violation(format!("SDK sidecar framing: {detail}"))
|
||||
SidecarLinkError::Spawn => InferenceFault::new("the SDK sidecar could not start"),
|
||||
SidecarLinkError::Codec => {
|
||||
InferenceFault::integrity_violation("the SDK sidecar's framing was invalid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -526,9 +554,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(format!(
|
||||
"reasoning effort `{effort}` has no SDK counterpart"
|
||||
)));
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the requested reasoning effort has no SDK counterpart",
|
||||
));
|
||||
}
|
||||
let mut items = request.input.iter();
|
||||
let Some(CodexInputItem::UserText { text: prompt }) = items.next() else {
|
||||
|
|
@ -641,10 +669,11 @@ fn assemble_output(
|
|||
total_cost_usd_estimate: material.total_cost_usd_estimate,
|
||||
};
|
||||
let receipt_bytes = rmp_serde::to_vec_named(&receipt)
|
||||
.map_err(|error| InferenceFault::new(error.to_string()))?;
|
||||
Ok(InferenceOutput {
|
||||
events: lowered,
|
||||
receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)),
|
||||
.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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -701,10 +730,10 @@ pub(super) struct RoutedInferencePort {
|
|||
connector: Option<Arc<dyn InferencePort>>,
|
||||
sdk: Option<Arc<dyn InferencePort>>,
|
||||
sdk_model_prefix: String,
|
||||
/// The local port's own claimed prefix, carried beside it rather than in a
|
||||
/// third bare `String` field, so `route` can compare its length against
|
||||
/// the SDK's claim without a third parallel field to keep in sync.
|
||||
local: Option<(String, Arc<dyn InferencePort>)>,
|
||||
/// The OpenAI-compatible ports (the loopback one and the hosted one), each
|
||||
/// with its own claimed prefix carried beside it, so `route` can compare
|
||||
/// lengths without a parallel field per port to keep in sync.
|
||||
claims: Vec<(String, Arc<dyn InferencePort>)>,
|
||||
}
|
||||
|
||||
impl RoutedInferencePort {
|
||||
|
|
@ -712,13 +741,13 @@ impl RoutedInferencePort {
|
|||
connector: Option<Arc<dyn InferencePort>>,
|
||||
sdk: Option<Arc<dyn InferencePort>>,
|
||||
sdk_model_prefix: impl Into<String>,
|
||||
local: Option<(String, Arc<dyn InferencePort>)>,
|
||||
claims: Vec<(String, Arc<dyn InferencePort>)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
connector,
|
||||
sdk,
|
||||
sdk_model_prefix: sdk_model_prefix.into(),
|
||||
local,
|
||||
claims,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -735,9 +764,10 @@ impl RoutedInferencePort {
|
|||
// the connector, which is what keeps a typo'd model prefix loud
|
||||
// instead of silently reaching the wrong backend.
|
||||
let local_claim = self
|
||||
.local
|
||||
.as_ref()
|
||||
.claims
|
||||
.iter()
|
||||
.filter(|(prefix, _)| model.starts_with(prefix.as_str()))
|
||||
.max_by_key(|(prefix, _)| prefix.len())
|
||||
.map(|(prefix, port)| (prefix.len(), port));
|
||||
let sdk_claim = model
|
||||
.starts_with(&self.sdk_model_prefix)
|
||||
|
|
@ -763,9 +793,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(format!(
|
||||
"no configured inference backend claims the model `{model}`"
|
||||
))),
|
||||
None => Err(InferenceFault::integrity_violation(
|
||||
"no configured inference backend claims the requested model",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -773,9 +803,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(format!(
|
||||
"no configured inference backend claims the model `{model}`"
|
||||
))),
|
||||
None => Err(InferenceFault::integrity_violation(
|
||||
"no configured inference backend claims the requested model",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1190,6 +1220,41 @@ mod tests {
|
|||
assert_eq!(output.events.len(), 2);
|
||||
}
|
||||
|
||||
/// The SDK lane reports the run's tokens summed over its models: the whole
|
||||
/// prompt is fresh input plus both cache figures, the cached part is the
|
||||
/// cache read, and a run that reported nothing is `None`, not a zero.
|
||||
#[tokio::test]
|
||||
async fn the_sdk_receipts_token_counts_reach_inference_output() {
|
||||
let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME);
|
||||
let prepared = prose_prepared(&port, "Say something true.");
|
||||
let events = vec![SidecarEvent::Text { text: "ok".into() }];
|
||||
let mut two_models = material();
|
||||
two_models.usage = vec![
|
||||
SdkModelUsage {
|
||||
model: TEST_MODEL.into(),
|
||||
input_tokens: 11,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 3,
|
||||
cache_creation_input_tokens: 2,
|
||||
},
|
||||
SdkModelUsage {
|
||||
model: "claude-haiku-5".into(),
|
||||
input_tokens: 100,
|
||||
output_tokens: 40,
|
||||
cache_read_input_tokens: 7,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
];
|
||||
let output = assemble_output(&prepared, events.clone(), two_models, &[]).unwrap();
|
||||
assert_eq!(
|
||||
output.usage(),
|
||||
Some(TokenUsage { prompt: 123, completion: 45, cached_prompt: Some(10) })
|
||||
);
|
||||
let mut none = material();
|
||||
none.usage = Vec::new();
|
||||
assert_eq!(assemble_output(&prepared, events, none, &[]).unwrap().usage(), None);
|
||||
}
|
||||
|
||||
/// Spec test 11. The digest is a pure function of the receipt, so it is
|
||||
/// compared directly rather than through two whole queries.
|
||||
#[tokio::test]
|
||||
|
|
@ -1262,6 +1327,7 @@ mod tests {
|
|||
let granted = vec![AffordanceSnapshot {
|
||||
id: AffordanceId::issue(),
|
||||
entry: kernel_speak_entry(),
|
||||
render_path: None,
|
||||
}];
|
||||
let speak = granted[0].entry.kind.0.clone();
|
||||
let link = ScriptedLink::new(vec![
|
||||
|
|
@ -1323,7 +1389,7 @@ mod tests {
|
|||
Some(Arc::clone(&connector)),
|
||||
Some(Arc::clone(&sdk)),
|
||||
DEFAULT_SDK_MODEL_PREFIX,
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk));
|
||||
assert!(Arc::ptr_eq(
|
||||
|
|
@ -1334,7 +1400,7 @@ mod tests {
|
|||
Some(Arc::clone(&connector)),
|
||||
Some(Arc::clone(&sdk)),
|
||||
"gpt-",
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk));
|
||||
assert!(Arc::ptr_eq(
|
||||
|
|
@ -1373,11 +1439,11 @@ mod tests {
|
|||
};
|
||||
|
||||
assert!(matches!(
|
||||
open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()),
|
||||
open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()),
|
||||
Err(ControllerOpenError::UnroutableModel { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
open_inference(Some(connector()), None, None, &models("claude-opus-5").each()),
|
||||
open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()),
|
||||
Err(ControllerOpenError::UnroutableModel { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
|
|
@ -1389,12 +1455,13 @@ mod tests {
|
|||
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&models(TEST_MODEL).each(),
|
||||
),
|
||||
Err(ControllerOpenError::SdkSidecarMissing { .. })
|
||||
));
|
||||
assert!(
|
||||
open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(),
|
||||
open_inference(Some(connector()), Some(sdk()), None, None, &models(TEST_MODEL).each()).is_ok(),
|
||||
"a configuration where every model routes did not open"
|
||||
);
|
||||
}
|
||||
|
|
@ -1482,7 +1549,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("detail".into());
|
||||
let fault = reason.into_fault();
|
||||
assert!(
|
||||
!fault.requires_recovery() && !fault.integrity_was_violated(),
|
||||
"{reason:?} was not retryable"
|
||||
|
|
@ -1500,13 +1567,13 @@ mod tests {
|
|||
Unknown,
|
||||
] {
|
||||
assert!(
|
||||
reason.into_fault("detail".into()).requires_recovery(),
|
||||
reason.into_fault().requires_recovery(),
|
||||
"{reason:?} was not recovery-required"
|
||||
);
|
||||
}
|
||||
for reason in [ProtocolViolation, ToolRegistrationFailed, TurnCapRefused] {
|
||||
assert!(
|
||||
reason.into_fault("detail".into()).integrity_was_violated(),
|
||||
reason.into_fault().integrity_was_violated(),
|
||||
"{reason:?} did not violate integrity"
|
||||
);
|
||||
}
|
||||
|
|
@ -1684,6 +1751,88 @@ 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() {
|
||||
|
|
@ -1788,7 +1937,7 @@ mod tests {
|
|||
)) as Arc<dyn InferencePort>),
|
||||
None,
|
||||
DEFAULT_SDK_MODEL_PREFIX,
|
||||
None,
|
||||
Vec::new(),
|
||||
);
|
||||
assert!(connector_only.route(TEST_MODEL).is_none());
|
||||
let prepared =
|
||||
|
|
@ -1803,7 +1952,7 @@ mod tests {
|
|||
.expect_err("an unroutable model inferred"),
|
||||
] {
|
||||
assert!(fault.integrity_was_violated(), "{fault:?}");
|
||||
assert!(fault.to_string().contains(TEST_MODEL));
|
||||
assert!(fault.to_string().contains("no configured inference backend"), "{fault:?}");
|
||||
}
|
||||
|
||||
// An SDK-only deployment opens with no connector binding at all, so no
|
||||
|
|
@ -1820,6 +1969,7 @@ mod tests {
|
|||
model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(),
|
||||
}),
|
||||
None,
|
||||
None,
|
||||
&models(TEST_MODEL).each(),
|
||||
)
|
||||
.is_ok()
|
||||
|
|
@ -1897,6 +2047,7 @@ mod tests {
|
|||
let granted = vec![AffordanceSnapshot {
|
||||
id: AffordanceId::issue(),
|
||||
entry: kernel_speak_entry(),
|
||||
render_path: None,
|
||||
}];
|
||||
let mut emitted: Vec<(String, String)> = patch_tools()
|
||||
.into_iter()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
//! Projector may call it.
|
||||
|
||||
use super::controllers::{
|
||||
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest,
|
||||
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage,
|
||||
RequestShape, tool_request,
|
||||
};
|
||||
use super::patch::{
|
||||
|
|
@ -1070,6 +1070,12 @@ impl InferenceOutput {
|
|||
pub fn receipt_digest(&self) -> &str {
|
||||
&self.receipt_digest
|
||||
}
|
||||
|
||||
/// The tokens the provider reported for this reply; `None` when it
|
||||
/// reported none.
|
||||
pub fn usage(&self) -> Option<TokenUsage> {
|
||||
self.usage
|
||||
}
|
||||
}
|
||||
|
||||
/// The canonical id as the patch vocabulary spells it: the bare UUID text,
|
||||
|
|
@ -1600,7 +1606,8 @@ mod tests {
|
|||
// PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it
|
||||
// from its own list too. This is now the complete 24-tool list.
|
||||
// PA.f88: this is the library's own test fixture, kept in sync by hand.
|
||||
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the
|
||||
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at
|
||||
// `parked/ghostlight-eve-surface`) is the
|
||||
// single policy owner of what the play agent may actually call; this
|
||||
// copy pins nothing there and is not read by production code.
|
||||
const PLAY_TOOLS: &[&str] = &[
|
||||
|
|
@ -2489,6 +2496,7 @@ mod tests {
|
|||
outcome_bands: Vec::new(),
|
||||
carries_speech: false,
|
||||
},
|
||||
render_path: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
759
crates/ghostlight/src/verse/calls.rs
Normal file
759
crates/ghostlight/src/verse/calls.rs
Normal file
|
|
@ -0,0 +1,759 @@
|
|||
//! Provider call records, the run's spend, and replay.
|
||||
//!
|
||||
//! `RecordingInferencePort` is the one owner of a run's spend. Before it
|
||||
//! forwards a call it decides, from the request alone, whether the call could
|
||||
//! pass the run's token cap; after the reply it charges what the call really
|
||||
//! cost. Every reply is stored as a `ProviderCallRecord` before the output
|
||||
//! returns to the lane, so `ReplayInferencePort` can serve the same run again
|
||||
//! from the store with no provider behind it.
|
||||
//!
|
||||
//! Lanes and runners never count tokens. A request is held against the cap by
|
||||
//! its bound: the serialized provider request's bytes (a token is at least one
|
||||
//! byte, so bytes bound the prompt) plus the request's own output allowance.
|
||||
//!
|
||||
//! The run's spend is one persisted ledger row per run, owned by the store and
|
||||
//! written where it is spent. A call reserves its bound in that row before the
|
||||
//! provider is called; a reply settles the reservation to what it reported
|
||||
//! (prompt plus completion, cached prompt tokens being part of the prompt; none
|
||||
//! reported is charged the bound); a fault leaves the reservation charged. A
|
||||
//! restart reads the row, so no restart returns budget. A reply that reports
|
||||
//! more than the call's bound is charged in full and faults the call, so the
|
||||
//! run's spend never passes the cap unnoticed. One port at a time owns a run.
|
||||
|
||||
use crate::controllers::{
|
||||
InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose,
|
||||
InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use chrono::Utc;
|
||||
use codex_connector::CodexProviderRequest;
|
||||
use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, VecDeque},
|
||||
path::Path,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
const CALL_RECORD_ROW: &str = "verse_provider_call.v1";
|
||||
const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1";
|
||||
/// The run ledger document's schema.
|
||||
const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1";
|
||||
/// The call record document's schema. A store holding any other row type or
|
||||
/// schema is refused at open: this store is not migrated from, or shared with,
|
||||
/// another document.
|
||||
pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1";
|
||||
|
||||
#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
|
||||
pub enum CallRecordError {
|
||||
#[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")]
|
||||
InvalidRunId,
|
||||
#[error("the call record store could not be read or written")]
|
||||
Store,
|
||||
#[error("the call record store holds a row that is not a canonical call record")]
|
||||
Corrupt,
|
||||
#[error("a call record's sequence is not the next in its run")]
|
||||
OutOfSequence,
|
||||
#[error("replay needs one run's records in dense sequence from 1")]
|
||||
NotReplayable,
|
||||
#[error("another recording port already owns this run")]
|
||||
RunInUse,
|
||||
#[error("the call could pass the run's token cap")]
|
||||
OverCap,
|
||||
#[error("the run was recorded with a different {0}")]
|
||||
RunMismatch(RunField),
|
||||
}
|
||||
|
||||
/// What a run is bound to when it begins, named by the field a mismatch refuses.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum RunField {
|
||||
/// The token cap the run was opened with.
|
||||
Cap,
|
||||
/// The identity of the world the run started from.
|
||||
WorldId,
|
||||
/// The state digest of the world the run started from.
|
||||
StateDigest,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for RunField {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Cap => "token cap",
|
||||
Self::WorldId => "world id",
|
||||
Self::StateDigest => "world state digest",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The world a run began from: its identity and state digest. Replay is of a
|
||||
/// restored copy of this world, so it takes the same two values.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RunStart {
|
||||
world_id: String,
|
||||
state_digest: String,
|
||||
}
|
||||
|
||||
impl RunStart {
|
||||
pub fn of(world: &crate::WorldSnapshot) -> Self {
|
||||
Self {
|
||||
world_id: world.world_id.text(),
|
||||
state_digest: world.state_digest.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A run's identity as a key segment.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct RunId(String);
|
||||
|
||||
impl RunId {
|
||||
pub fn new(id: &str) -> Result<Self, CallRecordError> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_');
|
||||
if valid {
|
||||
Ok(Self(id.to_owned()))
|
||||
} else {
|
||||
Err(CallRecordError::InvalidRunId)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One provider call of one run: the request's content digest (never its text, so no
|
||||
/// prompt or credential is stored), the reply exactly as the lane received it,
|
||||
/// and what the call was charged. Keyed (run id, sequence).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProviderCallRecord {
|
||||
run_id: String,
|
||||
sequence: u64,
|
||||
purpose: InferencePurpose,
|
||||
model: String,
|
||||
content_sha256: String,
|
||||
events: Vec<InferenceEvent>,
|
||||
receipt_digest: String,
|
||||
usage: Option<TokenUsage>,
|
||||
charged: u64,
|
||||
}
|
||||
|
||||
impl ProviderCallRecord {
|
||||
pub fn run_id(&self) -> &str {
|
||||
&self.run_id
|
||||
}
|
||||
|
||||
pub fn sequence(&self) -> u64 {
|
||||
self.sequence
|
||||
}
|
||||
|
||||
pub fn purpose(&self) -> InferencePurpose {
|
||||
self.purpose
|
||||
}
|
||||
|
||||
pub fn model(&self) -> &str {
|
||||
&self.model
|
||||
}
|
||||
|
||||
/// Lowercase hex of the request's content digest (see `content_digest`).
|
||||
pub fn content_sha256(&self) -> &str {
|
||||
&self.content_sha256
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> Option<TokenUsage> {
|
||||
self.usage
|
||||
}
|
||||
|
||||
/// Tokens this call was charged against the run's cap.
|
||||
pub fn charged(&self) -> u64 {
|
||||
self.charged
|
||||
}
|
||||
|
||||
/// The reply as the lane received it.
|
||||
pub fn output(&self) -> InferenceOutput {
|
||||
let output = InferenceOutput::new(self.events.clone(), self.receipt_digest.clone());
|
||||
match self.usage {
|
||||
Some(usage) => output.with_usage(usage),
|
||||
None => output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn row_key(run: &str, sequence: u64) -> String {
|
||||
format!("{run}/{sequence:020}")
|
||||
}
|
||||
|
||||
fn hex(digest: &[u8]) -> String {
|
||||
digest.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
/// What a request asks of the provider, without the ids that name it. The
|
||||
/// request and conversation ids are derived from the command id, and a command
|
||||
/// id from the world it ran in, so keying on them would tie a recorded run to
|
||||
/// one world's random identity. Everything else the provider is sent
|
||||
/// (model, instructions, input, tools, shape) is in the digest.
|
||||
fn content_digest(request: &CodexProviderRequest) -> Result<String, InferenceFault> {
|
||||
// Exhaustive on purpose: a field added upstream must be placed in the key
|
||||
// or left out here by name before this compiles.
|
||||
let CodexProviderRequest {
|
||||
schema_id,
|
||||
request_id: _request_id, // names the call, not what it asks
|
||||
conversation_id: _conversation_id, // names the call, not what it asks
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
} = request;
|
||||
let bytes = rmp_serde::to_vec(&(
|
||||
schema_id,
|
||||
model,
|
||||
instructions,
|
||||
input,
|
||||
reasoning_effort,
|
||||
reasoning_summary,
|
||||
service_tier,
|
||||
output_format_name,
|
||||
previous_response_id,
|
||||
tools,
|
||||
tool_choice,
|
||||
parallel_tool_calls,
|
||||
output_schema_json,
|
||||
max_output_tokens,
|
||||
prompt_cache_key,
|
||||
))
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok(hex(&Sha256::digest(bytes)))
|
||||
}
|
||||
|
||||
/// A run's persisted ledger, the one owner of what the run began as: the cap
|
||||
/// it was opened under, the world it started from, and the tokens reserved or
|
||||
/// charged so far.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
struct RunLedger {
|
||||
run_id: String,
|
||||
cap: u64,
|
||||
start: RunStart,
|
||||
spent: u64,
|
||||
}
|
||||
|
||||
enum DecodedRow {
|
||||
Call(ProviderCallRecord),
|
||||
Ledger(RunLedger),
|
||||
}
|
||||
|
||||
pub(super) fn canonical<T: Serialize + for<'de> Deserialize<'de>>(
|
||||
row: &CultCacheEnvelope,
|
||||
) -> Result<T, CallRecordError> {
|
||||
let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?;
|
||||
let again = rmp_serde::to_vec_named(&value).map_err(|_| CallRecordError::Corrupt)?;
|
||||
if again == row.payload {
|
||||
Ok(value)
|
||||
} else {
|
||||
Err(CallRecordError::Corrupt)
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_row(row: &CultCacheEnvelope) -> Result<DecodedRow, CallRecordError> {
|
||||
match (row.r#type.as_str(), row.schema_id.as_deref()) {
|
||||
(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA)) => {
|
||||
let record: ProviderCallRecord = canonical(row)?;
|
||||
// A run id that is no whole key segment would key into another
|
||||
// run's records, and sequences count from 1.
|
||||
if RunId::new(&record.run_id).is_err()
|
||||
|| record.sequence == 0
|
||||
|| row.key != row_key(&record.run_id, record.sequence)
|
||||
{
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Call(record))
|
||||
}
|
||||
(RUN_LEDGER_ROW, Some(RUN_LEDGER_SCHEMA)) => {
|
||||
let ledger: RunLedger = canonical(row)?;
|
||||
if RunId::new(&ledger.run_id).is_err() || row.key != ledger.run_id {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
Ok(DecodedRow::Ledger(ledger))
|
||||
}
|
||||
_ => Err(CallRecordError::Corrupt),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RunTally {
|
||||
last_sequence: u64,
|
||||
/// The persisted ledger, read at open and written where it is spent.
|
||||
ledger: Option<RunLedger>,
|
||||
}
|
||||
|
||||
struct StoreState {
|
||||
store: OwnedRedbMessagePackBackingStore,
|
||||
runs: BTreeMap<String, RunTally>,
|
||||
/// Runs with a recording port alive; one ledger has one owner.
|
||||
owned: BTreeSet<String>,
|
||||
}
|
||||
|
||||
/// The call records and spend ledgers of every run in one CultCache file.
|
||||
/// Records are appended and never changed; a run's sequence is dense from 1.
|
||||
pub struct CallRecordStore {
|
||||
state: Mutex<StoreState>,
|
||||
}
|
||||
|
||||
impl CallRecordStore {
|
||||
/// Opens the file, creating it empty when absent. Every row must be a
|
||||
/// canonical call record or run ledger of this schema with its key, a
|
||||
/// dense sequence, and a ledger that covers its records' charges; anything
|
||||
/// else, an earlier or later schema version included, is refused and
|
||||
/// nothing is migrated.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, CallRecordError> {
|
||||
let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
store
|
||||
.validate_path_identity()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let rows = store.pull_all().map_err(|_| CallRecordError::Store)?;
|
||||
let mut runs: BTreeMap<String, RunTally> = BTreeMap::new();
|
||||
let mut counts: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut charged: BTreeMap<String, u64> = BTreeMap::new();
|
||||
for row in &rows {
|
||||
match decode_row(row)? {
|
||||
DecodedRow::Call(record) => {
|
||||
*counts.entry(record.run_id.clone()).or_default() += 1;
|
||||
let total = charged.entry(record.run_id.clone()).or_default();
|
||||
*total = total.saturating_add(record.charged);
|
||||
let tally = runs.entry(record.run_id).or_default();
|
||||
tally.last_sequence = tally.last_sequence.max(record.sequence);
|
||||
}
|
||||
DecodedRow::Ledger(ledger) => {
|
||||
let run = ledger.run_id.clone();
|
||||
runs.entry(run).or_default().ledger = Some(ledger);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Sequences start at 1 and keys are unique, so a run is dense exactly
|
||||
// when its rows number its highest sequence. Every record was reserved
|
||||
// in a ledger before it was made, so a run with records has a ledger
|
||||
// that covers what they charged.
|
||||
for (run, tally) in &runs {
|
||||
let rows = counts.get(run).copied().unwrap_or(0);
|
||||
let recorded = charged.get(run).copied().unwrap_or(0);
|
||||
let covered = tally
|
||||
.ledger
|
||||
.as_ref()
|
||||
.is_some_and(|ledger| ledger.spent >= recorded);
|
||||
if rows != tally.last_sequence || !covered {
|
||||
return Err(CallRecordError::Corrupt);
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
state: Mutex::new(StoreState {
|
||||
store,
|
||||
runs,
|
||||
owned: BTreeSet::new(),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn lock(&self) -> Result<std::sync::MutexGuard<'_, StoreState>, CallRecordError> {
|
||||
self.state.lock().map_err(|_| CallRecordError::Store)
|
||||
}
|
||||
|
||||
/// The sequence the next record of this run must carry.
|
||||
pub fn next_sequence(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.map_or(0, |tally| tally.last_sequence)
|
||||
+ 1)
|
||||
}
|
||||
|
||||
/// Tokens this run has reserved or been charged: its persisted ledger.
|
||||
pub fn spent(&self, run: &RunId) -> Result<u64, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent))
|
||||
}
|
||||
|
||||
/// Takes the run's ledger for one port, or refuses when another holds it.
|
||||
/// The first claim of a run writes its ledger: the cap it is opened under
|
||||
/// and the world it starts from. A later claim must name the same cap; the
|
||||
/// start is not compared, because a resumed world has moved on.
|
||||
fn claim(&self, run: &RunId, cap: u64, start: &RunStart) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if !state.owned.insert(run.as_str().to_owned()) {
|
||||
return Err(CallRecordError::RunInUse);
|
||||
}
|
||||
let begun = match state.runs.get(run.as_str()).and_then(|t| t.ledger.as_ref()) {
|
||||
Some(ledger) if ledger.cap == cap => Ok(()),
|
||||
Some(_) => Err(CallRecordError::RunMismatch(RunField::Cap)),
|
||||
None => Self::write_ledger(
|
||||
&mut state,
|
||||
RunLedger {
|
||||
run_id: run.as_str().to_owned(),
|
||||
cap,
|
||||
start: start.clone(),
|
||||
spent: 0,
|
||||
},
|
||||
),
|
||||
};
|
||||
if begun.is_err() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
begun
|
||||
}
|
||||
|
||||
/// The world a run began from, when it has begun.
|
||||
fn start_of(&self, run: &RunId) -> Result<Option<RunStart>, CallRecordError> {
|
||||
Ok(self
|
||||
.lock()?
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map(|ledger| ledger.start.clone()))
|
||||
}
|
||||
|
||||
fn disown(&self, run: &RunId) {
|
||||
if let Ok(mut state) = self.state.lock() {
|
||||
state.owned.remove(run.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
/// Persists the ledger, then adopts it in memory. A write that fails
|
||||
/// leaves the previous ledger.
|
||||
fn write_ledger(state: &mut StoreState, ledger: RunLedger) -> Result<(), CallRecordError> {
|
||||
let row = CultCacheEnvelope {
|
||||
key: ledger.run_id.clone(),
|
||||
r#type: RUN_LEDGER_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(&ledger).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(RUN_LEDGER_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
let run = ledger.run_id.clone();
|
||||
state.runs.entry(run).or_default().ledger = Some(ledger);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists `spent` as the run's spend; the run must have been claimed.
|
||||
fn write_spent(state: &mut StoreState, run: &str, spent: u64) -> Result<(), CallRecordError> {
|
||||
let mut ledger = state
|
||||
.runs
|
||||
.get(run)
|
||||
.and_then(|tally| tally.ledger.clone())
|
||||
.ok_or(CallRecordError::Store)?;
|
||||
ledger.spent = spent;
|
||||
Self::write_ledger(state, ledger)
|
||||
}
|
||||
|
||||
fn held(state: &StoreState, run: &RunId) -> u64 {
|
||||
state
|
||||
.runs
|
||||
.get(run.as_str())
|
||||
.and_then(|tally| tally.ledger.as_ref())
|
||||
.map_or(0, |ledger| ledger.spent)
|
||||
}
|
||||
|
||||
/// Charges `bound` to the run's persisted ledger, or refuses with
|
||||
/// `OverCap` when the ledger plus the bound would pass `cap`. The check and
|
||||
/// the write hold one lock, so calls on the wire hold their bounds.
|
||||
fn reserve(&self, run: &RunId, bound: u64, cap: u64) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
let after = Self::held(&state, run).saturating_add(bound);
|
||||
if after > cap {
|
||||
return Err(CallRecordError::OverCap);
|
||||
}
|
||||
Self::write_spent(&mut state, run.as_str(), after)
|
||||
}
|
||||
|
||||
/// Replaces a reservation of `bound` with what the call was charged. With
|
||||
/// a record, the record is stored first, so a crash between the two writes
|
||||
/// leaves the larger reservation charged.
|
||||
fn settle(
|
||||
&self,
|
||||
run: &RunId,
|
||||
bound: u64,
|
||||
charged: u64,
|
||||
record: Option<&ProviderCallRecord>,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
if let Some(record) = record {
|
||||
Self::push_record(&mut state, record)?;
|
||||
}
|
||||
let settled = Self::held(&state, run)
|
||||
.saturating_sub(bound)
|
||||
.saturating_add(charged);
|
||||
Self::write_spent(&mut state, run.as_str(), settled)
|
||||
}
|
||||
|
||||
fn append(&self, record: &ProviderCallRecord) -> Result<(), CallRecordError> {
|
||||
let mut state = self.lock()?;
|
||||
Self::push_record(&mut state, record)
|
||||
}
|
||||
|
||||
fn push_record(
|
||||
state: &mut StoreState,
|
||||
record: &ProviderCallRecord,
|
||||
) -> Result<(), CallRecordError> {
|
||||
let last = state
|
||||
.runs
|
||||
.get(&record.run_id)
|
||||
.map_or(0, |tally| tally.last_sequence);
|
||||
if record.sequence != last + 1 {
|
||||
return Err(CallRecordError::OutOfSequence);
|
||||
}
|
||||
let row = CultCacheEnvelope {
|
||||
key: row_key(&record.run_id, record.sequence),
|
||||
r#type: CALL_RECORD_ROW.into(),
|
||||
payload: rmp_serde::to_vec_named(record).map_err(|_| CallRecordError::Store)?,
|
||||
stored_at: Utc::now().to_rfc3339(),
|
||||
schema_id: Some(CALL_RECORD_SCHEMA.into()),
|
||||
};
|
||||
state.store.push(&row).map_err(|_| CallRecordError::Store)?;
|
||||
state
|
||||
.runs
|
||||
.entry(record.run_id.clone())
|
||||
.or_default()
|
||||
.last_sequence = record.sequence;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The run's records in sequence.
|
||||
pub fn records(&self, run: &RunId) -> Result<Vec<ProviderCallRecord>, CallRecordError> {
|
||||
let prefix = format!("{}/", run.as_str());
|
||||
let rows = self
|
||||
.lock()?
|
||||
.store
|
||||
.pull_all()
|
||||
.map_err(|_| CallRecordError::Store)?;
|
||||
let mut records = Vec::new();
|
||||
for row in rows.iter().filter(|row| row.key.starts_with(&prefix)) {
|
||||
if let DecodedRow::Call(record) = decode_row(row)? {
|
||||
records.push(record);
|
||||
}
|
||||
}
|
||||
records.sort_by_key(|record| record.sequence);
|
||||
Ok(records)
|
||||
}
|
||||
}
|
||||
|
||||
/// Decorates a port for one run: reserves each call's bound in the run's
|
||||
/// ledger before the provider is called, settles it to the real cost, and
|
||||
/// stores every reply before returning it.
|
||||
pub struct RecordingInferencePort {
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap: u64,
|
||||
}
|
||||
|
||||
impl RecordingInferencePort {
|
||||
/// Takes ownership of the run's ledger, which resumes from the store. The
|
||||
/// first open of a run records its cap and the world it starts from; a
|
||||
/// reopen with another cap is refused with `RunMismatch(Cap)`. A second
|
||||
/// port on a run whose first is alive is refused with `RunInUse`.
|
||||
pub fn open(
|
||||
inner: Arc<dyn InferencePort>,
|
||||
store: Arc<CallRecordStore>,
|
||||
run: RunId,
|
||||
cap_tokens: u64,
|
||||
start: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
store.claim(&run, cap_tokens, start)?;
|
||||
Ok(Self {
|
||||
inner,
|
||||
store,
|
||||
run,
|
||||
cap: cap_tokens,
|
||||
})
|
||||
}
|
||||
|
||||
/// The run this port records.
|
||||
pub fn run_id(&self) -> &RunId {
|
||||
&self.run
|
||||
}
|
||||
|
||||
/// The token cap the run was opened under.
|
||||
pub fn cap(&self) -> u64 {
|
||||
self.cap
|
||||
}
|
||||
|
||||
/// Tokens reserved or charged to the run so far.
|
||||
pub fn spent(&self) -> u64 {
|
||||
self.store.spent(&self.run).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
/// The most this request can cost: its serialized bytes plus its output
|
||||
/// allowance. A request with no output allowance has no bound, so it
|
||||
/// cannot be held under a cap.
|
||||
fn bound(request: &PreparedInference) -> Result<u64, InferenceFault> {
|
||||
let allowance = request
|
||||
.invocation
|
||||
.request
|
||||
.max_output_tokens
|
||||
.ok_or_else(InferenceFault::budget_exhausted)?;
|
||||
let payload = serde_json::to_vec(&request.invocation.request)
|
||||
.map_err(|_| InferenceFault::new("a provider request could not be measured"))?;
|
||||
Ok((payload.len() as u64).saturating_add(u64::from(allowance)))
|
||||
}
|
||||
|
||||
fn settle(
|
||||
&self,
|
||||
bound: u64,
|
||||
identity: (InferencePurpose, String, String),
|
||||
result: Result<InferenceOutput, InferenceFault>,
|
||||
) -> Result<InferenceOutput, InferenceFault> {
|
||||
// A fault leaves its reservation charged: the request may have run.
|
||||
let output = result?;
|
||||
let charged = match output.usage {
|
||||
Some(usage) => usage.prompt.saturating_add(usage.completion),
|
||||
None => bound,
|
||||
};
|
||||
if charged > bound {
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, None)
|
||||
.map_err(unrecorded)?;
|
||||
return Err(InferenceFault::budget_exhausted());
|
||||
}
|
||||
let (purpose, model, content_sha256) = identity;
|
||||
let record = ProviderCallRecord {
|
||||
run_id: self.run.as_str().to_owned(),
|
||||
sequence: self.store.next_sequence(&self.run).map_err(unrecorded)?,
|
||||
purpose,
|
||||
model,
|
||||
content_sha256,
|
||||
events: output.events.clone(),
|
||||
receipt_digest: output.receipt_digest.clone(),
|
||||
usage: output.usage,
|
||||
charged,
|
||||
};
|
||||
self.store
|
||||
.settle(&self.run, bound, charged, Some(&record))
|
||||
.map_err(unrecorded)?;
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for RecordingInferencePort {
|
||||
fn drop(&mut self) {
|
||||
self.store.disown(&self.run);
|
||||
}
|
||||
}
|
||||
|
||||
fn unrecorded(_: CallRecordError) -> InferenceFault {
|
||||
InferenceFault::integrity_violation("a provider call could not be recorded")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for RecordingInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
self.inner.prepare(request)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let bound = Self::bound(&request)?;
|
||||
let identity = (
|
||||
request.purpose,
|
||||
request.invocation.request.model.clone(),
|
||||
content_digest(&request.invocation.request)?,
|
||||
);
|
||||
self.store
|
||||
.reserve(&self.run, bound, self.cap)
|
||||
.map_err(|error| match error {
|
||||
CallRecordError::OverCap => InferenceFault::budget_exhausted(),
|
||||
_ => unrecorded(error),
|
||||
})?;
|
||||
let result = self.inner.infer(request).await;
|
||||
self.settle(bound, identity, result)
|
||||
}
|
||||
|
||||
fn lend_tool_results(&self, prepared: &PreparedInference, oracle: Box<dyn ToolResultOracle>) {
|
||||
self.inner.lend_tool_results(prepared, oracle);
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves a run's stored replies, in sequence, to the same requests, matched
|
||||
/// by what they ask of the provider and not by the ids that name them. It holds
|
||||
/// no provider and makes no call: a request that is not the next recorded one
|
||||
/// is a fault.
|
||||
pub struct ReplayInferencePort {
|
||||
caller_runtime_id: String,
|
||||
pending: Mutex<VecDeque<ProviderCallRecord>>,
|
||||
}
|
||||
|
||||
impl ReplayInferencePort {
|
||||
/// Replays `run` from the store against `world`, a restored copy of the
|
||||
/// world the run started from. Any other world is refused here, before a
|
||||
/// call, with `RunMismatch` naming the field; a run the store never began
|
||||
/// is `NotReplayable`.
|
||||
pub fn new(
|
||||
caller_runtime_id: impl Into<String>,
|
||||
store: &CallRecordStore,
|
||||
run: &RunId,
|
||||
world: &RunStart,
|
||||
) -> Result<Self, CallRecordError> {
|
||||
let began = store.start_of(run)?.ok_or(CallRecordError::NotReplayable)?;
|
||||
if began.world_id != world.world_id {
|
||||
return Err(CallRecordError::RunMismatch(RunField::WorldId));
|
||||
}
|
||||
if began.state_digest != world.state_digest {
|
||||
return Err(CallRecordError::RunMismatch(RunField::StateDigest));
|
||||
}
|
||||
Ok(Self {
|
||||
caller_runtime_id: caller_runtime_id.into(),
|
||||
pending: Mutex::new(store.records(run)?.into()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl InferencePort for ReplayInferencePort {
|
||||
fn prepare(&self, request: InferenceRequest) -> Result<PreparedInference, InferenceFault> {
|
||||
PreparedInference::prepare(
|
||||
&self.caller_runtime_id,
|
||||
unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64),
|
||||
request,
|
||||
)
|
||||
}
|
||||
|
||||
async fn infer(&self, request: PreparedInference) -> Result<InferenceOutput, InferenceFault> {
|
||||
let mut pending = self
|
||||
.pending
|
||||
.lock()
|
||||
.map_err(|_| InferenceFault::integrity_violation("the replay queue was poisoned"))?;
|
||||
let digest = content_digest(&request.invocation.request)?;
|
||||
let Some(next) = pending.front() else {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"replay has no recorded call left",
|
||||
));
|
||||
};
|
||||
if next.content_sha256 != digest {
|
||||
return Err(InferenceFault::integrity_violation(
|
||||
"the request is not the next recorded call",
|
||||
));
|
||||
}
|
||||
let output = next.output();
|
||||
pending.pop_front();
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
1230
crates/ghostlight/src/verse/calls/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
143
crates/ghostlight/src/verse/genesis.rs
Normal file
143
crates/ghostlight/src/verse/genesis.rs
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
//! The canonical Aetheria verse world's genesis.
|
||||
//!
|
||||
//! One intent lowers to one `CreateWorld`: a commons, every grammar verb
|
||||
//! declared as an affordance, and each Aetheria faction as an institution
|
||||
//! imported by its record key. The world is bound to the catalog's grammar, so
|
||||
//! the kernel refuses any affordance the grammar does not carry.
|
||||
//!
|
||||
//! Phase A verbs record the act and change no component: an outcome band with
|
||||
//! no effects, and the act carried as the utterance that makes an entry
|
||||
//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling
|
||||
//! `verb-effects-ghostlight`), and those arrive with phase B.
|
||||
|
||||
use crate::aetheria::AetheriaCatalog;
|
||||
use crate::mailbox::{MailboxError, commons_declaration, commons_ref};
|
||||
use crate::patch::{
|
||||
AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef,
|
||||
FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration,
|
||||
kernel_speak_entry, kernel_speak_grant,
|
||||
};
|
||||
use crate::{
|
||||
CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind,
|
||||
WorldPatch, WorldScaleIntentRef,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COMMIT_LEN: usize = 40;
|
||||
|
||||
/// What a caller states to create the canonical verse world. Everything else a
|
||||
/// world needs (the commons, the subjects, the affordances, their grants and
|
||||
/// the provenance of what is known) is derived from the catalog.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CreateVerseWorldIntent {
|
||||
pub id: CommandId,
|
||||
pub title: String,
|
||||
pub brief: String,
|
||||
pub catalog: AetheriaCatalog,
|
||||
/// The Aetheria commit the catalog was read at: 40 lower-case hex digits.
|
||||
/// It is the provenance of every fact the world holds about a faction.
|
||||
pub aetheria_commit: String,
|
||||
pub lens_weights: LensWeights,
|
||||
pub targets: BTreeMap<SubjectKind, u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum VerseGenesisError {
|
||||
#[error("aetheria_commit is not 40 lower-case hex digits")]
|
||||
NotACommit,
|
||||
#[error(transparent)]
|
||||
Mailbox(#[from] MailboxError),
|
||||
}
|
||||
|
||||
impl CreateVerseWorldIntent {
|
||||
/// The one lowering. The kernel supplies the `speak` entry at genesis and
|
||||
/// holds it to the grammar, so it is granted here and never declared.
|
||||
pub(crate) fn lower(self, owner: PrincipalId) -> Result<CreateWorld, VerseGenesisError> {
|
||||
let commit = &self.aetheria_commit;
|
||||
if commit.len() != COMMIT_LEN
|
||||
|| !commit
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
{
|
||||
return Err(VerseGenesisError::NotACommit);
|
||||
}
|
||||
|
||||
let speak = kernel_speak_entry().kind;
|
||||
let mut grants = kernel_speak_grant();
|
||||
let mut declarations = vec![commons_declaration()];
|
||||
for (kind, verb) in self.catalog.grammar.verbs() {
|
||||
if kind == speak.0 {
|
||||
continue;
|
||||
}
|
||||
let handle = DraftHandle::new(format!("verb-{kind}"));
|
||||
grants.insert(Ref::Draft(handle.clone()));
|
||||
declarations.push(Declaration::Affordance(AffordanceDeclaration {
|
||||
handle,
|
||||
kind: AffordanceKindName(kind.to_owned()),
|
||||
roles: verb.role_specs(),
|
||||
preconditions: vec![Precondition::CanBroadcast {
|
||||
via: AudienceSpec::Colocated,
|
||||
}],
|
||||
effect_slots: Vec::new(),
|
||||
outcome_bands: vec![OutcomeBand {
|
||||
weight: 1,
|
||||
effects: Vec::new(),
|
||||
}],
|
||||
// The kernel refuses an entry that can neither change a
|
||||
// component nor speak, and a speaking entry needs an audience,
|
||||
// so a phase A verb is its own utterance to the room: the act
|
||||
// is recorded as speech and nothing else changes.
|
||||
carries_speech: true,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut evidence = Vec::new();
|
||||
for (position, faction) in self.catalog.factions.iter().enumerate() {
|
||||
declarations.push(Declaration::Subject(SubjectDeclaration {
|
||||
handle: DraftHandle::new(format!("faction-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
kind: SubjectKind::Institution,
|
||||
controller: NewController::OperationalAgent,
|
||||
affordances: grants.clone(),
|
||||
position: Some(commons_ref()),
|
||||
import_key: Some(faction.key.clone()),
|
||||
}));
|
||||
// A faction with no description has nothing to state.
|
||||
if let Some(statement) = Statement::new(faction.description.trim()) {
|
||||
let receipt = EvidenceRef::new(format!(
|
||||
"aetheria:{commit}:{}/{}",
|
||||
faction.key.schema(),
|
||||
faction.key.record()
|
||||
));
|
||||
evidence.push(receipt.clone());
|
||||
declarations.push(Declaration::Fact(FactDeclaration {
|
||||
handle: DraftHandle::new(format!("fact-{position}")),
|
||||
label: faction.name.trim().to_owned(),
|
||||
statement,
|
||||
standing: FactStandingRef::Canonical { evidence: receipt },
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(CreateWorld {
|
||||
id: self.id,
|
||||
owner,
|
||||
title: self.title,
|
||||
brief: self.brief,
|
||||
patch: WorldPatch {
|
||||
declarations,
|
||||
operations: Vec::new(),
|
||||
evidence,
|
||||
},
|
||||
scale_intent: WorldScaleIntentRef {
|
||||
targets: self.targets,
|
||||
jurisdictions: BTreeMap::new(),
|
||||
},
|
||||
lens_weights: self.lens_weights,
|
||||
grammar: Some(self.catalog.grammar),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
476
crates/ghostlight/src/verse/genesis/tests.rs
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
use super::*;
|
||||
use crate::aetheria::read_aetheria_catalog;
|
||||
use crate::patch::{FactStanding, Mismatch};
|
||||
use crate::tests::{auth_principal, owner, stock_weights};
|
||||
use crate::{
|
||||
CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb,
|
||||
KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel,
|
||||
WorldMailbox, WorldPhase,
|
||||
};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Aetheria master when the fixture catalog was written.
|
||||
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
|
||||
const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
const FIXTURE: &str = concat!(
|
||||
env!("CARGO_MANIFEST_DIR"),
|
||||
"/tests/fixtures/aetheria-verse-catalog.cc"
|
||||
);
|
||||
|
||||
fn catalog() -> AetheriaCatalog {
|
||||
read_aetheria_catalog(FIXTURE).unwrap()
|
||||
}
|
||||
|
||||
fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent {
|
||||
CreateVerseWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "The Verse".into(),
|
||||
brief: "Aetheria.".into(),
|
||||
catalog,
|
||||
aetheria_commit: commit.into(),
|
||||
lens_weights: stock_weights(),
|
||||
targets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A world created through the kernel from the lowering, for what the
|
||||
/// snapshot hides: the facts' evidence.
|
||||
fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result<WorldKernel, KernelError>) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let world = intent.lower(owner()).unwrap();
|
||||
let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner()))
|
||||
.map(|(kernel, _)| kernel);
|
||||
(dir, result)
|
||||
}
|
||||
|
||||
struct Verse {
|
||||
_dir: tempfile::TempDir,
|
||||
_task: tokio::task::JoinHandle<()>,
|
||||
mailbox: WorldMailbox,
|
||||
principal: VerifiedPrincipalEvidence,
|
||||
receipt: crate::CreationReceipt,
|
||||
}
|
||||
|
||||
async fn verse(intent: CreateVerseWorldIntent) -> Verse {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let receipt = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
Verse {
|
||||
_dir: dir,
|
||||
_task: task,
|
||||
mailbox,
|
||||
principal,
|
||||
receipt,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verse_genesis_from_the_aetheria_fixture() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
// One institution per faction, keyed by its record, named by its Name.
|
||||
let mut factions: Vec<_> = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.map(|subject| {
|
||||
(
|
||||
subject.import_key.clone().unwrap(),
|
||||
subject.label.clone(),
|
||||
subject.kind,
|
||||
subject.controller_mode,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
factions.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
let expected: Vec<_> = catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| {
|
||||
(
|
||||
faction.key.clone(),
|
||||
faction.name.clone(),
|
||||
SubjectKind::Institution,
|
||||
Some(ControllerMode::OperationalAgent),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(factions.len(), 2);
|
||||
assert_eq!(factions, expected);
|
||||
|
||||
// One fact per faction, carrying its description.
|
||||
let statements: BTreeSet<_> = snapshot
|
||||
.facts
|
||||
.iter()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
statements,
|
||||
catalog
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| faction.description.clone())
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// The world is bound to the catalog's grammar.
|
||||
assert_eq!(snapshot.grammar, Some(catalog.grammar.identity()));
|
||||
assert_eq!(verse.receipt.world_id, snapshot.world_id);
|
||||
|
||||
// Every faction stands in the one commons.
|
||||
let commons: Vec<_> = snapshot
|
||||
.places
|
||||
.iter()
|
||||
.filter(|place| place.label == "The Commons")
|
||||
.collect();
|
||||
assert_eq!(commons.len(), 1);
|
||||
assert!(
|
||||
snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.all(|subject| subject.position == Some(commons[0].id))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() {
|
||||
for commit in [COMMIT, OTHER_COMMIT] {
|
||||
let (_dir, kernel) = kernel_of(intent_of(catalog(), commit));
|
||||
let kernel = kernel.unwrap();
|
||||
let evidence: BTreeSet<String> = kernel
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| match &fact.standing {
|
||||
FactStanding::Canonical { evidence } => evidence.text().to_owned(),
|
||||
other => panic!("a verse fact is canonical, not {other:?}"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
evidence,
|
||||
catalog()
|
||||
.factions
|
||||
.iter()
|
||||
.map(|faction| format!(
|
||||
"aetheria:{commit}:aetheria.faction/{}",
|
||||
faction.key.record()
|
||||
))
|
||||
.collect::<BTreeSet<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_is_forty_lower_case_hex_digits() {
|
||||
for bad in [
|
||||
"",
|
||||
&COMMIT[..39],
|
||||
&format!("{COMMIT}0"),
|
||||
&COMMIT.to_uppercase(),
|
||||
&format!("{}g", &COMMIT[..39]),
|
||||
] {
|
||||
assert!(
|
||||
matches!(
|
||||
intent_of(catalog(), bad).lower(owner()),
|
||||
Err(VerseGenesisError::NotACommit)
|
||||
),
|
||||
"{bad:?}"
|
||||
);
|
||||
}
|
||||
assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok());
|
||||
assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verse_genesis_refuses_a_duplicate_faction_record() {
|
||||
let mut twice = catalog();
|
||||
let first = twice.factions[0].clone();
|
||||
twice.factions.push(first);
|
||||
// The lowering does not decide: it declares all three.
|
||||
let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap();
|
||||
let subjects = world
|
||||
.patch
|
||||
.declarations
|
||||
.iter()
|
||||
.filter(|declaration| matches!(declaration, Declaration::Subject(_)))
|
||||
.count();
|
||||
assert_eq!(subjects, 3);
|
||||
|
||||
let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a second faction under one record key was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_granted_affordance_is_a_grammar_verb() {
|
||||
let catalog = catalog();
|
||||
let verse = verse(intent_of(catalog.clone(), COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
|
||||
let mut kinds: Vec<_> = snapshot
|
||||
.affordances
|
||||
.iter()
|
||||
.map(|affordance| affordance.entry.kind.0.clone())
|
||||
.collect();
|
||||
kinds.sort();
|
||||
// Each verb once: the kernel's speak is the grammar's speak.
|
||||
assert_eq!(
|
||||
kinds,
|
||||
catalog
|
||||
.grammar
|
||||
.verbs()
|
||||
.map(|(kind, _)| kind.to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
assert!(kinds.contains(&"speak".to_owned()));
|
||||
|
||||
// Each has its render path from the grammar, and phase A changes nothing.
|
||||
for affordance in &snapshot.affordances {
|
||||
let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap();
|
||||
assert_eq!(affordance.render_path, Some(verb.render_path));
|
||||
assert!(affordance.entry.effect_slots.is_empty());
|
||||
assert_eq!(affordance.entry.outcome_bands.len(), 1);
|
||||
assert!(affordance.entry.outcome_bands[0].effects.is_empty());
|
||||
}
|
||||
|
||||
// Every faction is granted every verb.
|
||||
let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect();
|
||||
for subject in &snapshot.subjects {
|
||||
assert_eq!(subject.affordances, all);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn factions_get_opportunities() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let revision = verse.mailbox.snapshot().await.unwrap().revision;
|
||||
verse
|
||||
.mailbox
|
||||
.submit_principal(
|
||||
PrincipalCommandIntent {
|
||||
id: CommandId::new(),
|
||||
world_id: verse.receipt.world_id,
|
||||
expected_revision: revision,
|
||||
body,
|
||||
},
|
||||
&verse.principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.phase, WorldPhase::Active);
|
||||
assert_eq!(snapshot.opportunities.len(), catalog().factions.len());
|
||||
let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect();
|
||||
assert_eq!(scopes.len(), snapshot.opportunities.len());
|
||||
for opportunity in &snapshot.opportunities {
|
||||
assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent);
|
||||
assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_faction_without_a_description_holds_no_fact() {
|
||||
let mut bare = catalog();
|
||||
bare.factions[0].description = " ".into();
|
||||
bare.factions[1].description = " Second. ".into();
|
||||
let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT));
|
||||
let facts: Vec<_> = kernel
|
||||
.unwrap()
|
||||
.state
|
||||
.facts
|
||||
.values()
|
||||
.map(|fact| fact.statement.as_str().to_owned())
|
||||
.collect();
|
||||
assert_eq!(facts, vec!["Second.".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() {
|
||||
let mut mute = catalog();
|
||||
mute.grammar = GrammarBinding::new(
|
||||
"aetheria.verse_grammar",
|
||||
1,
|
||||
[(
|
||||
"haul".to_owned(),
|
||||
GrammarVerb {
|
||||
render_path: RenderPath::ShipAction,
|
||||
roles: vec![GrammarRole {
|
||||
role: "cargo".into(),
|
||||
referent: GrammarReferentKind::Cargo,
|
||||
}],
|
||||
},
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT));
|
||||
let Err(KernelError::PatchRejected(set)) = kernel else {
|
||||
panic!("a world whose grammar has no speak was admitted");
|
||||
};
|
||||
assert!(
|
||||
set.iter()
|
||||
.any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })),
|
||||
"{set:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_dungeon_ingress_still_creates_an_unbound_world() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1));
|
||||
mailbox
|
||||
.create(
|
||||
crate::CreateWorldIntent {
|
||||
id: CommandId::new(),
|
||||
title: "Dungeon".into(),
|
||||
brief: String::new(),
|
||||
human_subject_label: "Player".into(),
|
||||
narrative_persona_label: None,
|
||||
operational_agent_label: None,
|
||||
targets: BTreeMap::new(),
|
||||
jurisdictions: Vec::new(),
|
||||
lens_weights: stock_weights(),
|
||||
},
|
||||
&principal,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let snapshot = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(snapshot.grammar, None);
|
||||
assert!(
|
||||
snapshot
|
||||
.places
|
||||
.iter()
|
||||
.any(|place| place.label == "The Commons")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_faction_name_is_trimmed_into_its_institution_label() {
|
||||
let mut padded = catalog();
|
||||
let expected = padded.factions[0].name.trim().to_owned();
|
||||
padded.factions[0].name = format!(" {expected}\t ");
|
||||
let key = padded.factions[0].key.clone();
|
||||
let verse = verse(intent_of(padded, COMMIT)).await;
|
||||
let snapshot = verse.mailbox.snapshot().await.unwrap();
|
||||
let subject = snapshot
|
||||
.subjects
|
||||
.iter()
|
||||
.find(|subject| subject.import_key.as_ref() == Some(&key))
|
||||
.expect("the padded faction was created");
|
||||
assert_eq!(subject.label, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_twice_with_one_intent_is_one_creation() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap();
|
||||
let principal =
|
||||
VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1));
|
||||
let intent = intent_of(catalog(), COMMIT);
|
||||
let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap();
|
||||
let before = mailbox.snapshot().await.unwrap();
|
||||
let again = mailbox.create_verse(intent, &principal).await.unwrap();
|
||||
let after = mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(again, first);
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
assert_eq!(after.subjects.len(), before.subjects.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn genesis_under_another_command_id_finds_the_world_occupied() {
|
||||
let verse = verse(intent_of(catalog(), COMMIT)).await;
|
||||
let before = verse.mailbox.snapshot().await.unwrap();
|
||||
let refused = verse
|
||||
.mailbox
|
||||
.create_verse(intent_of(catalog(), COMMIT), &verse.principal)
|
||||
.await;
|
||||
assert!(
|
||||
matches!(
|
||||
refused,
|
||||
Err(VerseGenesisError::Mailbox(MailboxError::Kernel(
|
||||
KernelError::CreationTargetOccupied
|
||||
)))
|
||||
),
|
||||
"{refused:?}"
|
||||
);
|
||||
let after = verse.mailbox.snapshot().await.unwrap();
|
||||
assert_eq!(after.revision, before.revision);
|
||||
assert_eq!(after.state_digest, before.state_digest);
|
||||
}
|
||||
|
||||
/// Pins the standing phase A behaviour (ruling `ship-acts-speech-shaped-phase-a`):
|
||||
/// the lowering admits a ship verb only as an utterance,
|
||||
/// and committing it asserts a claim fact the room hears.
|
||||
#[test]
|
||||
fn phase_a_ship_verbs_are_spoken_acts_in_the_room() {
|
||||
use crate::tests::{command, opportunity_for, spoken, submit_owner};
|
||||
use crate::{
|
||||
AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt,
|
||||
Target,
|
||||
};
|
||||
|
||||
let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT));
|
||||
let mut kernel = kernel.unwrap();
|
||||
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
|
||||
let snapshot = kernel.snapshot().unwrap();
|
||||
submit_owner(&mut kernel, &snapshot, body);
|
||||
}
|
||||
let active = kernel.snapshot().unwrap();
|
||||
let attack = active
|
||||
.affordances
|
||||
.iter()
|
||||
.find(|a| a.entry.kind.0 == "attack")
|
||||
.unwrap()
|
||||
.id;
|
||||
let [first, second] = [&active.subjects[0], &active.subjects[1]];
|
||||
let opportunity = opportunity_for(&active, first.id);
|
||||
let caller = CallerId::Controller(opportunity.controller_id);
|
||||
let invocation = |speech: Option<&str>| DecisionInvocation {
|
||||
affordance: attack,
|
||||
bindings: vec![RoleBinding {
|
||||
role: Role("target".into()),
|
||||
target: Target::Subject(second.id),
|
||||
}],
|
||||
proposed: Vec::new(),
|
||||
speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()),
|
||||
display: None,
|
||||
};
|
||||
let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| {
|
||||
kernel.submit(
|
||||
command(
|
||||
&active,
|
||||
CommandId::new(),
|
||||
caller.clone(),
|
||||
CommandBody::ExerciseDecision {
|
||||
opportunity: opportunity.clone(),
|
||||
invocation: invocation(speech),
|
||||
},
|
||||
),
|
||||
&AuthenticatedCaller::fixture(caller.clone()),
|
||||
)
|
||||
};
|
||||
|
||||
// Without an utterance the act is refused: the verb has no other way in.
|
||||
assert!(try_it(&mut kernel, None).is_err());
|
||||
// With one, the act commits and its words are a claim fact.
|
||||
let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits");
|
||||
assert!(matches!(receipt, SubmitReceipt::Applied(_)));
|
||||
let event = kernel.state.events.last().unwrap();
|
||||
assert_eq!(spoken(&kernel.state, event), Some("We strike."));
|
||||
}
|
||||
19
crates/ghostlight/src/verse/mod.rs
Normal file
19
crates/ghostlight/src/verse/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! The Aetheria verse run's own organs. The verse binary sits above this
|
||||
//! module; here sit the parts of a run that belong to the library: the record
|
||||
//! of every provider call and the spend it implies, the genesis of the
|
||||
//! canonical verse world, and the manual run that advances it with its record.
|
||||
|
||||
mod calls;
|
||||
mod genesis;
|
||||
mod run;
|
||||
|
||||
pub use genesis::{CreateVerseWorldIntent, VerseGenesisError};
|
||||
pub use calls::{
|
||||
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord,
|
||||
RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart,
|
||||
};
|
||||
pub use run::{
|
||||
RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus,
|
||||
TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
|
||||
call_run_id, run_manual,
|
||||
};
|
||||
1165
crates/ghostlight/src/verse/run.rs
Normal file
1165
crates/ghostlight/src/verse/run.rs
Normal file
File diff suppressed because it is too large
Load diff
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
2783
crates/ghostlight/src/verse/run/tests.rs
Normal file
File diff suppressed because it is too large
Load diff
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc
vendored
Normal file
Binary file not shown.
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
16
crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt
vendored
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight.
|
||||
|
||||
Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test
|
||||
Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs),
|
||||
which authors VerseGrammar, three VerseVerb and two Faction records through
|
||||
Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH.
|
||||
Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc
|
||||
(contains aetheria-release cut verse-grammar, merged at 35af2930).
|
||||
Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc
|
||||
dotnet test tests/Aetheria.Shared.Tests
|
||||
-p:CultLibRoot=<CultLib 45c2f40 clone> -p:CultMathRoot=<CultLib 252e226c clone>
|
||||
--filter "FullyQualifiedName~Fixture_writer_is_deterministic"
|
||||
run on Yggdrasil through the verify stopgap (dotnet/sdk:10.0), 2026-10-10.
|
||||
Content: six records; two writes of the writer agree on content, though not bytes
|
||||
(CultCache stamps storedAt from the wall clock). Regenerate and diff on content,
|
||||
never hand-edit.
|
||||
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
BIN
crates/ghostlight/tests/fixtures/base_world.cc
vendored
Normal file
Binary file not shown.
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
3
crates/ghostlight/tests/fixtures/base_world.digests
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
3
|
||||
sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d
|
||||
sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979
|
||||
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
1
crates/ghostlight/tests/fixtures/base_world.id
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"a534b32a-4d00-429c-bf6c-ce1d55385380"
|
||||
|
|
@ -1,188 +0,0 @@
|
|||
schema = "gamecult.idunn.target_declaration.v1"
|
||||
target = "ghostlight"
|
||||
source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT"
|
||||
required_gitlinks = ["vendor/eve"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-persona-boundary"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-world-library"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-dungeon"
|
||||
phase = "test"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-daemon"
|
||||
phase = "build"
|
||||
runner = "rust-build"
|
||||
required_environment = ["CARGO_HOME", "HOME"]
|
||||
argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"]
|
||||
|
||||
[[steps]]
|
||||
id = "install-web-dependencies"
|
||||
phase = "prepare"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "ci"]
|
||||
|
||||
[[steps]]
|
||||
id = "test-web"
|
||||
phase = "test"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "test"]
|
||||
|
||||
[[steps]]
|
||||
id = "build-web"
|
||||
phase = "build"
|
||||
runner = "web-build"
|
||||
required_environment = ["HOME", "NPM_CONFIG_CACHE"]
|
||||
argv = ["npm", "--prefix", "web", "run", "build"]
|
||||
|
||||
[[steps]]
|
||||
id = "accept-real-persona-membrane"
|
||||
phase = "acceptance"
|
||||
runner = "rust-acceptance"
|
||||
argv = [
|
||||
"cargo",
|
||||
"test",
|
||||
"--locked",
|
||||
"-p",
|
||||
"ghostlight",
|
||||
"--lib",
|
||||
"controllers::tests::real_local_model_cognition_modes_commit_speech",
|
||||
"--",
|
||||
"--ignored",
|
||||
"--exact",
|
||||
]
|
||||
required_environment = [
|
||||
"CARGO_HOME",
|
||||
"GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG",
|
||||
"GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"HOME",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
]
|
||||
|
||||
[[artifacts]]
|
||||
id = "ghostlight-dungeon"
|
||||
source_kind = "runner-output"
|
||||
runner = "rust-build"
|
||||
source = "target/release/ghostlight-dungeon"
|
||||
destination = "ghostlight-dungeon"
|
||||
executable = true
|
||||
|
||||
[[artifacts]]
|
||||
id = "web"
|
||||
source_kind = "runner-output"
|
||||
runner = "web-build"
|
||||
source = "web/dist"
|
||||
destination = "web"
|
||||
|
||||
[service]
|
||||
executable_artifact = "ghostlight-dungeon"
|
||||
required_adjacent_artifacts = ["web"]
|
||||
arguments = [
|
||||
{ kind = "literal", value = "--state-root" },
|
||||
{ kind = "binding", name = "state_root" },
|
||||
]
|
||||
transport = "http"
|
||||
route_required = true
|
||||
required_environment = [
|
||||
"GAMECULT_IDUNN_CANDIDATE_BIND",
|
||||
"GAMECULT_IDUNN_PROCESS_WRITE_LEASE",
|
||||
"GAMECULT_IDUNN_RUNTIME_BUNDLE",
|
||||
"GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PERSONA_MODEL",
|
||||
"GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL",
|
||||
"GHOSTLIGHT_DISCORD_GUILD_ID",
|
||||
"GHOSTLIGHT_DISCORD_ROLE_ID",
|
||||
"GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE",
|
||||
"GHOSTLIGHT_HEIMDALL_BASE_URL",
|
||||
"GHOSTLIGHT_LOCAL_ENDPOINT",
|
||||
"GHOSTLIGHT_LOCATED_SERVICE",
|
||||
"GHOSTLIGHT_ODIN_RUDP",
|
||||
"GHOSTLIGHT_PLAY_MODEL",
|
||||
"GHOSTLIGHT_PUBLIC_APP_URL",
|
||||
"GHOSTLIGHT_SERVICE_ID",
|
||||
"GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE",
|
||||
]
|
||||
optional_environment = ["RUST_LOG"]
|
||||
|
||||
[service.health]
|
||||
contract = "ghostlight.cultnet-rudp-service-health"
|
||||
|
||||
[state]
|
||||
schema_generation = "world-v3"
|
||||
|
||||
[[state.slots]]
|
||||
id = "world"
|
||||
relative_path = "world.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.world_state.consumer.v6"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "app-sessions"
|
||||
relative_path = "service/app-sessions-v2.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.app_session_store.v2"
|
||||
writer = "process-bound-single-writer"
|
||||
recovery = "preserve"
|
||||
startup = "create-or-open-after-write-lease"
|
||||
|
||||
[[state.slots]]
|
||||
id = "controller-work"
|
||||
relative_path = "service/controller-work.cc"
|
||||
kind = "cultcache-file"
|
||||
schema = "ghostlight.controller_work.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"
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# Ghostlight authority architecture
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
This document is the adopted rebuild target for the Ghostlight runtime. It is
|
||||
|
|
@ -624,9 +626,11 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's
|
|||
runs `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
against a real model; exercising a whole play turn against one is not gated
|
||||
by this contract.
|
||||
`world.create`'s current payload is `world_create.v4`: title, brief, targets,
|
||||
jurisdiction roots and lens weights are required, and Dungeon's create surface
|
||||
offers uniform weights as its editable default. `world.seed` runs one
|
||||
`world.begin` is the one route that makes a world. Its payload is
|
||||
`world_begin.v1`: title, brief and subject_label, nothing else; the route
|
||||
decides everything else a world is born with (no targets, no roots, no persona
|
||||
or agent label, uniform lens weights) and `begin_plan` decides how far Begin
|
||||
goes. `world.seed` runs one
|
||||
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
|
||||
|
||||
Focused tests must prove:
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
# Ghostlight Eve-native interface
|
||||
|
||||
## Objective
|
||||
|
||||
Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the
|
||||
provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`).
|
||||
Eve owns editable bindings, command invocation, receipts, plugin composition,
|
||||
and browser lowering. Ghostlight owns world creation, seeding, activation, play,
|
||||
and world admission; Heimdall owns authentication and entitlement decisions.
|
||||
|
||||
```text
|
||||
Ghostlight projection (eve.rs)
|
||||
-> EveBrowserProviderHost
|
||||
-> canonical Eve lowering
|
||||
-> gamecult.eve.command_invocation.v1
|
||||
-> Ghostlight command ingress (runtime.rs) or Heimdall private command plane
|
||||
-> gamecult.eve.command_result.v1
|
||||
-> authoritative surface refresh
|
||||
```
|
||||
|
||||
## Authority map
|
||||
|
||||
- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort`
|
||||
and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's
|
||||
app-session owner (`app_session.rs`) binds a verified Heimdall subject to a
|
||||
local cookie.
|
||||
- Inputs: the projector reads only the caller's app session, the world
|
||||
snapshot, and the caller's play-turn view. Command ingress reads one canonical
|
||||
Eve invocation and derives the principal server-side.
|
||||
- Outputs: one `gamecult.eve.surface.v1` document and one
|
||||
`gamecult.eve.command_receipt.v1` inside a command result.
|
||||
- Derived state: browser drafts, focus, command status, and rendered HTML are
|
||||
projections or local interaction state. None grants access or mutates
|
||||
fiction.
|
||||
- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the
|
||||
SSE stream cannot choose a principal or commit world state.
|
||||
- Shared path: every operation resolves the authenticated account, then calls
|
||||
the world mailbox, the seed port, or the play port.
|
||||
SSE carries invalidation only and the host refetches the same surface.
|
||||
|
||||
## The surface
|
||||
|
||||
`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller
|
||||
and world phase (`anonymous_surface`, `authenticated_surface` in
|
||||
`crates/ghostlight-dungeon/src/eve.rs`):
|
||||
|
||||
| Caller and world | Content | Operations |
|
||||
|---|---|---|
|
||||
| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` |
|
||||
| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) |
|
||||
| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) |
|
||||
| Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) |
|
||||
| Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) |
|
||||
|
||||
`world.play` is the only human play action. The open question's identity rides
|
||||
the play button's own action as an opaque token, never as an editable field.
|
||||
`operation_schema` in `eve.rs` is the single table from operation id to schema.
|
||||
|
||||
## Editable values
|
||||
|
||||
Eve bindings are the input model. A component binds a typed value by stable
|
||||
binding name, document identity, field path, value kind, and access mode.
|
||||
Renderer-local composers use `local-draft`. An operation captures one or more
|
||||
named binding values atomically.
|
||||
|
||||
The browser lowerer may use an HTML form for keyboard and accessibility
|
||||
behavior. HTML form structure never enters Eve state or command payload
|
||||
semantics. Accepted receipts may clear named drafts; rejection and stale
|
||||
conflict preserve them. An omitted clear-binding list means clear the surface's
|
||||
drafts; an explicit empty list means clear nothing. Ghostlight validates every
|
||||
payload at ingress regardless of what the control advertises.
|
||||
|
||||
## Authentication membrane
|
||||
|
||||
Anonymous projection contains only `heimdall.access_gate`. Its begin and
|
||||
complete operations cross Heimdall's encrypted, loopback-only CultNet boundary
|
||||
(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary`
|
||||
record from Odin, validates it, and invokes the discovered route. Odin does not
|
||||
proxy the command and never receives claims or completion payloads. The browser
|
||||
retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`).
|
||||
Ghostlight redeems the completion, validates the access claim, and creates a
|
||||
local HttpOnly session cookie (`ghostlight_session`).
|
||||
|
||||
Routine requests use local verified session state, persisted in
|
||||
`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall
|
||||
session and `access_revision`, expiries, and a wrapped refresh claim.
|
||||
`access_revision` is Heimdall's revocation epoch.
|
||||
|
||||
## Public boundary
|
||||
|
||||
- `GET /api/eve/provider`
|
||||
- `GET /api/eve/surfaces/{surface_id}`
|
||||
- `POST /api/eve/commands`
|
||||
- `GET /api/eve/events`
|
||||
- `GET /health`, a probe of the service's typed CultMesh health
|
||||
- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback
|
||||
CultNet doors (see `ghostlight-world-consumer-api.md` for the second)
|
||||
|
||||
Static assets in `web/` contain only the Eve provider host, the transport, the
|
||||
Heimdall adapter, and the SSE invalidation hook.
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
# Ghostlight Dungeon play agent (plan step 16)
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
## Status
|
||||
|
||||
Target, adopted 2026-09-22. Not implemented. The means will be in
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# Ghostlight Dungeon Session Zero
|
||||
|
||||
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
|
||||
|
||||
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
|
||||
playtest gate is superseded (operator, 2026-09-22) by the play agent,
|
||||
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes
|
||||
|
|
|
|||
|
|
@ -63,6 +63,11 @@ attributed proposal — is not part of this pass; see "Not in this pass" below.
|
|||
|
||||
## Transport
|
||||
|
||||
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
|
||||
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
|
||||
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
|
||||
serves.
|
||||
|
||||
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
|
||||
`api_router` (`runtime.rs`). The handler gates loopback and content type
|
||||
(`application/msgpack`) exactly as the snapshot route does, then hands the
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ provider request is a distinct round (`interpreter_round`) with its own
|
|||
content-addressed request id, so it cannot collide with the first lowering's
|
||||
request. State schema is `ghostlight.world_state.consumer.v6`, commit schema
|
||||
`ghostlight.world_commit.consumer.v6`, controller work
|
||||
`ghostlight.controller_work.v17`, Persona turn receipt
|
||||
`ghostlight.controller_work.v18`, Persona turn receipt
|
||||
`ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are
|
||||
refused. Ghostlight owns a conserved narrative ledger;
|
||||
Delvehold owns the economy (`delvehold-forced-ontology-integration.md`).
|
||||
|
|
|
|||
|
|
@ -2,25 +2,21 @@
|
|||
|
||||
This is a pointer. It does not carry open work; the Eureka mind does.
|
||||
|
||||
## What runs
|
||||
## What exists
|
||||
|
||||
Ghostlight Dungeon runs on Yggdrasil under Idunn at
|
||||
`https://yggdrasil.gamecult.org/ghostlight/` (runtime `ghostlight-yggdrasil`,
|
||||
target `ghostlight`, ref `main`, state root `/var/lib/gamecult/ghostlight-world-v2`).
|
||||
`/health` names the commit it serves (`crates/ghostlight-dungeon/src/runtime.rs`).
|
||||
The binary is `ghostlight-dungeon`; its inference reaches Bonsai on Raven
|
||||
through a loopback tunnel, and sign-in goes through Heimdall, discovered via
|
||||
Odin.
|
||||
Nothing is deployed. The Dungeon (daemon, play table, Eve surface, browser host,
|
||||
`vendor/eve`, Idunn recipe) is parked at the tag `parked/ghostlight-eve-surface`
|
||||
and is not on main. A Thing 2 rebuild starts from that tag.
|
||||
|
||||
## How it deploys
|
||||
What is in the tree is the kernel (`crates/ghostlight`), the persona projection
|
||||
(`crates/ghostlight-persona-projection`) and the verse runner
|
||||
(`crates/ghostlight-verse`). `cargo test` runs them on Linux; use the
|
||||
`ygg-verify` stopgap on Yggdrasil, not the workstation, and say which host
|
||||
you used.
|
||||
|
||||
Idunn is the only deploy path (`idunn up ghostlight`). The recipe is
|
||||
`deployment/idunn/recipe.toml`; the runbook is
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. The Ghostlight daemon
|
||||
cannot start without Odin: its Warming presence and process write lease go
|
||||
through Odin. Preflight Odin's route health before a deploy. Do not invoke the
|
||||
retired v1 actuator (`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`), its
|
||||
wiring test, or `ghostlight-dungeon.service`.
|
||||
The Idunn target `ghostlight` is braked and stopped, not retired. Its state is in
|
||||
`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. Do not run
|
||||
`idunn up ghostlight`, the v1 actuator, or `ghostlight-dungeon.service`.
|
||||
|
||||
## Where open work is
|
||||
|
||||
|
|
@ -35,9 +31,10 @@ target and probed body facts are `notes/ghostlight-verse-target.md` and
|
|||
|
||||
1. `README.md`: what exists and what does not.
|
||||
2. `AGENTS.md`: invariants and where things are.
|
||||
3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture.
|
||||
4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
|
||||
3. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
|
||||
current mechanism.
|
||||
4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority
|
||||
architecture. History until Thing 2 revives it.
|
||||
5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play
|
||||
agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216).
|
||||
It is history; the mind says which findings are still open.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# Ghostlight Verse: Map
|
||||
|
||||
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2`
|
||||
> Status: Dungeon, eve.rs and the bridge-test references below are parked at tag `parked/ghostlight-eve-surface`; line refs are to that tag.
|
||||
|
||||
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3`
|
||||
(`notes/ghostlight-verse-target.md`). This page holds body facts, the model
|
||||
page and rationale only. Cut specs, questions, rulings and follow-ups live in
|
||||
the Eureka mind; query them there, never copy them here.
|
||||
|
|
@ -9,6 +11,15 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09.
|
|||
Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`.
|
||||
Times are UTC unless marked local (UTC+2).
|
||||
|
||||
## Cut sequence
|
||||
|
||||
Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it.
|
||||
|
||||
- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key).
|
||||
- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key).
|
||||
- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain).
|
||||
- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run).
|
||||
|
||||
## Body facts (probed 2026-10-09)
|
||||
|
||||
### Does main run on Yggdrasil?
|
||||
|
|
@ -57,7 +68,7 @@ Times are UTC unless marked local (UTC+2).
|
|||
|
||||
### Does main build and pass?
|
||||
|
||||
Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised:
|
||||
Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised:
|
||||
|
||||
- Rust (image `eureka-verify-rust`), the recipe's own steps:
|
||||
`ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed,
|
||||
|
|
@ -148,7 +159,15 @@ from 21:04 the denial forever. Mechanism:
|
|||
that key can ever succeed, and `sessions_due_for_refresh`
|
||||
(`app_session.rs:247-268`) keeps offering the same candidate while
|
||||
`refresh_expires_at > now`. Which clause of `app_session.rs:293-299` fired at
|
||||
21:03 is not logged.
|
||||
21:03 is not logged, but only one is reachable: the session-id and account
|
||||
clauses are pre-checked and revoked at `runtime.rs:1904-1916`, a revoked
|
||||
session is never offered, and nothing else moves `access_revision`. That
|
||||
leaves `input.access_revision() <= expected` (`app_session.rs:295`). Heimdall's
|
||||
refresh reissues the claim with the refresh claim's `access_revision`
|
||||
unchanged (`Heimdall/src/app.ts:716-724`; only logout bumps it,
|
||||
`private-command-plane.ts:197`), so every real refresh is executed upstream
|
||||
and refused locally, and the key `refresh:{sid}:{rev}` never changes within a
|
||||
session. Question `ghostlight-verse:question:refresh-revision-contract`.
|
||||
|
||||
### The host
|
||||
|
||||
|
|
@ -194,6 +213,67 @@ from 21:04 the denial forever. Mechanism:
|
|||
code is flat under `crates/ghostlight/src/`. Dated cut documents and
|
||||
postmortems keep their paths: they are history.
|
||||
|
||||
### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`)
|
||||
|
||||
- Three paths answer the same fact (the subject still holds one opportunity on
|
||||
the bound scope, at a new digest), with two errors. A probe test run on
|
||||
Yggdrasil returned these, with one provider call in total:
|
||||
- The scope moves during round 0 of a fresh run. Round 1's
|
||||
`ensure_scope_unchanged` returns `NoOpportunity`.
|
||||
- A resume passing the row's own stale opportunity returns `NoOpportunity`
|
||||
(same gate).
|
||||
- A resume passing the fresh opportunity returns `OpportunityMismatch`, from
|
||||
the digest clause of `row_answers_caller` at lookup.
|
||||
- The driver's `classify` (`verse/run.rs` 641-651) labels the first two
|
||||
`OpportunityGone` and the third `ScopeMoved`. That makes the first two
|
||||
mislabels: the label's doc says `OpportunityGone` means the subject holds no
|
||||
opportunity. Cause: when `select_scope`'s digest clause empties the match
|
||||
list, it answers `NoOpportunity` (`controllers.rs` ~1552).
|
||||
- In the narrative lane, the lookup digest clause is the only gate. A driver
|
||||
resume with the fresh opportunity is refused there. Fork A's kernel-refusal
|
||||
re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity.
|
||||
- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now
|
||||
returns `ScopeMoved`): `run_group` maps every `select_one` error to
|
||||
`LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member
|
||||
whose scope moved is dropped before any constituent, inference or submission
|
||||
exists, so only the label is wrong; spend is identical and nothing branches on
|
||||
the reason (`grouped-record-and-carry` keys on membership in `refused`). Own
|
||||
cut: `cut-grouped-scope-moved-label.r1`.
|
||||
|
||||
### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`)
|
||||
|
||||
- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed
|
||||
and CustodyUncertain arms (`controllers.rs` 3144, 3152) call
|
||||
`row_answers_caller`, the only callers it has since `in-flight-gate`
|
||||
(`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller
|
||||
passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads
|
||||
the world (source read: `self.select` is called only at fresh run, round
|
||||
checks of the operational lane, and the other lanes' fresh paths).
|
||||
- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged`
|
||||
arm calls `interrupted`, which re-lowers once. A second move reads
|
||||
`interrupted_from.is_some()` and ends the turn `overtaken`, with nothing
|
||||
submitted and no further inference. The progression arm (~2375) refuses to
|
||||
persist a second re-lowering even if the runner tried. Pinned by
|
||||
`a_second_scope_change_after_the_re_lowering_spends_nothing`.
|
||||
- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job;
|
||||
script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup
|
||||
arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved`
|
||||
to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803
|
||||
passed, 3 failed. The failures were
|
||||
`a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference`
|
||||
(the resumed row now called the provider and ran the test port dry),
|
||||
`a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and
|
||||
`an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No
|
||||
verse driver test reached `ScopeMoved` through `OpportunityMismatch`.
|
||||
- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut
|
||||
it arises from `same_decision` (another decision), `select_scope`'s
|
||||
controller-id check, and the Persona lane's forged-field refusals. The driver
|
||||
cannot reach a `same_decision` mismatch, because `run_cell` derives the
|
||||
command id from world, cell, subject and tick.
|
||||
- **Dead code.** Both `awaits_world` methods have no production caller once
|
||||
`row_answers_caller` goes. The operational one has been test-only since
|
||||
`771a79d`. The only other reader is the `NeverSentStore` test fixture.
|
||||
|
||||
## Model page
|
||||
|
||||
One row per persistent kind phase 1 touches. No cell may be empty.
|
||||
|
|
@ -208,6 +288,7 @@ One row per persistent kind phase 1 touches. No cell may be empty.
|
|||
| v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling |
|
||||
| Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question |
|
||||
| Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems |
|
||||
| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone |
|
||||
|
||||
### Where phase 2's verb sets plug in
|
||||
|
||||
|
|
@ -256,3 +337,16 @@ verbs as the target.
|
|||
Dungeon tests that drive the real client, and nothing runs them. Fixing the
|
||||
gate means a runner with both Rust and Node, which is Idunn's recipe and
|
||||
runner surface: a follow-up, with each phase-1 Hands run doing them by hand.
|
||||
- **A narrative resume has one detector, and it is the kernel** (ruling
|
||||
`narrative-resume-relowers`). The caller's opportunity names the decision
|
||||
and nothing more, in both lanes, through the one `same_decision`. The lanes
|
||||
differ only in when the world is asked. The operational lane asks before each
|
||||
round, because it has nothing to preserve. The narrative lane asks at submit,
|
||||
because its prose and receipts survive to be lowered again. The price is the
|
||||
remaining rounds plus at most one Interpreter call. `OpportunityMismatch`
|
||||
then stops meaning a moved scope, so `classify` labels it `OpportunityGone`,
|
||||
as the grouped lane already does. Keeping it on `ScopeMoved` would leave a
|
||||
second, wrong answer to "did the scope move". The grouped selection label
|
||||
becomes a pure function, because `AmbiguousOpportunity` cannot be built
|
||||
through a live world, which offers one opportunity per scope. Pinning the
|
||||
function is what closes follow-up `grouped-left-arms-pin`.
|
||||
|
|
|
|||
|
|
@ -5,8 +5,14 @@ No live smoke exists in this tree. The ignored test this file once documented,
|
|||
ran the production tick driver and the Active elaboration sweep against a real
|
||||
CodexConnector; the play agent's Cut 1 (`d69e9d4`) deleted both, and the test
|
||||
with them. A command that names it now matches nothing and exits 0. No
|
||||
replacement exists. The real-model gate that does exist is the Idunn recipe's
|
||||
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`).
|
||||
replacement exists. The recipe that once gated a deploy on a real model is
|
||||
gone from main, and so is its `accept-real-persona-membrane` step. The one real-model test
|
||||
left is the ignored `controllers::tests::real_local_model_cognition_modes_commit_speech`
|
||||
(`crates/ghostlight/src/controllers.rs`). It has no automated run: it needs a model server
|
||||
on `GHOSTLIGHT_LOCAL_ENDPOINT` plus the `GHOSTLIGHT_CONTROLLER_*_MODEL`,
|
||||
`GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID` and `GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG`
|
||||
variables, and a person runs it by hand with `cargo test -p ghostlight -- --ignored
|
||||
real_local_model_cognition_modes_commit_speech`.
|
||||
|
||||
What remains true and stays here: bringing up a real CodexConnector and a
|
||||
built Claude SDK sidecar. The seed lane (`world.seed`) still runs real
|
||||
|
|
|
|||
|
|
@ -2,11 +2,8 @@
|
|||
"name": "ghostlight",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.",
|
||||
"description": "Ghostlight: the Rust kernel for socially situated agents.",
|
||||
"scripts": {
|
||||
"dungeon:build": "cargo build --workspace && npm --prefix web run build",
|
||||
"dungeon:test": "cargo test --workspace && npm --prefix web run build",
|
||||
"dungeon:run": "cargo run -p ghostlight-dungeon",
|
||||
"sdk:build": "npm --prefix sidecar/claude-sdk run build",
|
||||
"sdk:test": "npm --prefix sidecar/claude-sdk test"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,117 +0,0 @@
|
|||
// Cut 10 (PA.f149): drives the real vendored Eve browser lowering
|
||||
// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a
|
||||
// surface document Dungeon actually served, so the command intent this
|
||||
// prints is what a real client sends -- never a hand-built payload asserting
|
||||
// its own shape is correct.
|
||||
//
|
||||
// stdin: {"surface": <the surface document>, "steps": [{"set": {node_id: text}}, {"click": node_id}]}
|
||||
// stdout: one JSON line per intent the click(s) produced, in order.
|
||||
//
|
||||
// `set` types into a control by its Eve node id (the same id
|
||||
// `eve.rs`'s own `json!({"id": ...})` calls carry), by finding the rendered
|
||||
// element via `data-eve-node-id` (assignId) and dispatching a real `input`
|
||||
// event, exactly as a user's keystroke would. `click` clicks a
|
||||
// `control.button` node the same way. jsdom is resolved from the vendored
|
||||
// package's own devDependencies (its own test suite uses the identical
|
||||
// pattern in `test/host-isolation.test.mjs`) rather than adding a second copy
|
||||
// to this repo.
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const loweringRoot = join(here, "..", "vendor", "eve", "packages", "eve-browser-lowering");
|
||||
const vendorRequire = createRequire(join(loweringRoot, "package.json"));
|
||||
const { JSDOM } = vendorRequire("jsdom");
|
||||
const { EveBrowserDraftStore, renderEveSurface } = await import(
|
||||
"file://" + join(loweringRoot, "dist", "index.js").replace(/\\/g, "/")
|
||||
);
|
||||
|
||||
function installDom() {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id='host'></div></body></html>", {
|
||||
url: "https://eve.bridge.test/",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const names = [
|
||||
"document", "window", "Element", "HTMLElement", "HTMLButtonElement",
|
||||
"HTMLInputElement", "HTMLSelectElement", "HTMLOptionElement",
|
||||
"HTMLTextAreaElement", "HTMLImageElement", "Event", "CustomEvent",
|
||||
];
|
||||
for (const name of names) globalThis[name] = dom.window[name];
|
||||
return dom;
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
return value.replace(/[^a-zA-Z0-9_-]/g, char => `\\${char}`);
|
||||
}
|
||||
|
||||
async function readStdin() {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", chunk => { data += chunk; });
|
||||
process.stdin.on("end", () => resolve(data));
|
||||
process.stdin.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { surface, steps, provider } = JSON.parse(await readStdin());
|
||||
|
||||
const dom = installDom();
|
||||
const host = dom.window.document.getElementById("host");
|
||||
const intents = [];
|
||||
const draftStore = new EveBrowserDraftStore();
|
||||
const options = {
|
||||
draftStore,
|
||||
clientId: "eve.bridge.test",
|
||||
commandSink: intent => { intents.push(intent); },
|
||||
// `commandBoundary`/`receiptSchema` are not carried on the surface
|
||||
// document itself -- a real client learns them from the provider's own
|
||||
// CultMesh advertisement (`mesh.rs::provider_advertisement`,
|
||||
// `surfaces[].worldInteraction`), fetched separately. The caller passes
|
||||
// that same shape through here.
|
||||
provider,
|
||||
};
|
||||
renderEveSurface(surface, host, options);
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.set) {
|
||||
for (const [nodeId, value] of Object.entries(step.set)) {
|
||||
const wrapper = host.querySelector(`[data-eve-node-id="${cssEscape(nodeId)}"]`);
|
||||
if (!wrapper) throw new Error(`bridge: no rendered control for node id ${nodeId}`);
|
||||
// `assignId` stamps the Eve node id onto the field's own wrapper
|
||||
// (`renderEditableControl`'s `<label>`), not the `<input>`/
|
||||
// `<textarea>` inside it, so the real editable element is a
|
||||
// descendant when the wrapper itself is not one.
|
||||
const element = /^(INPUT|TEXTAREA|SELECT)$/.test(wrapper.tagName)
|
||||
? wrapper
|
||||
: wrapper.querySelector("input, textarea, select");
|
||||
if (!element) throw new Error(`bridge: node id ${nodeId} has no editable control`);
|
||||
// PA.f174: a real browser refuses keystrokes into a disabled
|
||||
// control outright -- typing into it here without this check would
|
||||
// silently capture a value no real client could ever have entered,
|
||||
// hiding exactly the gap PA.f167 named untestable.
|
||||
if (element.disabled) {
|
||||
throw new Error(`bridge: node id ${nodeId}'s own control is disabled; a real client cannot type into it`);
|
||||
}
|
||||
element.value = value;
|
||||
element.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
|
||||
element.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
if (step.click) {
|
||||
const element = host.querySelector(`[data-eve-node-id="${cssEscape(step.click)}"]`);
|
||||
if (!element) throw new Error(`bridge: no rendered control for node id ${step.click}`);
|
||||
element.dispatchEvent(new dom.window.Event("click", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
|
||||
dom.window.close();
|
||||
process.stdout.write(intents.map(intent => JSON.stringify(intent)).join("\n") + "\n");
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
process.stderr.write(`${error.stack || error}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
1
vendor/eve
vendored
1
vendor/eve
vendored
|
|
@ -1 +0,0 @@
|
|||
Subproject commit 672c0c1e9fc828b63752345385727b06c50491d0
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Ghostlight Dungeon</title>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#surface">Skip to Ghostlight</a>
|
||||
<header>
|
||||
<p class="eyebrow">GHOSTLIGHT / LIVING WORLDS</p>
|
||||
<h1>Ghostlight Dungeon</h1>
|
||||
<p id="status" role="status" aria-live="polite">Reading the table…</p>
|
||||
</header>
|
||||
<main id="surface" tabindex="-1"></main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
1269
web/package-lock.json
generated
1269
web/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +0,0 @@
|
|||
{
|
||||
"name": "@gamecult/ghostlight-dungeon-web",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "npm run typecheck && vite build",
|
||||
"dev": "vite --host 127.0.0.1 --port 8832",
|
||||
"test": "node test/static-boundary.mjs && node --experimental-strip-types --test test/heimdall-adapter.test.mjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gamecult/eve-browser-lowering": "file:../vendor/eve/packages/eve-browser-lowering",
|
||||
"@gamecult/eve-contracts": "file:../vendor/eve/packages/eve-contracts",
|
||||
"ajv": "^8.17.1"
|
||||
},
|
||||
"devDependencies": { "typescript": "^5.6.3", "vite": "^7.1.2" }
|
||||
}
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Heimdall access browser adapter provenance
|
||||
|
||||
- Owner: `GameCult/Heimdall`
|
||||
- Plugin: `gamecult.heimdall.access`
|
||||
- Source commit: `e8e2832`
|
||||
- Source path: `plugins/gamecult.heimdall.access/browser-adapter.ts`
|
||||
- Vendoring mode: exact semantic copy with formatting only
|
||||
|
||||
Ghostlight embeds this adapter so immutable releases do not depend on a sibling
|
||||
checkout at browser-build time. Authentication behavior remains Heimdall-owned;
|
||||
changes are imported from the owner and this provenance is advanced with them.
|
||||
|
|
@ -1,139 +0,0 @@
|
|||
// Vendored from GameCult/Heimdall commit e8e2832, plugin
|
||||
// gamecult.heimdall.access. Heimdall owns these semantics; Ghostlight embeds the
|
||||
// adapter because its release must remain self-contained.
|
||||
const ATTEMPT_KEY = "gamecult.heimdall.access.attempt";
|
||||
|
||||
export interface HeimdallAccessResumeTarget {
|
||||
complete(handle: string): Promise<void>;
|
||||
}
|
||||
|
||||
export interface HeimdallAccessResumeOptions {
|
||||
appSlug: string;
|
||||
}
|
||||
|
||||
export function createHeimdallAccessBrowserAdapter(options: { advertisedOrigins: readonly string[] }) {
|
||||
const advertisedOrigins = new Set(options.advertisedOrigins.map(value => new URL(value).origin));
|
||||
return {
|
||||
pluginId: "gamecult.heimdall.access",
|
||||
capabilities: ["auth.gate", "auth.begin", "auth.complete", "auth.logout"],
|
||||
componentKinds: ["heimdall.access_gate", "heimdall.identity", "heimdall.access_status"],
|
||||
schemas: ["heimdall.access_gate_state.v1"],
|
||||
normalizeDocument(_schemaId: string | undefined, value: unknown): unknown {
|
||||
return value;
|
||||
},
|
||||
renderComponent(
|
||||
component: { id?: string },
|
||||
props: Record<string, unknown>,
|
||||
): HTMLElement {
|
||||
const view = document.createElement("section");
|
||||
view.className = `heimdall-access heimdall-access-${String(props.state || "unknown")}`;
|
||||
if (component.id) view.id = component.id;
|
||||
view.setAttribute("role", "status");
|
||||
view.setAttribute("aria-live", "polite");
|
||||
const title = document.createElement("h2");
|
||||
title.textContent = String(props.title || "Access");
|
||||
const detail = document.createElement("p");
|
||||
detail.textContent = String(props.detail || "");
|
||||
view.append(title, detail);
|
||||
const displayName = String(props.displayName || "");
|
||||
if (displayName) {
|
||||
const identity = document.createElement("p");
|
||||
identity.className = "heimdall-identity";
|
||||
identity.textContent = displayName;
|
||||
view.append(identity);
|
||||
}
|
||||
return view;
|
||||
},
|
||||
async consumeCommandResult(
|
||||
pluginPayload: { schemaId: string; payload: Record<string, unknown> },
|
||||
): Promise<void> {
|
||||
if (pluginPayload.schemaId === "heimdall.auth_navigation_receipt.v1") {
|
||||
const handle = String(pluginPayload.payload.handle || "");
|
||||
const navigation = object(pluginPayload.payload.navigation);
|
||||
const url = new URL(String(navigation.url || ""));
|
||||
const allowedOrigins = Array.isArray(navigation.allowedOrigins)
|
||||
? navigation.allowedOrigins.filter((value): value is string => typeof value === "string")
|
||||
: [];
|
||||
if (!handle || url.protocol !== "https:" || !advertisedOrigins.has(url.origin) || !allowedOrigins.includes(url.origin)) {
|
||||
throw new Error("Heimdall returned an unsafe authentication navigation receipt.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, handle);
|
||||
window.location.assign(url.toString());
|
||||
return;
|
||||
}
|
||||
if (pluginPayload.schemaId === "heimdall.auth_completion_status.v1") {
|
||||
const status = String(pluginPayload.payload.status || "");
|
||||
if (status === "authenticated" || status === "denied" || status === "expired") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const heimdallAccessBrowserAdapter = createHeimdallAccessBrowserAdapter({
|
||||
advertisedOrigins: ["https://discord.com", "https://heimdall.gamecult.org"],
|
||||
});
|
||||
|
||||
export async function resumeHeimdallAccess(
|
||||
target: HeimdallAccessResumeTarget,
|
||||
options: HeimdallAccessResumeOptions,
|
||||
): Promise<boolean> {
|
||||
const returned = readHeimdallBrowserReturn(window.location.href, options.appSlug);
|
||||
const storedHandle = sessionStorage.getItem(ATTEMPT_KEY);
|
||||
if (returned) {
|
||||
clearHeimdallBrowserReturn();
|
||||
if (returned.status === "error") {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error(returned.message);
|
||||
}
|
||||
if (storedHandle && storedHandle !== returned.handle) {
|
||||
sessionStorage.removeItem(ATTEMPT_KEY);
|
||||
throw new Error("Heimdall returned an authentication attempt other than the one this browser started.");
|
||||
}
|
||||
sessionStorage.setItem(ATTEMPT_KEY, returned.handle);
|
||||
}
|
||||
const handle = returned?.status === "success" ? returned.handle : storedHandle;
|
||||
if (!handle) return false;
|
||||
await target.complete(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function readHeimdallBrowserReturn(
|
||||
href: string,
|
||||
appSlug: string,
|
||||
): { status: "success"; handle: string } | { status: "error"; message: string } | undefined {
|
||||
const url = new URL(href);
|
||||
const parameters = new URLSearchParams(url.hash.startsWith("#") ? url.hash.slice(1) : url.hash);
|
||||
const status = parameters.get("heimdall_status");
|
||||
if (!status) return undefined;
|
||||
if (parameters.get("heimdall_handoff_kind") !== "browser_completion"
|
||||
|| parameters.get("heimdall_app_slug") !== appSlug) {
|
||||
return { status: "error", message: "Heimdall returned an authentication result for another application or handoff mode." };
|
||||
}
|
||||
if (status !== "success") {
|
||||
return {
|
||||
status: "error",
|
||||
message: parameters.get("heimdall_error_description")
|
||||
|| parameters.get("heimdall_error")
|
||||
|| "Heimdall authentication failed.",
|
||||
};
|
||||
}
|
||||
// The attempt id is the handle the app's backend completes over Heimdall's
|
||||
// private command plane. The completion code in the same fragment is a
|
||||
// distinct random secret for the HTTP redeem route; this adapter neither
|
||||
// needs nor forwards it.
|
||||
const attemptId = parameters.get("heimdall_attempt_id") || "";
|
||||
if (!attemptId) {
|
||||
return { status: "error", message: "Heimdall returned an authentication completion without its attempt id." };
|
||||
}
|
||||
return { status: "success", handle: attemptId };
|
||||
}
|
||||
|
||||
function clearHeimdallBrowserReturn(): void {
|
||||
history.replaceState(null, "", `${window.location.pathname}${window.location.search}`);
|
||||
}
|
||||
|
||||
function object(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
197
web/src/main.ts
197
web/src/main.ts
|
|
@ -1,197 +0,0 @@
|
|||
import {
|
||||
EveBrowserProviderHost,
|
||||
type EveBrowserProviderTransport,
|
||||
type EveCommandIntent,
|
||||
type EveProviderAdvertisement,
|
||||
type EveProviderSurfaceAdvertisement,
|
||||
type EveSurfaceDocument,
|
||||
} from "@gamecult/eve-browser-lowering";
|
||||
import { heimdallAccessBrowserAdapter, resumeHeimdallAccess } from "./heimdall-access-adapter";
|
||||
import "@gamecult/eve-browser-lowering/styles.css";
|
||||
import "./style.css";
|
||||
|
||||
class GhostlightEveTransport implements EveBrowserProviderTransport {
|
||||
private provider: EveProviderAdvertisement | undefined;
|
||||
private sourceVersion = 0;
|
||||
private authenticatedSurface = false;
|
||||
|
||||
async providerAdvertisement(): Promise<EveProviderAdvertisement> {
|
||||
this.provider = await fetchJson<EveProviderAdvertisement>("api/eve/provider");
|
||||
return this.provider;
|
||||
}
|
||||
|
||||
async surface(surface: EveProviderSurfaceAdvertisement): Promise<EveSurfaceDocument> {
|
||||
const surfaceId = surface.surfaceId || "ghostlight.play";
|
||||
const document = await fetchJson<EveSurfaceDocument>(`api/eve/surfaces/${encodeURIComponent(surfaceId)}`);
|
||||
this.sourceVersion = Math.max(0, Math.trunc(document.version || 0));
|
||||
this.authenticatedSurface = !containsAnonymousAccessGate(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
hasAuthenticatedSurface(): boolean {
|
||||
return this.authenticatedSurface;
|
||||
}
|
||||
|
||||
async submitCommand(intent: EveCommandIntent): Promise<unknown> {
|
||||
const result = await fetchJson<any>("api/eve/commands", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(intent),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
private advertisedTransport(): { transport?: string } {
|
||||
const commands = (this.provider as { commands?: Array<{ command?: string; transport?: string }> } | undefined)?.commands;
|
||||
const transport = commands?.find(command => command.command === "ghostlight.eve.commands")?.transport;
|
||||
return transport ? { transport } : {};
|
||||
}
|
||||
|
||||
async completeAuthentication(handle: string): Promise<any> {
|
||||
if (!this.provider) await this.providerAdvertisement();
|
||||
return await this.submitCommand({
|
||||
schema: "gamecult.eve.command_invocation.v1",
|
||||
providerId: this.provider?.providerId || "gamecult.ghostlight.dungeon",
|
||||
surfaceId: "ghostlight.play",
|
||||
operation: {
|
||||
operationId: "heimdall.auth.complete",
|
||||
schemaId: "heimdall.auth_complete_command.v1",
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
// The route transport is the one the provider advertises for its
|
||||
// command boundary, as the vendored client takes it from a command
|
||||
// descriptor; Ghostlight refuses an invocation without it.
|
||||
routeHint: { sourceVersion: this.sourceVersion, ...this.advertisedTransport() },
|
||||
},
|
||||
payload: { handle },
|
||||
issuedAt: new Date().toISOString(),
|
||||
clientId: "ghostlight.browser",
|
||||
commandBoundary: "ghostlight.eve.commands",
|
||||
receiptSchema: "gamecult.eve.command_result.v1",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson<T = any>(input: RequestInfo | URL, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, { cache: "no-store", credentials: "same-origin", ...init });
|
||||
const body = await response.json().catch(() => undefined);
|
||||
if (!response.ok) {
|
||||
const message = body && typeof body.message === "string"
|
||||
? body.message
|
||||
: `Ghostlight refused the request (${response.status}).`;
|
||||
throw new Error(message);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
function containsAnonymousAccessGate(value: unknown): boolean {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) return value.some(containsAnonymousAccessGate);
|
||||
const node = value as Record<string, unknown>;
|
||||
if (node.kind === "heimdall.access_gate") {
|
||||
const props = node.props && typeof node.props === "object" && !Array.isArray(node.props)
|
||||
? node.props as Record<string, unknown>
|
||||
: {};
|
||||
return props.state === "anonymous";
|
||||
}
|
||||
return Object.values(node).some(containsAnonymousAccessGate);
|
||||
}
|
||||
|
||||
function requiredElement<T extends HTMLElement>(id: string): T {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) throw new Error(`Missing browser host #${id}.`);
|
||||
return element as T;
|
||||
}
|
||||
|
||||
const surfaceHost = requiredElement<HTMLElement>("surface");
|
||||
const status = requiredElement<HTMLElement>("status");
|
||||
const api = new GhostlightEveTransport();
|
||||
const host = new EveBrowserProviderHost(surfaceHost, api, {
|
||||
body: document.body,
|
||||
clientId: "ghostlight.browser",
|
||||
// PA.f191: this used to be 0 (no poll at all), relying entirely on the
|
||||
// `revision` SSE subscription below for every live update. A dropped
|
||||
// stream — closed by an intermediary, or never subscribed at all (see
|
||||
// `trySubscribeToRevisions` below) — then froze the card with no recovery
|
||||
// short of a manual reload. This poll is deliberately low-frequency: it is
|
||||
// a fallback net, not the primary channel, and `host.refresh()` is already
|
||||
// a no-op when the surface version has not moved.
|
||||
pollMs: 5_000,
|
||||
requestedSurfaceId: "ghostlight.play",
|
||||
source: "Ghostlight",
|
||||
statusElement: status,
|
||||
pluginAdapters: [heimdallAccessBrowserAdapter],
|
||||
});
|
||||
|
||||
window.addEventListener("unhandledrejection", event => {
|
||||
event.preventDefault();
|
||||
status.textContent = event.reason instanceof Error ? event.reason.message : String(event.reason);
|
||||
});
|
||||
|
||||
// PA.f191: `api.hasAuthenticatedSurface()` reflects whatever the *last*
|
||||
// `surface()` fetch saw, and that flag can flip from false to true well
|
||||
// after this module's own startup sequence finishes — a player who signs in
|
||||
// through the in-page gate (`heimdall.auth_completion_status.v1`, resolved
|
||||
// entirely inside the vendored host's own `submit()`/`refresh()` cycle,
|
||||
// never observed by this file directly) authenticates on a timer this file
|
||||
// has no direct hook into. A one-shot check right after startup only ever
|
||||
// caught the redirect flow (`resumeHeimdallAccess`'s own `complete`
|
||||
// callback, below); the in-page flow left the tab with no `revision`
|
||||
// subscription for the rest of the session, so every answer after the first
|
||||
// was refused as stale and the card looked frozen. `trySubscribeToRevisions`
|
||||
// is idempotent and cheap to call speculatively — `hasAuthenticatedSurface()`
|
||||
// is a field read — so it is called at every point this file can observe an
|
||||
// authentication transition, plus on a short recheck interval to catch the
|
||||
// in-page flow it cannot observe directly any other way.
|
||||
let revisionsSubscribed = false;
|
||||
function trySubscribeToRevisions(): void {
|
||||
if (revisionsSubscribed || !api.hasAuthenticatedSurface()) return;
|
||||
revisionsSubscribed = true;
|
||||
const events = new EventSource("api/eve/events");
|
||||
events.addEventListener("revision", () => void host.refresh());
|
||||
events.addEventListener("error", () => {
|
||||
status.textContent = "Live revision notices are unavailable; your authoritative surface remains safe to refresh.";
|
||||
});
|
||||
}
|
||||
|
||||
await host.start();
|
||||
trySubscribeToRevisions();
|
||||
const resumeTarget = {
|
||||
complete: async (handle: string) => {
|
||||
const result = await api.completeAuthentication(handle);
|
||||
if (result.pluginPayload?.pluginId === heimdallAccessBrowserAdapter.pluginId) {
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult?.(result.pluginPayload);
|
||||
}
|
||||
await host.refresh();
|
||||
trySubscribeToRevisions();
|
||||
},
|
||||
};
|
||||
await resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" });
|
||||
trySubscribeToRevisions();
|
||||
|
||||
// A player who finishes Discord in another tab or app and switches back to
|
||||
// this one, rather than following Heimdall's "Return to the app" link, never
|
||||
// reloads this page, so the load-time resume above never sees the result.
|
||||
// Re-running the same resume when the tab becomes visible asks Heimdall about
|
||||
// the attempt this tab started; it answers pending or authenticated, and the
|
||||
// adapter drops the stored attempt once the answer is final.
|
||||
let resuming = false;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState !== "visible" || resuming || api.hasAuthenticatedSurface()) return;
|
||||
resuming = true;
|
||||
resumeHeimdallAccess(resumeTarget, { appSlug: "ghostlight" })
|
||||
.catch(error => {
|
||||
status.textContent = error instanceof Error ? error.message : String(error);
|
||||
})
|
||||
.finally(() => {
|
||||
resuming = false;
|
||||
});
|
||||
});
|
||||
|
||||
// The in-page gate's own completion never calls back into this file: it
|
||||
// resolves inside the vendored host's `submit()` (a command dispatch) and
|
||||
// its own `window.setTimeout(() => void host.refresh(), 100)`, which is the
|
||||
// one place `api.hasAuthenticatedSurface()` gets re-evaluated for that flow.
|
||||
// This is the only vantage point this file has on that transition; the
|
||||
// pollMs fallback above shares the same "cheap, idempotent, safe to run
|
||||
// speculatively" reasoning.
|
||||
window.setInterval(trySubscribeToRevisions, 2_000);
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
@import url('https://fonts.googleapis.com/css2?family=Crimson+Pro:opsz,wght@8..144,400;8..144,600&family=IBM+Plex+Mono:wght@400;600&display=swap');
|
||||
:root{color-scheme:dark;font-family:"Crimson Pro",serif;background:#0c1110;color:#e8e1cf}
|
||||
body{margin:0;min-height:100vh;background:radial-gradient(circle at 15% 0,#253229 0,transparent 38rem),linear-gradient(145deg,#0c1110,#111513)}
|
||||
header,#surface{width:min(1180px,calc(100% - 2rem));margin:auto}
|
||||
header{padding:3rem 0 1.25rem;border-bottom:1px solid #66543d}
|
||||
h1{font-size:clamp(2.6rem,8vw,5.5rem);font-weight:400;letter-spacing:-.045em;margin:.1rem 0}
|
||||
.eyebrow,#status,.field-label,button,pre{font-family:"IBM Plex Mono",monospace}
|
||||
.eyebrow{color:#d49b58;letter-spacing:.18em;font-size:.72rem}
|
||||
#status{color:#9aa69f;font-size:.78rem}
|
||||
#surface{padding:1.5rem 0 5rem;min-height:12rem}
|
||||
.eve-surface{display:grid;gap:1rem}
|
||||
.card,.pane,.heimdall-access{padding:1rem;border:1px solid #66543d;background:#17201ddd}
|
||||
.cultui-field{display:block;margin:.75rem 0}
|
||||
.field-label{display:block;font-size:.72rem;text-transform:uppercase;letter-spacing:.12em;margin:.65rem 0 .25rem}
|
||||
textarea,input,select{box-sizing:border-box;width:100%;resize:vertical;background:#0b0f0d;color:#f2e7d0;border:1px solid #4a5148;padding:.8rem;margin:.15rem 0;font:1.05rem/1.4 "Crimson Pro",serif}
|
||||
textarea{min-height:7rem}
|
||||
button{margin:.8rem .5rem 0 0;padding:.65rem 1rem;border:1px solid #d49b58;background:#d49b58;color:#17120c;cursor:pointer}
|
||||
button:disabled,input:disabled,textarea:disabled,select:disabled{cursor:not-allowed;opacity:.5}
|
||||
pre,.cultui-code{white-space:pre-wrap;padding:1rem;background:#080b09;color:#aeb9af;overflow:auto;font:.72rem/1.5 "IBM Plex Mono",monospace}
|
||||
.skip-link{position:absolute;left:-10000px}.skip-link:focus{left:1rem;top:1rem;background:#d49b58;color:#111;padding:.5rem;z-index:9}
|
||||
.cultui-list-item{border-top:1px solid #354039;padding:.8rem 0}
|
||||
.cultui-list-item-label,.cultui-list-item-status{font-family:"IBM Plex Mono",monospace;color:#d49b58;font-size:.72rem;text-transform:uppercase;letter-spacing:.08em}
|
||||
.cultui-tabs [role="tablist"]{display:flex;gap:.4rem;position:sticky;top:0;background:#0c1110e8;z-index:2}
|
||||
.eve-command-result{margin:1rem 0;padding:1rem;border:1px solid #66543d;background:#17201d}
|
||||
@media(max-width:600px){header{padding-top:2rem}h1{font-size:3rem}header,#surface{width:min(100% - 1rem,1180px)}.card,.pane,.heimdall-access{padding:.75rem}}
|
||||
|
|
@ -1,106 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { afterEach, test } from "node:test";
|
||||
import {
|
||||
heimdallAccessBrowserAdapter,
|
||||
readHeimdallBrowserReturn,
|
||||
resumeHeimdallAccess,
|
||||
} from "../src/heimdall-access-adapter.ts";
|
||||
|
||||
const attemptKey = "gamecult.heimdall.access.attempt";
|
||||
const originalGlobals = {
|
||||
history: globalThis.history,
|
||||
sessionStorage: globalThis.sessionStorage,
|
||||
window: globalThis.window,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
restoreGlobal("history", originalGlobals.history);
|
||||
restoreGlobal("sessionStorage", originalGlobals.sessionStorage);
|
||||
restoreGlobal("window", originalGlobals.window);
|
||||
});
|
||||
|
||||
test("browser return parser completes by attempt id and never reads the completion code", () => {
|
||||
const handle = "d0a9c1e7-89ac-46f9-8c85-c2484ed18f76";
|
||||
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle), "ghostlight"), { status: "success", handle });
|
||||
assert.equal(readHeimdallBrowserReturn("https://yggdrasil.gamecult.org/ghostlight/", "ghostlight"), undefined);
|
||||
assert.equal(readHeimdallBrowserReturn(returnUrl(handle), "another-app")?.status, "error");
|
||||
assert.deepEqual(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight"), { status: "success", handle });
|
||||
assert.ok(!JSON.stringify(readHeimdallBrowserReturn(returnUrl(handle, "secret-redeem-code"), "ghostlight")).includes("secret-redeem-code"));
|
||||
assert.equal(readHeimdallBrowserReturn(returnUrl("", "secret-redeem-code"), "ghostlight")?.status, "error");
|
||||
});
|
||||
|
||||
test("fresh browser context redeems the URL witness and scrubs it", async () => {
|
||||
const handle = "0be30b8c-b599-4920-b8b8-733f5e7bc275";
|
||||
const storage = memoryStorage();
|
||||
const replaced = [];
|
||||
installBrowserGlobals(returnUrl(handle), storage, replaced);
|
||||
const completed = [];
|
||||
|
||||
assert.equal(await resumeHeimdallAccess({ complete: async value => completed.push(value) }, { appSlug: "ghostlight" }), true);
|
||||
assert.deepEqual(completed, [handle]);
|
||||
assert.equal(storage.getItem(attemptKey), handle);
|
||||
assert.deepEqual(replaced, ["/ghostlight/"]);
|
||||
|
||||
await heimdallAccessBrowserAdapter.consumeCommandResult({
|
||||
schemaId: "heimdall.auth_completion_status.v1",
|
||||
payload: { status: "authenticated" },
|
||||
});
|
||||
assert.equal(storage.getItem(attemptKey), null);
|
||||
});
|
||||
|
||||
test("conflicting stored and returned attempts never reach completion", async () => {
|
||||
const storage = memoryStorage([[attemptKey, "stored-handle"]]);
|
||||
installBrowserGlobals(returnUrl("returned-handle"), storage, []);
|
||||
let completed = false;
|
||||
|
||||
await assert.rejects(
|
||||
resumeHeimdallAccess({ complete: async () => { completed = true; } }, { appSlug: "ghostlight" }),
|
||||
/other than the one this browser started/,
|
||||
);
|
||||
assert.equal(completed, false);
|
||||
assert.equal(storage.getItem(attemptKey), null);
|
||||
});
|
||||
|
||||
function installBrowserGlobals(href, storage, replaced) {
|
||||
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: storage });
|
||||
Object.defineProperty(globalThis, "history", {
|
||||
configurable: true,
|
||||
value: { replaceState: (_state, _title, url) => replaced.push(url) },
|
||||
});
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
configurable: true,
|
||||
value: { location: { href, pathname: "/ghostlight/", search: "" } },
|
||||
});
|
||||
}
|
||||
|
||||
function restoreGlobal(name, value) {
|
||||
if (value === undefined) delete globalThis[name];
|
||||
else Object.defineProperty(globalThis, name, { configurable: true, value });
|
||||
}
|
||||
|
||||
// Heimdall issues a random completion code distinct from the attempt id, as
|
||||
// the real callback does; the fixture must not restate the old equality.
|
||||
function returnUrl(attemptId, completionCode = `code-for-${attemptId}`) {
|
||||
const parameters = new URLSearchParams({
|
||||
heimdall_status: "success",
|
||||
heimdall_provider: "discord",
|
||||
heimdall_handoff_kind: "browser_completion",
|
||||
heimdall_app_slug: "ghostlight",
|
||||
heimdall_mode: "sign_in",
|
||||
heimdall_attempt_id: attemptId,
|
||||
heimdall_completion_code: completionCode,
|
||||
});
|
||||
return `https://yggdrasil.gamecult.org/ghostlight/#${parameters}`;
|
||||
}
|
||||
|
||||
function memoryStorage(initial = []) {
|
||||
const values = new Map(initial);
|
||||
return {
|
||||
get length() { return values.size; },
|
||||
clear: () => values.clear(),
|
||||
getItem: key => values.get(key) ?? null,
|
||||
key: index => [...values.keys()][index] ?? null,
|
||||
removeItem: key => { values.delete(key); },
|
||||
setItem: (key, value) => { values.set(key, value); },
|
||||
};
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const forbidden = [
|
||||
"/api/session-zero",
|
||||
"/api/governance",
|
||||
"/api/command",
|
||||
"/api/auth/heimdall",
|
||||
"renderSession",
|
||||
"renderCampaign",
|
||||
"payload.fields",
|
||||
'kind: "form"',
|
||||
'"kind":"form"',
|
||||
];
|
||||
|
||||
function sourceFiles(directory) {
|
||||
return readdirSync(directory)
|
||||
.flatMap(name => {
|
||||
const path = join(directory, name);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : [path];
|
||||
})
|
||||
.filter(path => /\.(ts|tsx|js|mjs)$/.test(path));
|
||||
}
|
||||
|
||||
const violations = sourceFiles(fileURLToPath(new URL("../src", import.meta.url)))
|
||||
.flatMap(path => {
|
||||
const source = readFileSync(path, "utf8");
|
||||
return forbidden.filter(token => source.includes(token)).map(token => `${path}: ${token}`);
|
||||
});
|
||||
|
||||
if (violations.length) {
|
||||
throw new Error(`Ghostlight browser crossed its Eve boundary:\n${violations.join("\n")}`);
|
||||
}
|
||||
|
||||
console.log("Ghostlight browser contains only the Eve provider boundary.");
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
import { fileURLToPath, URL } from "node:url";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@gamecult/eve-contracts": fileURLToPath(
|
||||
new URL("../vendor/eve/packages/eve-contracts/dist/index.js", import.meta.url),
|
||||
),
|
||||
"ajv/dist/2020.js": fileURLToPath(
|
||||
new URL("./node_modules/ajv/dist/2020.js", import.meta.url),
|
||||
),
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Reference in a new issue