Compare commits

..
Author SHA1 Message Date
Meta Vens
f84c5b5df4 Acceptance gate: pin that a failed attempt is numbered against the limit
Builds; the previous commit was the first mutation run's survivor
(record-states-attempts-as-the-limit): every pass fixture ran all three
attempts, so limit and attempts made were indistinguishable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:52:26 +02:00
Meta Vens
6aec390f64 Acceptance gate: retry content failures up to three attempts, fail faults at once, keep evidence and pass rate
The real-model acceptance test now builds a fresh fixture world per attempt
and runs the model-dependent turns over it. A reply the interpreter or agent
turns into a decline is a content failure and is retried (3 attempts); an
error, pending run, interruption or unapplied command is a fault and fails the
gate at once. Each failed attempt prints its assertion and a bounded excerpt of
the reply; the persona prose log now carries every failed attempt, the
acceptance-pass-rate line, and the passing persona prose. The recipe adds
--nocapture so a passing gate still shows its failed attempts in the step log.

Not verified yet: this commit has not been built or tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 02:35:56 +02:00
74 changed files with 29695 additions and 13665 deletions

1
.gitignore vendored
View file

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

3
.gitmodules vendored Normal file
View file

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

View file

@ -5,8 +5,7 @@
Ghostlight is a Rust world kernel and the play daemon built on it. Models
propose; the kernel admits or refuses. The same kernel is meant to run the
simulation behind Aetheria's public story, a persistent cast and storylets, as
well as Ghostlight Dungeon, the single-player DM-led client (parked at
tag `parked/ghostlight-eve-surface`). `README.md` says
well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says
what exists today and what does not.
## Where State Lives
@ -56,21 +55,28 @@ 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`.
- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe):
tag `parked/ghostlight-eve-surface`; not on main.
- 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`.
- Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`.
- Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`.
- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`.
- Architecture rationale: `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
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`.
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`).
For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult
`F:\Projects\gamecult-ops` before acting.

702
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

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

220
README.md
View file

@ -1,121 +1,149 @@
# Ghostlight
**Ghostlight is a world kernel: typed world state that a language model can
propose changes to and can never write.** The valuable part is
`crates/ghostlight`. The Dungeon game, one client of it, is parked (below).
Ghostlight 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.
**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.
## What is here
**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.
| 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. |
- `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`).
## How it works
Start with those two documents, then `cargo doc -p ghostlight --open`.
- The kernel is sealed. A consumer reaches it through create/open, immutable
snapshots, command submission, typed receipts, and the runner and port entry
points. There is no public mutable state, ID issuer, reducer, or journal
writer, and the crate declares no cargo features. The seal is proven by
`compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by
`crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests,
and opportunities and asserts that each is refused with nothing committed.
- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a
command, is decided by one reducer, and commits atomically to a
digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb).
- Models author through typed tools and a repair loop
(`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A
refused proposal returns its mismatches to the model; it never edits state.
- A Persona speaks only prose. A Projector builds its view, and an Interpreter
lowers its words into typed proposals with exact source quotes
(`crates/ghostlight/src/controllers.rs`).
- Vault evidence for seeding comes from a read-only markdown directory reader
(`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the
directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so
changing it needs a restart
(`crates/ghostlight-dungeon/src/runtime.rs`).
- Other programs can propose changes to the characters they control through
`POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`,
`crates/ghostlight/src/consumer.rs`). The contract is
`docs/architecture/ghostlight-world-consumer-api.md`.
**Populate the algebra first.** The kernel owns the component operations; a world owns its
affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in
Speak, which records an utterance and changes nothing. Until your world declares affordances
(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on,
every intent a model voices lowers to speech or to a recorded gap, and the prose built on that
will be thin no matter how good the model is. The Aetheria verse genesis
(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still
only record the act; their effects are the next phase.
## Ghostlight Dungeon
## Elaborators
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.
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.
Where it runs:
## Direction (planned, not built)
- **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.
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`.
## Direction
## Ghostlight Dungeon (parked)
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:
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.
- 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.
## 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.
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.
## Build and test
Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a
workstation:
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:
```sh
cargo build --locked --workspace
git submodule update --init vendor/eve
cargo test --locked -p ghostlight-persona-projection
cargo test --locked -p ghostlight
cargo test --locked -p ghostlight-verse
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
```
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.
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.
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.
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.
Runtime documents are MessagePack-backed CultCache. JSON appears only at schema
publication, model-provider and diagnostic boundaries.
## 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.

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,15 +0,0 @@
[package]
name = "ghostlight-verse"
version.workspace = true
edition.workspace = true
license.workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
ghostlight = { path = "../ghostlight" }
tokio.workspace = true
[dev-dependencies]
serde_json.workspace = true
tempfile = "3"

View file

@ -1,346 +0,0 @@
//! The verse runner's operator command line.
//!
//! `genesis` creates the canonical Aetheria verse world in a state root, and
//! `run` advances it a given number of ticks under a token cap, printing per
//! tick what each faction did and what it cost. A run starts only when this
//! command is invoked, and ends when it has done what it was asked.
//!
//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and
//! `calls.cc`. The provider key is read from a file at run time and appears
//! nowhere else.
use anyhow::{Context, Result, anyhow, bail};
use chrono::{Duration, Utc};
use ghostlight::{
CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX,
DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent,
HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort,
RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore,
WorldMailbox, call_run_id, id_text, open_controller_work, open_inference,
read_aetheria_catalog, run_manual,
};
use std::collections::BTreeMap;
use std::net::SocketAddr;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
/// What a manual run spends per tick on cognition cells. The operator's lever
/// on cost is the token cap; the partition always covers every faction.
const COVER: CoverBudget = CoverBudget {
cells: 8,
constituent_cap: 8,
urgency_slots: 2,
};
const RUNTIME_ID: &str = "ghostlight-verse";
#[tokio::main]
async fn main() -> ExitCode {
let arguments: Vec<String> = std::env::args().skip(1).collect();
match command(arguments).await {
Ok(code) => code,
Err(error) => {
eprintln!("ghostlight-verse: {error:#}");
ExitCode::FAILURE
}
}
}
async fn command(arguments: Vec<String>) -> Result<ExitCode> {
let Some((name, rest)) = arguments.split_first() else {
bail!("usage: ghostlight-verse genesis|run [flags]");
};
match name.as_str() {
"genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await,
"run" => run(Flags::parse(rest, &RUN_FLAGS)?).await,
_ => bail!("unknown command, expected genesis or run"),
}
}
const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"];
const RUN_FLAGS: [&str; 7] = [
"state",
"ticks",
"token-cap",
"tick-span-minutes",
"model",
"base-url",
"key-file",
];
/// Flags a command may omit.
const OPTIONAL: [&str; 1] = ["key-file"];
/// `--name value` pairs, each named once and from the command's own list.
struct Flags(BTreeMap<&'static str, String>);
impl Flags {
fn parse(arguments: &[String], allowed: &[&'static str]) -> Result<Self> {
let mut seen = BTreeMap::new();
let mut rest = arguments.iter();
while let Some(flag) = rest.next() {
let Some(name) = flag.strip_prefix("--") else {
bail!("expected a --flag, found a bare argument");
};
let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else {
bail!("a flag this command does not take was given");
};
let Some(value) = rest.next() else {
bail!("--{known} needs a value");
};
if seen.insert(known, value.clone()).is_some() {
bail!("--{known} was given twice");
}
}
for &required in allowed {
if !OPTIONAL.contains(&required) && !seen.contains_key(required) {
bail!("--{required} is required");
}
}
Ok(Self(seen))
}
fn text(&self, name: &str) -> &str {
self.0.get(name).map_or("", String::as_str)
}
fn path(&self, name: &str) -> PathBuf {
PathBuf::from(self.text(name))
}
fn number<T: std::str::FromStr + PartialOrd + Default>(&self, name: &str) -> Result<T> {
match self.text(name).parse::<T>() {
Ok(value) if value > T::default() => Ok(value),
_ => bail!("--{name} is not a positive integer"),
}
}
}
async fn genesis(flags: Flags) -> Result<ExitCode> {
let state = flags.path("state");
std::fs::create_dir_all(&state).context("the state directory cannot be created")?;
let catalog = read_aetheria_catalog(flags.path("catalog"))
.context("the Aetheria catalog cannot be read")?;
let factions = catalog.factions.len();
let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1));
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
.map_err(store_fault("the world file in --state cannot be opened"))?;
let receipt = mailbox
.create_verse(
CreateVerseWorldIntent {
id: CommandId::new(),
title: "The Aetheria Verse".into(),
brief: "The Aetheria verse, simulated from its catalog.".into(),
catalog,
aetheria_commit: flags.text("aetheria-commit").into(),
lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()),
targets: BTreeMap::new(),
},
&principal,
)
.await
.context("the verse world cannot be created")?;
// A created world is a draft: the owner approves and activates it, so the
// factions hold opportunities.
for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] {
let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision;
mailbox
.submit_principal(
PrincipalCommandIntent {
id: CommandId::new(),
world_id: receipt.world_id,
expected_revision: revision,
body,
},
&principal,
)
.await
.context("the world cannot be activated")?;
}
drop(mailbox);
task.await.context("the world file did not close cleanly")?;
println!("world {}", id_text(receipt.world_id));
println!("factions {factions}");
println!("phase Active");
Ok(ExitCode::SUCCESS)
}
async fn run(flags: Flags) -> Result<ExitCode> {
let state = flags.path("state");
let ticks: u32 = flags.number("ticks")?;
let cap: u64 = flags.number("token-cap")?;
let span: u32 = flags.number("tick-span-minutes")?;
let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?;
if !state.join("world.cc").is_file() {
bail!("the state directory holds no world; run genesis first");
}
let model = flags.text("model").to_owned();
let models = ControllerModels {
projector: model.clone(),
persona: model.clone(),
interpreter: model.clone(),
operational_agent: model.clone(),
elaborator: model.clone(),
};
let inner = open_lane(&flags, &model, &models)?;
let (mailbox, task) = WorldMailbox::open(state.join("world.cc"))
.map_err(store_fault("the world file in --state cannot be opened"))?;
let world = mailbox.snapshot().await.context("the world cannot be read")?;
let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).map_err(store_fault("the call records in --state cannot be opened"))?);
let runs = VerseRunStore::open(state.join("runs.cc")).map_err(store_fault("the run records in --state cannot be opened"))?;
let work = open_controller_work(state.join("controller-work.cc"))
.map_err(store_fault("the controller work store in --state cannot be opened"))?;
let config = VerseRunConfig {
ticks,
tick_span,
cover: COVER,
};
let claim = runs
.claim(&world, cap, &config)
.context("the run cannot be claimed")?;
let sequence = claim.sequence();
// A start that fails after the claim ends the claimed run, so it binds
// nothing: the next claim takes the next number.
let started = RecordingInferencePort::open(
inner,
calls,
call_run_id(sequence),
cap,
&RunStart::of(&world),
)
.context("the run's call records cannot be opened")
.and_then(|port| {
let port = Arc::new(port);
ControllerRunner::open(mailbox.clone(), port.clone(), work, models)
.map(|runner| (port, runner))
.context("the controller organ cannot open")
});
let (port, runner) = match started {
Ok(started) => started,
Err(error) => {
return Err(match runs.abandon(claim, "start_refused") {
Ok(()) => error,
Err(_) => error.context("the refused run's record could not be ended"),
});
}
};
println!(
"run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}"
);
let interrupted = Arc::new(AtomicBool::new(false));
{
let interrupted = interrupted.clone();
tokio::spawn(async move {
if tokio::signal::ctrl_c().await.is_ok() {
interrupted.store(true, Ordering::SeqCst);
}
});
}
let outcome = run_manual(
&mailbox,
&runner,
&port,
&runs,
claim,
&config,
|report| {
print!("{report}");
if interrupted.load(Ordering::SeqCst) {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
},
)
.await
.context("the run could not be recorded")?;
println!(
"status: {}; {} of {} ticks closed",
outcome.record.status,
outcome.record.ticks_closed(),
outcome.record.ticks_requested
);
drop(runner);
drop(port);
drop(mailbox);
task.await.context("the world file did not close cleanly")?;
Ok(match outcome.record.status {
RunStatus::Failed { .. } => ExitCode::FAILURE,
_ => ExitCode::SUCCESS,
})
}
/// The inference lane the model's prefix names. A hosted model needs an https
/// origin and a key file; a local one reaches a loopback server and needs
/// neither, so a key file beside it is refused.
fn open_lane(
flags: &Flags,
model: &str,
models: &ControllerModels,
) -> Result<Arc<dyn ghostlight::InferencePort>> {
let (mut hosted, mut local) = (None, None);
if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) {
let Some(key) = flags.0.get("key-file") else {
bail!("a hosted model needs --key-file");
};
hosted = Some(HostedBinding {
base_url: flags.text("base-url").to_owned(),
key_path: Path::new(key).to_owned(),
model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(),
caller_runtime_id: RUNTIME_ID.into(),
});
} else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) {
if flags.0.contains_key("key-file") {
bail!("a local model takes no --key-file");
}
let endpoint: SocketAddr = flags
.text("base-url")
.strip_prefix("http://")
.and_then(|address| address.trim_end_matches('/').parse().ok())
.context("--base-url for a local model is not http://<address>:<port>")?;
local = Some(LocalBinding {
endpoint,
model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(),
caller_runtime_id: RUNTIME_ID.into(),
});
} else {
bail!("--model names no inference lane (expected a hosted/ or local/ prefix)");
}
open_inference(None, None, local, hosted, &models.each())
.map_err(|error| anyhow!(lane_fault(&error)))
}
/// A store that cannot open says which one and that it is under `--state`,
/// never the path or the store's own text, which carries the path.
fn store_fault<E>(message: &'static str) -> impl FnOnce(E) -> anyhow::Error {
move |_| anyhow!(message)
}
/// What a lane that cannot open says: the flag or the kind of fault, never a
/// value the operator passed.
fn lane_fault(error: &ControllerOpenError) -> &'static str {
match error {
ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier",
ControllerOpenError::Connector(_) => "the connector lane cannot open",
ControllerOpenError::WorkStore(_) => "the controller work journal cannot open",
ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane",
ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file",
ControllerOpenError::LocalEndpointNotLoopback { .. } => {
"--base-url for a local model must be a loopback address"
}
ControllerOpenError::HostedEndpointNotHttps => {
"--base-url for a hosted model must be an https URL"
}
ControllerOpenError::HostedKeyUnreadable => {
"--key-file is unreadable, empty, or not one run of visible ASCII"
}
ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix",
ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty",
}
}

View file

@ -1,445 +0,0 @@
//! The `ghostlight-verse` command against a loopback scripted responder. No
//! real model is called and no key file is read: the responder answers the
//! OpenAI-compatible chat-completions request the local lane sends.
use serde_json::{Value, json};
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::Path;
use std::process::{Command, Output};
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse");
const CATALOG: &str = concat!(
env!("CARGO_MANIFEST_DIR"),
"/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc"
);
const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc";
/// What the responder reports each call cost: 120 prompt and 30 completion.
const PER_CALL: u64 = 150;
fn verse(arguments: &[&str]) -> Output {
Command::new(BINARY).args(arguments).output().unwrap()
}
fn stdout(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).into_owned()
}
fn stderr(output: &Output) -> String {
String::from_utf8_lossy(&output.stderr).into_owned()
}
fn genesis(state: &Path) -> Output {
verse(&[
"genesis",
"--catalog",
CATALOG,
"--aetheria-commit",
COMMIT,
"--state",
state.to_str().unwrap(),
"--owner-hash",
"verse-owner",
])
}
/// Answers every chat-completions request with one tool call: the faction the
/// prompt names speaks its own line. Counts the requests it served.
struct Responder {
address: SocketAddr,
served: Arc<AtomicUsize>,
}
impl Responder {
fn start() -> Self {
Self::answering(200)
}
/// A provider that refuses every call with this status.
fn refusing(status: u16) -> Self {
Self::answering(status)
}
fn answering(status: u16) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
let served = Arc::new(AtomicUsize::new(0));
let counter = served.clone();
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(stream) = stream else { return };
let counter = counter.clone();
std::thread::spawn(move || serve(stream, &counter, status));
}
});
Self { address, served }
}
fn url(&self) -> String {
format!("http://{}", self.address)
}
}
fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) {
let mut raw = Vec::new();
let mut chunk = [0u8; 4096];
let (head_end, length) = loop {
let read = stream.read(&mut chunk).unwrap_or(0);
if read == 0 {
return;
}
raw.extend_from_slice(&chunk[..read]);
if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") {
let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase();
let length = head
.lines()
.find_map(|line| line.strip_prefix("content-length:"))
.and_then(|value| value.trim().parse::<usize>().ok())
.unwrap_or(0);
break (end + 4, length);
}
};
while raw.len() < head_end + length {
let read = stream.read(&mut chunk).unwrap_or(0);
if read == 0 {
return;
}
raw.extend_from_slice(&chunk[..read]);
}
let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap();
let text = request.to_string();
let faction = ["Adrasteia", "Brannoch"]
.into_iter()
.find(|name| text.contains(&format!("Decision owner:\\n{name}\\n")))
.expect("a request names its faction");
let sequence = served.fetch_add(1, Ordering::SeqCst);
let reply = json!({
"id": format!("reply-{sequence}"),
"choices": [{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [{
"id": format!("call-{sequence}"),
"type": "function",
"function": {
"name": "speak",
"arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(),
},
}],
},
"finish_reason": "tool_calls",
}],
"usage": { "prompt_tokens": 120, "completion_tokens": 30 },
})
.to_string();
let response = format!(
"HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}",
reply.len()
);
let _ = stream.write_all(response.as_bytes());
}
fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output {
verse(&[
"run",
"--state",
state.to_str().unwrap(),
"--ticks",
ticks,
"--token-cap",
cap,
"--tick-span-minutes",
"60",
"--model",
"local/scripted",
"--base-url",
&responder.url(),
])
}
#[test]
fn binary_run_prints_acts_and_spend() {
let state = tempfile::tempdir().unwrap();
let made = genesis(state.path());
assert!(made.status.success(), "{}", stderr(&made));
let made = stdout(&made);
assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}");
assert!(state.path().join("world.cc").is_file());
let responder = Responder::start();
let output = run(state.path(), &responder, "2", "1000000");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
// Two ticks, each with both factions named by verb, and the spend as it
// accrues against the cap.
assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}");
for tick in 0..2u64 {
let spent = PER_CALL * 2 * (tick + 1);
assert!(
text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")),
"{text}"
);
assert!(
text.contains(&format!(
" tokens: {} this tick, {spent} spent, {} remaining\n",
PER_CALL * 2,
1_000_000 - spent
)),
"{text}"
);
}
for faction in ["Adrasteia", "Brannoch"] {
assert_eq!(
text.matches(&format!(" {faction} speak [")).count(),
2,
"{text}"
);
assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}");
}
assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}");
assert_eq!(responder.served.load(Ordering::SeqCst), 4);
// The state root holds each store the run wrote.
for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] {
assert!(state.path().join(file).is_file(), "{file} was not written");
}
// A second run is the next in the world and carries on from its clock.
let again = run(state.path(), &responder, "1", "1000000");
assert!(again.status.success(), "{}", stderr(&again));
let text = stdout(&again);
assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}");
assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}");
}
#[test]
fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let responder = Responder::start();
let output = run(state.path(), &responder, "3", "1000");
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("tick 0 open: "), "{text}");
assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}");
assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}");
assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made");
}
#[test]
fn a_run_that_fails_says_so_and_exits_nonzero() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let responder = Responder::refusing(500);
let output = run(state.path(), &responder, "2", "1000000");
assert!(!output.status.success());
let text = stdout(&output);
assert!(text.contains("tick 0 open: "), "{text}");
assert!(
text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"),
"{text}"
);
assert_eq!(
responder.served.load(Ordering::SeqCst),
1,
"the run stopped at the first refusal"
);
}
#[test]
fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() {
let state = tempfile::tempdir().unwrap();
let responder = Responder::start();
// No world yet.
let output = run(state.path(), &responder, "1", "1000");
assert!(!output.status.success());
assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output));
assert!(genesis(state.path()).status.success());
// A second genesis does not overwrite the first.
assert!(!genesis(state.path()).status.success());
let secret = "sk-not-a-real-key-0000";
let key = state.path().join(secret);
let cases: [(Vec<&str>, &str); 9] = [
(vec![], "usage"),
(vec!["fly"], "unknown command"),
(
vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"],
"--token-cap is required",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "many",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--ticks is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "0",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--ticks is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1",
],
"--token-cap is not a positive integer",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4",
"--base-url", "http://127.0.0.1:1",
],
"--model names no inference lane",
),
(
// A hosted model needs its key file; a missing one is named by flag.
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x",
"--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(),
],
"--key-file is unreadable",
),
(
vec![
"run", "--state", state.path().to_str().unwrap(), "--ticks", "1",
"--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x",
"--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(),
],
"a local model takes no --key-file",
),
];
for (arguments, expected) in cases {
let output = verse(&arguments);
assert!(!output.status.success(), "{arguments:?} succeeded");
let error = stderr(&output);
assert!(error.contains(expected), "{arguments:?}: {error}");
assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}");
assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}");
}
}
#[test]
fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() {
let state = tempfile::tempdir().unwrap();
assert!(genesis(state.path()).status.success());
let output = verse(&[
"run",
"--state",
state.path().to_str().unwrap(),
"--ticks",
"1",
"--token-cap",
"1000",
"--tick-span-minutes",
"60",
"--model",
"local/x",
"--base-url",
"http://10.77.0.99:4242",
]);
assert!(!output.status.success());
let error = stderr(&output);
assert!(error.contains("--base-url"), "{error}");
assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}");
// The lane failed before any run was claimed.
assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output));
}
/// A start refused after its claim ends the claimed record Failed, so the run
/// binds nothing. Reached from the command by removing the run records while
/// the call ledger still holds run 1 under another cap.
#[test]
fn a_start_the_ledger_refuses_ends_its_claimed_record_failed() {
let parent = tempfile::tempdir().unwrap();
let state = parent.path().join("state");
std::fs::create_dir_all(&state).unwrap();
assert!(genesis(&state).status.success());
let responder = Responder::start();
let first = run(&state, &responder, "1", "1000000");
assert!(first.status.success(), "{}", stderr(&first));
std::fs::remove_file(state.join("runs.cc")).unwrap();
let refused = run(&state, &responder, "1", "500000");
assert!(!refused.status.success());
let world = {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let _entered = runtime.enter();
let (mailbox, task) = ghostlight::WorldMailbox::open(state.join("world.cc")).unwrap();
let world = runtime.block_on(mailbox.snapshot()).unwrap().world_id;
drop(mailbox);
runtime.block_on(task).unwrap();
world
};
let runs = ghostlight::VerseRunStore::open(state.join("runs.cc"))
.unwrap()
.runs(world)
.unwrap();
assert_eq!(runs.len(), 1);
assert_eq!(
runs[0].status,
ghostlight::RunStatus::Failed {
reason: "start_refused".into()
}
);
}
/// A store that cannot open names the flag and the fault, never the path.
#[test]
fn a_store_that_cannot_open_never_echoes_the_state_path() {
let responder = Responder::start();
for file in ["world.cc", "controller-work.cc"] {
for held in [false, true] {
let parent = tempfile::tempdir().unwrap();
let state = parent.path().join("distinctivestatename");
std::fs::create_dir_all(&state).unwrap();
assert!(genesis(&state).status.success());
let first = run(&state, &responder, "1", "1000000");
assert!(first.status.success(), "{}", stderr(&first));
let target = state.join(file);
let _guard: Box<dyn std::any::Any> = if !held {
std::fs::write(&target, b"not a database").unwrap();
Box::new(())
} else if file == "world.cc" {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let entered = runtime.enter();
let opened = ghostlight::WorldMailbox::open(&target).unwrap();
drop(entered);
Box::new((runtime, opened))
} else {
Box::new(ghostlight::open_controller_work(&target).unwrap())
};
let output = run(&state, &responder, "1", "1000000");
assert!(!output.status.success(), "{file} held={held}");
let error = stderr(&output);
assert!(error.contains("--state"), "{file} held={held}: {error}");
assert!(
!error.contains("distinctivestatename"),
"{file} held={held} echoed the path: {error}"
);
}
}
}

View file

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

View file

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

View file

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

View file

@ -469,7 +469,7 @@ mod tests {
AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind,
ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle,
EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef,
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex,
FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref,
RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability,
WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph,
commit_digest, derive_boundaries, derive_cover, derive_opportunities,
@ -518,7 +518,6 @@ mod tests {
},
controller,
position: Some(Ref::Existing(commons)),
import_key: None,
})
};
WorldPatch {
@ -736,7 +735,8 @@ mod tests {
assert!(!graph.subjects.contains(&mirror.first));
let cover = derive_cover(
snapshot.world_id,
TickIndex(0),
snapshot.now,
30,
&opportunities,
&graph,
CoverBudget {
@ -1195,7 +1195,6 @@ mod tests {
controller: NewController::External { consumer },
affordances: BTreeSet::new(),
position: Some(Ref::Existing(commons)),
import_key: None,
})])
};
// A binding to another consumer is outside its ground.
@ -1574,7 +1573,6 @@ mod tests {
controller,
affordances,
position: Some(Ref::Existing(mirror.commons)),
import_key: None,
})])
};
let pairing = |result: Result<SubmitReceipt, KernelError>| {

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -56,15 +56,11 @@
//! ```
mod action;
mod aetheria;
mod clock;
mod consumer;
mod controllers;
mod cover;
mod elaboration;
mod grammar;
#[cfg(test)]
mod import_key_tests;
mod journal;
mod lens;
mod local_inference;
@ -74,7 +70,6 @@ mod sdk_inference;
mod table;
mod tool_schema;
mod vault;
mod verse;
pub(crate) use action::ActionMismatch;
pub(crate) use clock::Motion;
@ -85,31 +80,23 @@ pub use consumer::{
};
pub use controllers::{
CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels,
ControllerOpenError,
ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork,
ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError,
ControllerWorkWrite,
GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput,
InferencePort,
InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle,
OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle,
TracingInferencePort, open_controller_work, open_inference,
};
pub(crate) use cover::{CellId, Constituent, Resolution};
pub use aetheria::{
AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog,
};
pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover};
pub use elaboration::{
ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery,
EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row,
};
pub use grammar::{
GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb,
RenderPath,
};
pub use lens::{Lens, LensWeights};
pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding};
pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding};
#[cfg(feature = "test-support")]
pub use local_inference::open_local_port_with_timeout;
pub(crate) use mailbox::ElaborationPort;
@ -126,7 +113,6 @@ pub(crate) use patch::{
PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach,
SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef,
};
pub use patch::ImportKey;
#[cfg(test)]
use patch::{
AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp,
@ -144,13 +130,6 @@ pub use table::{
id_text, id_text_matches, table_view,
};
pub use vault::{VaultError, VaultEvidenceSource};
pub use verse::{
CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord,
RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim,
ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint,
TickReport, UnattributedCall, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore,
call_run_id, run_manual,
};
use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch};
use chrono::{DateTime, Utc};
@ -446,10 +425,6 @@ struct CreateWorld {
/// The world's flavor as genesis writes it. `SetLensWeights` may replace
/// it later; this command carries what the world began with.
lens_weights: LensWeights,
/// The grammar the world is bound to for its whole life, or none for an
/// open-catalog world. Authored here and nowhere else: genesis installs it.
#[serde(default, skip_serializing_if = "Option::is_none")]
grammar: Option<GrammarBinding>,
}
/// Unattributed creation intent. World ingress derives ownership, controller
@ -734,10 +709,6 @@ pub enum CommandBody {
struct SubjectState {
label: String,
kind: SubjectKind,
/// Written only when a subject declaration is resolved; absent from a
/// subject nobody imported, so its digests are unchanged.
#[serde(default, skip_serializing_if = "Option::is_none")]
import_key: Option<ImportKey>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
@ -857,11 +828,6 @@ struct WorldState {
/// Authored at creation and never mutated; the one prose the world says
/// about itself, projected as guidance to every lane.
brief: String,
/// Bound by genesis and never written again; absent from a world with no
/// grammar, so such a world's state and digests are what they were before
/// grammars existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
grammar: Option<GrammarBinding>,
draft_approvals: BTreeSet<PrincipalId>,
subjects: BTreeMap<SubjectId, SubjectState>,
entities: BTreeMap<EntityId, EntityRecord>,
@ -1027,8 +993,6 @@ pub struct SubjectSnapshot {
pub id: SubjectId,
pub label: String,
pub kind: SubjectKind,
/// The Aetheria record this subject was imported from, if it was.
pub import_key: Option<ImportKey>,
/// Absent for an externally controlled mirror, which has no controller and
/// no turn. Every reader fails closed on `None`; none gets a default.
pub(crate) controller_id: Option<ControllerId>,
@ -1171,9 +1135,6 @@ pub(crate) struct ForumSnapshot {
pub struct AffordanceSnapshot {
pub id: AffordanceId,
pub entry: Affordance,
/// Derived from the world's grammar by this entry's kind; none when the
/// world has no grammar.
pub render_path: Option<RenderPath>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -1236,8 +1197,6 @@ pub struct WorldSnapshot {
pub owner: PrincipalId,
pub title: String,
pub brief: String,
/// The bound grammar's identity; none for a world with an open catalog.
pub grammar: Option<GrammarIdentity>,
pub draft_approvals: BTreeSet<PrincipalId>,
pub required_approvers: BTreeSet<PrincipalId>,
pub subjects: Vec<SubjectSnapshot>,
@ -1279,21 +1238,6 @@ pub struct OperatorEvent {
pub speaker_label: String,
pub speech: Option<Statement>,
pub display: Option<Statement>,
/// The affordance kind the act exercised.
pub verb: String,
/// How the world's grammar renders that verb; none in a world with no
/// grammar.
pub render_path: Option<RenderPath>,
/// What the act bound each of the verb's roles to, in the invocation's
/// order.
pub roles: Vec<BoundRole>,
}
/// One role an act bound, with the label of what it was bound to.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BoundRole {
pub role: String,
pub target: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
@ -1481,12 +1425,7 @@ fn prepare_creation(
// derived ID, so it is minted before resolution rather than by it.
let world_id = WorldId::issue();
let resolved = patch::resolve_patch(
&WorldState::empty(
world_id,
input.owner.clone(),
title.clone(),
input.grammar.clone(),
),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -1584,43 +1523,7 @@ impl WorldKernel {
/// no consumer may read in full would be a loaded gun with a comment on it.
fn operator_log(&self) -> Result<Vec<OperatorEvent>, KernelError> {
self.journal.ensure_healthy()?;
// An event records what the world did; the invocation, which names the
// bound roles, lives on the commit that produced it.
let invocations: BTreeMap<EventId, &DecisionInvocation> = self
.journal
.commits()
.filter_map(|commit| match &commit.effect {
WorldEffect::DecisionExercised {
invocation, event, ..
} => Some((event.id, invocation)),
_ => None,
})
.collect();
operator_log(&self.state, &invocations)
}
/// The tick driver's opportunity list, in the same order a snapshot
/// carries it. Separate from `snapshot` for the reason `agency_graph` is:
/// only the tick driver reads it.
fn opportunities(&self) -> Result<Vec<DecisionOpportunity>, KernelError> {
self.journal.ensure_healthy()?;
Ok(order_opportunities(
&self.state,
derive_opportunities(&self.state)?,
))
}
/// How many clock advances the world has committed: the next tick's
/// ordinal, held by the world and read from its journal. Separate from
/// `snapshot` for the reason `opportunities` is: only the tick driver
/// reads it.
fn clock_advances(&self) -> Result<u64, KernelError> {
self.journal.ensure_healthy()?;
Ok(self
.journal
.commits()
.filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. }))
.count() as u64)
operator_log(&self.state)
}
/// The scheduler's adjacency projection. Separate from `snapshot` for the
@ -1993,12 +1896,7 @@ impl WorldState {
/// The world before any structure. Genesis resolves its own patch against
/// this value, so the genesis lane and `AdmitPatch` share one resolver
/// signature instead of hand-passing empty partitions.
fn empty(
world_id: WorldId,
owner: PrincipalId,
title: String,
grammar: Option<GrammarBinding>,
) -> Self {
fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self {
Self {
schema: STATE_SCHEMA.into(),
world_id,
@ -2007,7 +1905,6 @@ impl WorldState {
owner,
title,
brief: String::new(),
grammar,
draft_approvals: BTreeSet::new(),
subjects: BTreeMap::new(),
entities: BTreeMap::new(),
@ -2065,7 +1962,7 @@ impl WorldState {
// The same re-derive-and-compare that `apply_committed_command` runs for
// every other command. Deterministic allocation is what lets one
// equality replace a field-by-field binding zip.
let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone());
let mut state = Self::empty(world_id, owner.clone(), title.clone());
state.brief = brief.clone();
let expected = patch::resolve_patch(
&state,
@ -2174,18 +2071,6 @@ fn admit_resolved(
.filter_map(ControllerAssignment::id)
.collect();
for entry in &resolved.affordances {
// The catalog's only writer holds the same grammar check the resolver
// runs, so an effect resolved elsewhere cannot install an entry the
// bound grammar refuses.
if state.grammar.as_ref().is_some_and(|grammar| {
grammar
.refusal(&entry.affordance.kind, &entry.affordance.roles)
.is_some()
}) {
return Err(KernelError::Invariant(
"admitted affordance is outside the bound grammar".into(),
));
}
if state
.affordance_catalog
.insert(entry.affordance_id, entry.affordance.clone())
@ -2196,21 +2081,6 @@ fn admit_resolved(
));
}
}
// The subjects map's only writer holds the same injectivity the resolver
// checks, decided by the same function.
{
let held = patch::held_import_keys(state.subjects.values());
let mut declared = BTreeSet::new();
for subject in &resolved.subjects {
if let Some(key) = &subject.subject.import_key
&& patch::import_key_fault(key, &held, &mut declared).is_some()
{
return Err(KernelError::Invariant(
"admitted subject's import key is malformed, held or repeated".into(),
));
}
}
}
for subject in &resolved.subjects {
let scope = DecisionScope {
subject_id: subject.subject_id,
@ -3795,10 +3665,7 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent
/// The human operator's story feed, derived from the causal record. The events
/// log is not a perception surface: `knowledge` is the sole answer to what a
/// subject may act on and what its projection may contain.
fn operator_log(
state: &WorldState,
invocations: &BTreeMap<EventId, &DecisionInvocation>,
) -> Result<Vec<OperatorEvent>, KernelError> {
fn operator_log(state: &WorldState) -> Result<Vec<OperatorEvent>, KernelError> {
state
.events
.iter()
@ -3807,30 +3674,6 @@ fn operator_log(
.subjects
.get(&event.scope.subject_id)
.ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?;
let invocation = invocations.get(&event.id).ok_or_else(|| {
KernelError::Invariant("event has no committed invocation".into())
})?;
let entry = state
.affordance_catalog
.get(&event.affordance)
.ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?;
let roles = invocation
.bindings
.iter()
.map(|binding| {
let label = match binding.target {
Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()),
Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()),
Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()),
};
label
.map(|target| BoundRole {
role: binding.role.0.clone(),
target,
})
.ok_or_else(|| KernelError::Invariant("event binds no live referent".into()))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(OperatorEvent {
revision: event.revision,
speaker: event.scope.subject_id,
@ -3843,13 +3686,6 @@ fn operator_log(
.display
.and_then(|fact| state.facts.get(&fact))
.map(|record| record.statement.clone()),
verb: entry.kind.0.clone(),
render_path: state
.grammar
.as_ref()
.and_then(|grammar| grammar.verb(&entry.kind.0))
.map(|verb| verb.render_path),
roles,
})
})
.collect()
@ -4128,7 +3964,6 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
id: *subject_id,
label: subject.label.clone(),
kind: subject.kind,
import_key: subject.import_key.clone(),
controller_id: controller.id(),
controller_mode: controller.mode(),
human_controller: controller.human_principal().cloned(),
@ -4244,7 +4079,6 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
owner: state.owner.clone(),
title: state.title.clone(),
brief: state.brief.clone(),
grammar: state.grammar.as_ref().map(GrammarBinding::identity),
draft_approvals: state.draft_approvals.clone(),
required_approvers: required_approvers(state),
subjects,
@ -4254,11 +4088,6 @@ fn snapshot(state: &WorldState) -> Result<WorldSnapshot, KernelError> {
.map(|(affordance_id, entry)| AffordanceSnapshot {
id: *affordance_id,
entry: entry.clone(),
render_path: state
.grammar
.as_ref()
.and_then(|grammar| grammar.verb(&entry.kind.0))
.map(|verb| verb.render_path),
})
.collect(),
places,
@ -5609,7 +5438,6 @@ mod tests {
// The fixture world is one room. Co-located speech needs a place to
// fill, so every fixture subject stands in the commons.
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
import_key: None,
})
}
@ -5712,13 +5540,11 @@ mod tests {
.chain(std::iter::once(Ref::Draft(DraftHandle::new("convene"))))
.collect(),
position: Some(Ref::Draft(DraftHandle::new(COMMONS))),
import_key: None,
}),
],
},
scale_intent: WorldScaleIntentRef::default(),
lens_weights: stock_weights(),
grammar: None,
}
}
@ -5863,7 +5689,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Draft(DraftHandle::new("yard"))),
import_key: None,
}),
],
// A route that should start closed is declared and closed in one
@ -5969,7 +5794,6 @@ mod tests {
}))
.collect(),
position: Some(Ref::Existing(place)),
import_key: None,
})
}
@ -6490,7 +6314,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: civic_grants(handles, &speak),
position: Some(place),
import_key: None,
})
};
let declarations = civic_affordances()
@ -6547,7 +6370,6 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("hall"))),
import_key: None,
}),
Declaration::Subject(SubjectDeclaration {
handle: DraftHandle::new("reeve"),
@ -6559,7 +6381,6 @@ mod tests {
&speak,
),
position: Some(Ref::Draft(DraftHandle::new("chamber"))),
import_key: None,
}),
person(
"farmer",
@ -6583,7 +6404,6 @@ mod tests {
controller: NewController::NarrativePersona,
affordances: civic_grants(&["levy", "petition"], &speak),
position: None,
import_key: None,
}),
])
.collect();
@ -6747,7 +6567,6 @@ mod tests {
Ref::Draft(DraftHandle::new("recant")),
]),
position: place.map(|place| Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
};
submit_owner(
@ -8016,7 +7835,6 @@ mod tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.road)),
import_key: None,
}),
],
operations: Vec::new(),
@ -9035,7 +8853,6 @@ mod custody_tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.yard)),
import_key: None,
}),
],
operations: vec![ComponentOp::Admit {
@ -9695,7 +9512,6 @@ mod custody_tests {
controller: NewController::OperationalAgent,
affordances: BTreeSet::from([speak.clone()]),
position: Some(Ref::Existing(topology.yard)),
import_key: None,
}),
],
operations: vec![admit(u64::MAX), admit(1)],
@ -11622,7 +11438,7 @@ mod soul_knowledge_tests {
// Nobody holds it.
assert!(kernel.state.knowledge.is_empty());
// The operator reads it anyway, with the speaker resolved by label.
let log = kernel.operator_log().unwrap();
let log = operator_log(&kernel.state).unwrap();
assert_eq!(
log,
vec![OperatorEvent {
@ -11631,9 +11447,6 @@ mod soul_knowledge_tests {
speaker_label: "The Yard Bystander".into(),
speech: Some(Statement::new(text).unwrap()),
display: None,
verb: "speak".into(),
render_path: None,
roles: Vec::new(),
}]
);
// And no subject snapshot carries it.
@ -11691,7 +11504,6 @@ mod witness_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]),
position: Some(Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
}
@ -12805,7 +12617,6 @@ mod witness_tests {
// Every non-mirror subject must hold one.
affordances: BTreeSet::from([Ref::Existing(beacon)]),
position: position.map(Ref::Existing),
import_key: None,
}),
],
operations: vec![witness(
@ -13170,7 +12981,6 @@ mod clock_tests {
Ref::Draft(DraftHandle::new("threaten")),
]),
position: Some(Ref::Draft(DraftHandle::new(place))),
import_key: None,
})
};
let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle));
@ -15352,7 +15162,7 @@ mod clock_tests {
}));
let title = normalize_title(&input.title).unwrap();
let resolved = patch::resolve_patch(
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -15381,7 +15191,7 @@ mod clock_tests {
});
let title = normalize_title(&input.title).unwrap();
let resolved = patch::resolve_patch(
&WorldState::empty(world_id, input.owner.clone(), title.clone(), None),
&WorldState::empty(world_id, input.owner.clone(), title.clone()),
input.id,
&input.patch,
Some(&input.scale_intent),
@ -17224,7 +17034,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17291,7 +17100,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: Vec::new(),
@ -17369,7 +17177,6 @@ mod clock_tests {
},
affordances: BTreeSet::new(),
position: Some(Ref::Existing(commons)),
import_key: None,
})],
operations: Vec::new(),
evidence: Vec::new(),
@ -17463,7 +17270,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
}),
],
operations: vec![ComponentOp::Mint {
@ -17706,7 +17512,6 @@ mod clock_tests {
// the boundary its own answer claims: an occupied
// place is no longer unelaborated.
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: Vec::new(),
evidence: Vec::new(),
@ -17885,7 +17690,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::Retire {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -17995,7 +17799,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(commons)),
import_key: None,
}),
],
operations: vec![ComponentOp::GrantAffordance {
@ -18210,7 +18013,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::GrantAffordance {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -18629,7 +18431,6 @@ mod clock_tests {
Ref::Existing(clockwork.threaten),
]),
position: Some(Ref::Existing(clockwork.dead_end)),
import_key: None,
})],
operations: vec![ComponentOp::RevokeAffordance {
subject: Ref::Draft(DraftHandle::new("stray")),
@ -19078,7 +18879,6 @@ mod clock_tests {
controller: NewController::NarrativePersona,
affordances: BTreeSet::from([speak.clone()]),
position: None,
import_key: None,
}),
Declaration::Channel(ChannelDeclaration {
handle: DraftHandle::new("wire"),

View file

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

View file

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

View file

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

View file

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

View file

@ -14,7 +14,7 @@
//! Projector may call it.
use super::controllers::{
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage,
self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest,
RequestShape, tool_request,
};
use super::patch::{
@ -1070,12 +1070,6 @@ impl InferenceOutput {
pub fn receipt_digest(&self) -> &str {
&self.receipt_digest
}
/// The tokens the provider reported for this reply; `None` when it
/// reported none.
pub fn usage(&self) -> Option<TokenUsage> {
self.usage
}
}
/// The canonical id as the patch vocabulary spells it: the bare UUID text,
@ -1606,8 +1600,7 @@ mod tests {
// PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it
// from its own list too. This is now the complete 24-tool list.
// PA.f88: this is the library's own test fixture, kept in sync by hand.
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at
// `parked/ghostlight-eve-surface`) is the
// Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the
// single policy owner of what the play agent may actually call; this
// copy pins nothing there and is not read by production code.
const PLAY_TOOLS: &[&str] = &[
@ -2496,7 +2489,6 @@ mod tests {
outcome_bands: Vec::new(),
carries_speech: false,
},
render_path: None,
}
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

@ -1,7 +1,5 @@
# 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
@ -626,11 +624,9 @@ 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.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
`world.create`'s current payload is `world_create.v4`: title, brief, targets,
jurisdiction roots and lens weights are required, and Dungeon's create surface
offers uniform weights as its editable default. `world.seed` runs one
`SeedRunner` session per invocation against a `VaultEvidenceSource`.
Focused tests must prove:

View file

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

View file

@ -1,7 +1,5 @@
# Ghostlight Dungeon play agent (plan step 16)
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
## Status
Target, adopted 2026-09-22. Not implemented. The means will be in

View file

@ -1,7 +1,5 @@
# Ghostlight Dungeon Session Zero
> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main.
Status: adopted direction, 2026-09-15. Not implemented. Its role as the
playtest gate is superseded (operator, 2026-09-22) by the play agent,
`ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes

View file

@ -63,11 +63,6 @@ attributed proposal — is not part of this pass; see "Not in this pass" below.
## Transport
Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is
parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves
it. `ghostlight::admit_document` and `encode_receipt` are the contract a host
serves.
`POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in
`api_router` (`runtime.rs`). The handler gates loopback and content type
(`application/msgpack`) exactly as the snapshot route does, then hands the

View file

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

View file

@ -2,21 +2,25 @@
This is a pointer. It does not carry open work; the Eureka mind does.
## What exists
## What runs
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.
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.
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.
## How it deploys
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`.
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`.
## Where open work is
@ -31,10 +35,9 @@ 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-world-ontology.md`: the kernel's vocabulary and
3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture.
4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and
current mechanism.
4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority
architecture. History until Thing 2 revives it.
5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play
agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216).
It is history; the mind says which findings are still open.

View file

@ -1,8 +1,6 @@
# Ghostlight Verse: Map
> 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`
Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2`
(`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.
@ -11,15 +9,6 @@ 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?
@ -68,7 +57,7 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec
### Does main build and pass?
Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised:
Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised:
- Rust (image `eureka-verify-rust`), the recipe's own steps:
`ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed,
@ -213,67 +202,6 @@ 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.
@ -288,7 +216,6 @@ 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
@ -337,16 +264,3 @@ verbs as the target.
Dungeon tests that drive the real client, and nothing runs them. Fixing the
gate means a runner with both Rust and Node, which is Idunn's recipe and
runner surface: a follow-up, with each phase-1 Hands run doing them by hand.
- **A narrative resume has one detector, and it is the kernel** (ruling
`narrative-resume-relowers`). The caller's opportunity names the decision
and nothing more, in both lanes, through the one `same_decision`. The lanes
differ only in when the world is asked. The operational lane asks before each
round, because it has nothing to preserve. The narrative lane asks at submit,
because its prose and receipts survive to be lowered again. The price is the
remaining rounds plus at most one Interpreter call. `OpportunityMismatch`
then stops meaning a moved scope, so `classify` labels it `OpportunityGone`,
as the grouped lane already does. Keeping it on `ScopeMoved` would leave a
second, wrong answer to "did the scope move". The grouped selection label
becomes a pure function, because `AmbiguousOpportunity` cannot be built
through a live world, which offers one opportunity per scope. Pinning the
function is what closes follow-up `grouped-left-arms-pin`.

View file

@ -5,14 +5,8 @@ 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 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`.
replacement exists. The real-model gate that does exist is the Idunn recipe's
`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`).
What remains true and stays here: bringing up a real CodexConnector and a
built Claude SDK sidecar. The seed lane (`world.seed`) still runs real

View file

@ -2,8 +2,11 @@
"name": "ghostlight",
"private": true,
"version": "0.0.0",
"description": "Ghostlight: the Rust kernel for socially situated agents.",
"description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.",
"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"
},

117
tools/eve_client_bridge.mjs Normal file
View file

@ -0,0 +1,117 @@
// 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 Submodule

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

18
web/index.html Normal file
View file

@ -0,0 +1,18 @@
<!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 Normal file

File diff suppressed because it is too large Load diff

17
web/package.json Normal file
View file

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

View file

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

View file

@ -0,0 +1,139 @@
// 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 Normal file
View file

@ -0,0 +1,197 @@
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);

25
web/src/style.css Normal file
View file

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

View file

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

View file

@ -0,0 +1,36 @@
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.");

12
web/tsconfig.json Normal file
View file

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

15
web/vite.config.mjs Normal file
View file

@ -0,0 +1,15 @@
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),
),
},
},
});